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 { 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<string, unknown> {
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<DateRange>('30d');
const [statusMixMode, setStatusMixMode] = useState<'absolute' | 'proportional'>('proportional');
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
const [filterValues, setFilterValues] = useState<FilterValues>({});
// 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<OperationalReportPayload>({
queryKey: ['reports', 'operational', bounds.from.toISOString(), bounds.to.toISOString()],
queryKey: [
'reports',
'operational',
bounds.from.toISOString(),
bounds.to.toISOString(),
filterQs,
],
queryFn: () =>
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,
});
@@ -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<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 {
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={
<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} />
<ReportTemplatesButton<OperationalTemplateConfig>
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 */}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{query.isLoading || !data ? (