feat(reports): operational Area filter (FilterBar + query + template scope)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 10:21:57 +02:00
parent 91d8ee226b
commit 0ee3cd6073

View File

@@ -25,6 +25,11 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DateRangePicker } from '@/components/dashboard/date-range-picker'; 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 { ReportExportButton } from '@/components/reports/shared/report-export-button';
import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button'; import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button';
import { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency'; import { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency';
@@ -172,6 +177,7 @@ interface OperationalTemplateConfig extends Record<string, unknown> {
kind: 'operational'; kind: 'operational';
range: DateRange; range: DateRange;
statusMixMode: 'absolute' | 'proportional'; statusMixMode: 'absolute' | 'proportional';
filters?: FilterValues;
} }
export function OperationalReportClient({ portSlug }: { portSlug: string }) { export function OperationalReportClient({ portSlug }: { portSlug: string }) {
@@ -181,6 +187,7 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) {
const [range, setRange] = useState<DateRange>('30d'); const [range, setRange] = useState<DateRange>('30d');
const [statusMixMode, setStatusMixMode] = useState<'absolute' | 'proportional'>('proportional'); const [statusMixMode, setStatusMixMode] = useState<'absolute' | 'proportional'>('proportional');
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId); const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
const [filterValues, setFilterValues] = useState<FilterValues>({});
// User-driven setters clear the active-template badge; template // User-driven setters clear the active-template badge; template
// apply uses the raw setters so it doesn't immediately clear its // apply uses the raw setters so it doesn't immediately clear its
@@ -195,23 +202,47 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) {
setActiveTemplateId(null); 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( const currentConfig: OperationalTemplateConfig = useMemo(
() => ({ kind: 'operational', range, statusMixMode }), () => ({ kind: 'operational', range, statusMixMode, filters: filterValues }),
[range, statusMixMode], [range, statusMixMode, filterValues],
); );
const handleApplyTemplate = useCallback((config: OperationalTemplateConfig) => { const handleApplyTemplate = useCallback((config: OperationalTemplateConfig) => {
if (config.range) setRange(config.range); if (config.range) setRange(config.range);
if (config.statusMixMode) setStatusMixMode(config.statusMixMode); if (config.statusMixMode) setStatusMixMode(config.statusMixMode);
setFilterValues(config.filters ?? {});
}, []); }, []);
const bounds = useMemo(() => rangeToBounds(range), [range]); 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<OperationalReportPayload>({ const query = useQuery<OperationalReportPayload>({
queryKey: ['reports', 'operational', bounds.from.toISOString(), bounds.to.toISOString()], queryKey: [
'reports',
'operational',
bounds.from.toISOString(),
bounds.to.toISOString(),
filterQs,
],
queryFn: () => queryFn: () =>
apiFetch<OperationalReportPayload>( apiFetch<OperationalReportPayload>(
`/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, staleTime: 30_000,
}); });
@@ -219,6 +250,19 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) {
const data = query.data?.data; const data = query.data?.data;
const tenanciesOn = data?.kpis.tenanciesModuleEnabled ?? false; const tenanciesOn = data?.kpis.tenanciesModuleEnabled ?? false;
const areaOptions = query.data?.data.areaOptions;
const filterDefs = useMemo<FilterDefinition[]>(() => {
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 { function buildExportPayload(): ReportPayload {
if (!data) throw new Error('Report still loading'); if (!data) throw new Error('Report still loading');
return { return {
@@ -342,6 +386,14 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) {
description="Berth utilisation, tenancy lifecycle, signing turnaround, operational bottlenecks." description="Berth utilisation, tenancy lifecycle, signing turnaround, operational bottlenecks."
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{filterDefs.length > 0 ? (
<FilterBar
filters={filterDefs}
values={filterValues}
onChange={handleFilterChange}
onClear={handleFiltersClear}
/>
) : null}
<DateRangePicker value={range} onChange={handleRangeChange} /> <DateRangePicker value={range} onChange={handleRangeChange} />
<ReportTemplatesButton<OperationalTemplateConfig> <ReportTemplatesButton<OperationalTemplateConfig>
kind="operational" kind="operational"
@@ -356,6 +408,16 @@ export function OperationalReportClient({ portSlug }: { portSlug: string }) {
} }
/> />
{Array.isArray(filterValues.area) && filterValues.area.length > 0 ? (
<p className="text-xs text-muted-foreground">
Berth surfaces (KPIs, occupancy, vacant lists) scoped to:{' '}
<span className="font-medium text-foreground">
{(filterValues.area as string[]).join(', ')}
</span>
. Trend and tenancy panels show the full port.
</p>
) : null}
{/* KPI strip */} {/* KPI strip */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{query.isLoading || !data ? ( {query.isLoading || !data ? (