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