From 0ee3cd6073e105d55e33c54225d54912f821e25c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 2 Jun 2026 10:21:57 +0200 Subject: [PATCH] feat(reports): operational Area filter (FilterBar + query + template scope) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../operational/operational-report-client.tsx | 70 +++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/src/components/reports/operational/operational-report-client.tsx b/src/components/reports/operational/operational-report-client.tsx index 581e2c69..1a55ad0a 100644 --- a/src/components/reports/operational/operational-report-client.tsx +++ b/src/components/reports/operational/operational-report-client.tsx @@ -25,6 +25,11 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { DateRangePicker } from '@/components/dashboard/date-range-picker'; +import { + FilterBar, + type FilterDefinition, + type FilterValues, +} from '@/components/shared/filter-bar'; import { ReportExportButton } from '@/components/reports/shared/report-export-button'; import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button'; import { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency'; @@ -172,6 +177,7 @@ interface OperationalTemplateConfig extends Record { kind: 'operational'; range: DateRange; statusMixMode: 'absolute' | 'proportional'; + filters?: FilterValues; } export function OperationalReportClient({ portSlug }: { portSlug: string }) { @@ -181,6 +187,7 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) { const [range, setRange] = useState('30d'); const [statusMixMode, setStatusMixMode] = useState<'absolute' | 'proportional'>('proportional'); const [activeTemplateId, setActiveTemplateId] = useState(initialTemplateId); + const [filterValues, setFilterValues] = useState({}); // User-driven setters clear the active-template badge; template // apply uses the raw setters so it doesn't immediately clear its @@ -195,23 +202,47 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) { setActiveTemplateId(null); }, []); + const handleFilterChange = useCallback((key: string, value: unknown) => { + setFilterValues((prev) => ({ ...prev, [key]: value })); + setActiveTemplateId(null); + }, []); + + const handleFiltersClear = useCallback(() => { + setFilterValues({}); + setActiveTemplateId(null); + }, []); + const currentConfig: OperationalTemplateConfig = useMemo( - () => ({ kind: 'operational', range, statusMixMode }), - [range, statusMixMode], + () => ({ kind: 'operational', range, statusMixMode, filters: filterValues }), + [range, statusMixMode, filterValues], ); const handleApplyTemplate = useCallback((config: OperationalTemplateConfig) => { if (config.range) setRange(config.range); if (config.statusMixMode) setStatusMixMode(config.statusMixMode); + setFilterValues(config.filters ?? {}); }, []); const bounds = useMemo(() => rangeToBounds(range), [range]); + const filterQs = useMemo(() => { + const areas = filterValues.area; + return Array.isArray(areas) && areas.length > 0 + ? `&area=${encodeURIComponent(areas.join(','))}` + : ''; + }, [filterValues]); + const query = useQuery({ - queryKey: ['reports', 'operational', bounds.from.toISOString(), bounds.to.toISOString()], + queryKey: [ + 'reports', + 'operational', + bounds.from.toISOString(), + bounds.to.toISOString(), + filterQs, + ], queryFn: () => apiFetch( - `/api/v1/reports/operational?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}`, + `/api/v1/reports/operational?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}${filterQs}`, ), staleTime: 30_000, }); @@ -219,6 +250,19 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) { const data = query.data?.data; const tenanciesOn = data?.kpis.tenanciesModuleEnabled ?? false; + const areaOptions = query.data?.data.areaOptions; + const filterDefs = useMemo(() => { + if (!areaOptions || areaOptions.length === 0) return []; + return [ + { + key: 'area', + label: 'Berth area', + type: 'multi-select', + options: areaOptions.map((a) => ({ value: a, label: a })), + }, + ]; + }, [areaOptions]); + function buildExportPayload(): ReportPayload { if (!data) throw new Error('Report still loading'); return { @@ -342,6 +386,14 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) { description="Berth utilisation, tenancy lifecycle, signing turnaround, operational bottlenecks." actions={
+ {filterDefs.length > 0 ? ( + + ) : null} kind="operational" @@ -356,6 +408,16 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) { } /> + {Array.isArray(filterValues.area) && filterValues.area.length > 0 ? ( +

+ Berth surfaces (KPIs, occupancy, vacant lists) scoped to:{' '} + + {(filterValues.area as string[]).join(', ')} + + . Trend and tenancy panels show the full port. +

+ ) : null} + {/* KPI strip */}
{query.isLoading || !data ? (