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:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user