From 6d1e4a630e57de24137e2af5efb22d28ceee0acc Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 25 Aug 2026 13:01:47 +0000 Subject: [PATCH] feat(reports): publish per-stop times when loading & unloading groups by train MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouped by Train, a row was a period average over the train's stops, which threw away the one thing that grain is for: when the work actually happened. The row is now the stop itself — logged arrival, departure, unloading and loading times, and that stop's own durations. Grouped by Station it still averages over every train that called there. The two shapes need different columns, so `ReportColumn.hideWhen` names the filter values that hide a column, and the runner resolves sort against the visible set — sorting by an average and then switching to Train falls back to the default sort instead of a 42703 on a column the query no longer selects. `ReportFilterDef.defaultValue` pins the unset grain to `train`, so "no value" is never a third shape. The table, the export field list and the chart toggle all follow the visible set; the chart is station-grain only, since per-stop rows have nothing to bar-chart. Both grains EXPLAIN-validated and run against the dev database. Co-Authored-By: Claude Opus 5 (1M context) --- .../loading-unloading.report.spec.ts | 30 ++ .../definitions/loading-unloading.report.ts | 310 ++++++++++++++---- .../modules/reports/report-runner.service.ts | 27 +- .../src/modules/reports/report.types.ts | 12 + .../components/reports/ReportExportButton.tsx | 20 +- .../src/components/reports/ReportView.tsx | 32 +- .../backoffice/src/types/reports.ts | 11 +- 7 files changed, 364 insertions(+), 78 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.spec.ts diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.spec.ts b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.spec.ts new file mode 100644 index 000000000..b5b72a06b --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.spec.ts @@ -0,0 +1,30 @@ +import { visibleColumns } from '../report-runner.service'; +import { loadingUnloadingReport as def } from './loading-unloading.report'; + +/** + * The two grains select different columns — per train the stop's own times, + * per station the averages over it. A column shown under a grain its query + * doesn't select is a blank column; a column SORTED under one is a 42703. + */ +describe('loading-unloading', () => { + const keys = (grain: string) => visibleColumns(def, { grain }).map((c) => c.key); + + it('shows the stop times per train and the averages per station, never both', () => { + expect(keys('train')).toEqual(expect.arrayContaining(['arrivedAt', 'loadUnloadHours'])); + expect(keys('train')).not.toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops'])); + expect(keys('station')).toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops'])); + expect(keys('station')).not.toEqual(expect.arrayContaining(['arrivedAt', 'trainNumber'])); + }); + + it('sorts by a column both grains select, so the default sort never 42703s', () => { + for (const grain of ['train', 'station']) { + expect(keys(grain)).toContain(def.defaultSort!.key); + } + }); + + it("defaults the grain, so an unset filter can't show the wrong half", () => { + const grain = def.filters.find((f) => f.key === 'grain')!.defaultValue; + expect(grain).toBe('train'); + expect(keys(grain!)).toEqual(keys('train')); + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts index 753f71d3a..aa7564b39 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts @@ -6,8 +6,10 @@ import { cycleRateExpr, handlingHours, hoursBetween, + loadingEnd, loadingHours, loadingSource, + loadingStart, otherActivityHours, stationStaysQb, unloadingHours, @@ -18,9 +20,12 @@ import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-class * Loading and unloading per train — the spec's own report format: train number, * total loading and unloading time, other activity, station staying time. * - * The staying-time report publishes one row per individual stop; this one rolls - * a train's stops up into the chosen period, which is what "for week report, - * calculate average in the week" asks for. The station stays in the grain + * Two shapes, one definition. Per train the row is the stop itself: the logged + * arrival, departure, unloading and loading times and that stop's own + * durations, because the train number is what makes a specific stop worth + * naming. Per station it rolls up into the chosen period — one row per station, + * averaged over every train that called there, which is what "for week report, + * calculate average in the week" asks for. The station stays in both grains * because a train works both ends of the corridor and the standard it is judged * against differs by side (10h Ethiopia, 13h Djibouti) — averaging a train's * Nagad and Gelan stops together would compare that mixture to one standard. @@ -56,12 +61,20 @@ const GRAIN_FILTER: ReportFilterDef = { key: 'grain', label: 'Group by', type: 'select', + defaultValue: 'train', options: [ { value: 'train', label: 'Train' }, { value: 'station', label: 'Station' }, ], }; +/** Per train the rows are stops, so they carry times; per station, averages. */ +const TRAIN_ONLY = { grain: 'station' }; +const STATION_ONLY = { grain: 'train' }; + +/** Same display as the staying-time report, so a stop reads alike in both. */ +const at = (expr: string): string => `to_char(${expr}, 'YYYY-MM-DD HH24:MI')`; + /** Whitelisted here, so the user's value never reaches SQL. */ const byStation = (ctx: ReportContext): boolean => ctx.params.grain === 'station'; @@ -69,9 +82,10 @@ export const loadingUnloadingReport: ReportDefinition = { key: 'loading-unloading', title: 'Loading & Unloading', description: - 'Loading and unloading per train, at the granularity you choose — one row per train per ' + - 'station per period, which at week or month grain is that train’s average over its stops ' + - 'in the period, the way the OCC report publishes it. Total loading and unloading is ' + + 'Loading and unloading, at the granularity you choose. Grouped by Train the row is one ' + + 'stop — its logged arrival, departure, unloading and loading times and that stop’s own ' + + 'durations. Grouped by Station it is one row per station per period, averaged over every ' + + 'train that called there, the way the OCC report publishes it. Total loading and unloading is ' + 'the stop’s handling window, unloading start to loading end, which is the container ' + 'measure; the unloading and loading columns split it for bulk stations that only do ' + 'one of the two (Nagad, BCC and DMP on the Djibouti side; Sebeta, GMP, Adama and Modjo ' + @@ -95,74 +109,241 @@ export const loadingUnloadingReport: ReportDefinition = { ], columns: [ { key: 'period', label: 'Period', type: 'string', sortable: true }, - { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true }, + { + key: 'trainNumber', + label: 'Train No.', + type: 'string', + sortable: true, + hideWhen: TRAIN_ONLY, + }, { key: 'station', label: 'Station', type: 'string', sortable: true }, { key: 'country', label: 'Country', type: 'string' }, - { key: 'trainType', label: 'Train type', type: 'string' }, - { key: 'stops', label: 'Stops', type: 'number', sortable: true }, - { key: 'handlingMeasured', label: 'Handling measured', type: 'number' }, + // Per station this would be a MAX over whatever mix of trains called there. + { + key: 'trainType', + label: 'Train type', + type: 'string', + hideWhen: TRAIN_ONLY, + }, + { + key: 'stops', + label: 'Stops', + type: 'number', + sortable: true, + hideWhen: STATION_ONLY, + }, + { + key: 'handlingMeasured', + label: 'Handling measured', + type: 'number', + hideWhen: STATION_ONLY, + }, { key: 'loadingSource', label: 'Loading from', type: 'string' }, - { key: 'avgUnloadingHours', label: 'Avg unloading (hrs)', type: 'number', sortable: true }, - { key: 'avgLoadingHours', label: 'Avg loading (hrs)', type: 'number', sortable: true }, + // Per train: this stop's own clock, not a mean of several. + { + key: 'arrivedAt', + label: 'Arrived', + type: 'date', + sortable: true, + hideWhen: TRAIN_ONLY, + }, + { + key: 'departedAt', + label: 'Departed', + type: 'date', + hideWhen: TRAIN_ONLY, + }, + { + key: 'unloadingStartedAt', + label: 'Unloading start', + type: 'date', + hideWhen: TRAIN_ONLY, + }, + { + key: 'unloadingCompletedAt', + label: 'Unloading end', + type: 'date', + hideWhen: TRAIN_ONLY, + }, + { + key: 'loadingStartedAt', + label: 'Loading start', + type: 'date', + hideWhen: TRAIN_ONLY, + }, + { + key: 'loadingCompletedAt', + label: 'Loading end', + type: 'date', + hideWhen: TRAIN_ONLY, + }, + { + key: 'unloadingHours', + label: 'Unloading (hrs)', + type: 'number', + sortable: true, + hideWhen: TRAIN_ONLY, + }, + { + key: 'loadingHours', + label: 'Loading (hrs)', + type: 'number', + sortable: true, + hideWhen: TRAIN_ONLY, + }, + { + key: 'loadUnloadHours', + label: 'Loading + unloading (hrs)', + type: 'number', + sortable: true, + hideWhen: TRAIN_ONLY, + }, + { + key: 'otherActivityHours', + label: 'Other activity (hrs)', + type: 'number', + hideWhen: TRAIN_ONLY, + }, + { + key: 'stayingHours', + label: 'Staying (hrs)', + type: 'number', + sortable: true, + hideWhen: TRAIN_ONLY, + }, + { + key: 'avgUnloadingHours', + label: 'Avg unloading (hrs)', + type: 'number', + sortable: true, + hideWhen: STATION_ONLY, + }, + { + key: 'avgLoadingHours', + label: 'Avg loading (hrs)', + type: 'number', + sortable: true, + hideWhen: STATION_ONLY, + }, { key: 'avgLoadUnloadHours', label: 'Avg loading + unloading (hrs)', type: 'number', sortable: true, + hideWhen: STATION_ONLY, + }, + { + key: 'avgOtherActivityHours', + label: 'Avg other activity (hrs)', + type: 'number', + hideWhen: STATION_ONLY, + }, + { + key: 'avgStayingHours', + label: 'Avg staying (hrs)', + type: 'number', + sortable: true, + hideWhen: STATION_ONLY, + }, + { + key: 'stayStandardHours', + label: 'Staying standard (hrs)', + type: 'number', }, - { key: 'avgOtherActivityHours', label: 'Avg other activity (hrs)', type: 'number' }, - { key: 'avgStayingHours', label: 'Avg staying (hrs)', type: 'number', sortable: true }, - { key: 'stayStandardHours', label: 'Staying standard (hrs)', type: 'number' }, { key: 'stayVerdict', label: 'Staying verdict', type: 'string' }, - { key: 'handlingStandardHours', label: 'Handling standard (hrs)', type: 'number' }, - { key: 'handlingRate', label: 'Handling rate', type: 'percent', sortable: true }, + { + key: 'handlingStandardHours', + label: 'Handling standard (hrs)', + type: 'number', + }, + { + key: 'handlingRate', + label: 'Handling rate', + type: 'percent', + sortable: true, + }, ], defaultSort: { key: 'period', dir: 'DESC' }, - chart: { type: 'bar', x: 'trainNumber', y: ['avgLoadUnloadHours'] }, + // Only plottable at station grain — per train the rows are individual stops, + // and the frontend drops the chart toggle when its columns are hidden. + chart: { type: 'bar', x: 'station', y: ['avgLoadUnloadHours'] }, query(ctx) { const { params } = ctx; + + // Per train the row IS the stop: its own logged times and its own + // durations, since an average of one stop is just the stop with the clock + // thrown away. Averaging starts where the grain stops naming the train. + if (!byStation(ctx)) { + return stationStaysQb(ctx) + .select(periodExprOn('s.arrived_at', params), 'period') + .addSelect(TRAIN_NUMBER, 'trainNumber') + .addSelect('s.station', 'station') + .addSelect('s.country', 'country') + .addSelect('s.train_type', 'trainType') + .addSelect(loadingSource('s'), 'loadingSource') + .addSelect(at('s.arrived_at'), 'arrivedAt') + .addSelect(at('s.departed_at'), 'departedAt') + .addSelect(at('s.unloading_started_at'), 'unloadingStartedAt') + .addSelect(at('s.unloading_completed_at'), 'unloadingCompletedAt') + .addSelect(at(loadingStart('s')), 'loadingStartedAt') + .addSelect(at(loadingEnd('s')), 'loadingCompletedAt') + .addSelect(unloadingHours('s'), 'unloadingHours') + .addSelect(loadingHours('s'), 'loadingHours') + .addSelect(HANDLING_HOURS, 'loadUnloadHours') + .addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours') + .addSelect(STAYING_HOURS, 'stayingHours') + .addSelect('s.standard_hours::float8', 'stayStandardHours') + .addSelect( + `CASE WHEN (${STAYING_HOURS})::numeric <= s.standard_hours + THEN 'Encouraging' ELSE 'Needs reason' END`, + 'stayVerdict', + ) + .addSelect('s.handling_standard_hours::float8', 'handlingStandardHours') + .addSelect( + cycleRateExpr(`(${HANDLING_HOURS})::numeric`, 's.handling_standard_hours'), + 'handlingRate', + ); + } + // Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`. const bucket = periodTruncExprOn('s.arrived_at', params); - const perStation = byStation(ctx); - const qb = stationStaysQb(ctx) - .select(periodExprOn('s.arrived_at', params), 'period') - .addSelect(perStation ? "'All trains'" : TRAIN_NUMBER, 'trainNumber') - .addSelect('s.station', 'station') - .addSelect('s.country', 'country') - .addSelect('MAX(s.train_type)', 'trainType') - .addSelect('COUNT(*)::int', 'stops') - .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured') - // Which side of the COALESCE the loading columns came from. A group that - // mixes both says so rather than claiming either. - .addSelect( - `CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed' + return ( + stationStaysQb(ctx) + .select(periodExprOn('s.arrived_at', params), 'period') + .addSelect('s.station', 'station') + .addSelect('s.country', 'country') + .addSelect('COUNT(*)::int', 'stops') + .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured') + // Which side of the COALESCE the loading columns came from. A group that + // mixes both says so rather than claiming either. + .addSelect( + `CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed' ELSE MAX(${loadingSource('s')}) END`, - 'loadingSource', - ) - .addSelect(avg(unloadingHours('s')), 'avgUnloadingHours') - .addSelect(avg(loadingHours('s')), 'avgLoadingHours') - .addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours') - .addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours') - .addSelect(avg(STAYING_HOURS), 'avgStayingHours') - .addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours') - .addSelect( - `CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours) + 'loadingSource', + ) + .addSelect(avg(unloadingHours('s')), 'avgUnloadingHours') + .addSelect(avg(loadingHours('s')), 'avgLoadingHours') + .addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours') + .addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours') + .addSelect(avg(STAYING_HOURS), 'avgStayingHours') + .addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours') + .addSelect( + `CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours) THEN 'Encouraging' ELSE 'Needs reason' END`, - 'stayVerdict', - ) - .addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours') - // Same formula the turnaround cycle publishes, so the two read alike. - // NULL standard in, NULL rate out — nothing to measure against yet. - .addSelect( - cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD), - 'handlingRate', - ) - .groupBy(bucket) - .addGroupBy('s.station') - .addGroupBy('s.country'); - if (!perStation) qb.addGroupBy(TRAIN_NUMBER); - return qb; + 'stayVerdict', + ) + .addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours') + // Same formula the turnaround cycle publishes, so the two read alike. + // NULL standard in, NULL rate out — nothing to measure against yet. + .addSelect( + cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD), + 'handlingRate', + ) + .groupBy(bucket) + .addGroupBy('s.station') + .addGroupBy('s.country') + ); }, async summary(ctx) { const row = await stationStaysQb(ctx) @@ -170,13 +351,26 @@ export const loadingUnloadingReport: ReportDefinition = { .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'measured') .addSelect(avg(HANDLING_HOURS), 'avgHandling') .addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther') - .getRawOne<{ stops: number; measured: number; avgHandling: number; avgOther: number }>(); + .getRawOne<{ + stops: number; + measured: number; + avgHandling: number; + avgOther: number; + }>(); return [ { label: 'Stops measured', value: Number(row?.stops ?? 0) }, { label: 'Handling measured', value: Number(row?.measured ?? 0) }, - { label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' }, - { label: 'Average other activity', value: Number(row?.avgOther ?? 0), unit: 'h' }, + { + label: 'Average loading + unloading', + value: Number(row?.avgHandling ?? 0), + unit: 'h', + }, + { + label: 'Average other activity', + value: Number(row?.avgOther ?? 0), + unit: 'h', + }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts index d38a5ba8a..350fdc4ee 100644 --- a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -7,7 +7,7 @@ import { normalizePagination, } from '../../common/utils/pagination.util'; import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util'; -import { ReportDefinition, ReportRunResult } from './report.types'; +import { ReportColumn, ReportDefinition, ReportRunResult } from './report.types'; const DAY_MS = 24 * 60 * 60 * 1000; @@ -38,7 +38,7 @@ function coerceParams( const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? []; params[filter.key] = items.length ? items : null; } else { - params[filter.key] = raw[filter.key]?.trim() || null; + params[filter.key] = raw[filter.key]?.trim() || filter.defaultValue || null; } } // idKey, when the report declares one, is a plain string param. @@ -57,19 +57,34 @@ function coerceParams( */ const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`; +/** + * Columns the current filter values don't hide. A hidden column is not in the + * SELECT list of the shape those filters produce, so sorting by one would be a + * 42703 — the sort falls back to the default instead. + */ +export const visibleColumns = ( + def: ReportDefinition, + params: Record, +): ReportColumn[] => + def.columns.filter((c) => + Object.entries(c.hideWhen ?? {}).every(([key, value]) => params[key] !== value), + ); + /** Resolve a client-requested sort column against the report's own whitelist. */ function resolveSort( def: ReportDefinition, + params: Record, sortBy?: string, sortOrder?: string, ): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null { const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; - const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable); + const columns = visibleColumns(def, params); + const requested = sortBy && columns.find((c) => c.key === sortBy && c.sortable); if (requested) { return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir }; } if (!def.defaultSort) return null; - const fallback = def.columns.find((c) => c.key === def.defaultSort!.key); + const fallback = columns.find((c) => c.key === def.defaultSort!.key); if (!fallback) return null; return { key: fallback.key, @@ -91,7 +106,7 @@ export class ReportRunnerService { const ctx = { ds: this.ds, params, directions }; const qb = def.query(ctx); - const sort = resolveSort(def, raw.sortBy, raw.sortOrder); + const sort = resolveSort(def, params, raw.sortBy, raw.sortOrder); if (sort) qb.orderBy(sort.expr, sort.dir); const { page: pageNum, pageSize, skip, take } = normalizePagination({ @@ -141,7 +156,7 @@ export class ReportRunnerService { const qb = def.query(ctx); // Same sort the on-screen table is using, not always the default — an // export is supposed to match what the user is looking at. - const sort = resolveSort(def, raw.sortBy, raw.sortOrder); + const sort = resolveSort(def, params, raw.sortBy, raw.sortOrder); if (sort) qb.orderBy(sort.expr, sort.dir); const ceiling = limit ?? cap; diff --git a/apps/edr-freight-api/src/modules/reports/report.types.ts b/apps/edr-freight-api/src/modules/reports/report.types.ts index ca7578461..7f1758164 100644 --- a/apps/edr-freight-api/src/modules/reports/report.types.ts +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -19,6 +19,12 @@ export interface ReportColumn { sortable?: boolean; /** SQL to ORDER BY when this column is sorted, if different from `key`. */ sortExpr?: string; + /** + * Hide the column while a filter holds a given value — how one report serves + * two group-by grains without two column lists. Display only: the value is + * still selected, exported and sortable, it just isn't shown. + */ + hideWhen?: Record; } export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; @@ -34,6 +40,12 @@ export interface ReportFilterDef { type: ReportFilterType; /** Static option list for select/multiselect. */ options?: ReportFilterOption[]; + /** + * Value the filter takes when the client sends nothing — so a report whose + * shape depends on a filter (see `ReportColumn.hideWhen`) never has to guess + * what "unset" meant. + */ + defaultValue?: string; /** * Resolves the option list from the database instead of declaring it inline — * for filters whose choices are reference data (stations, cargo types). diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx index 76ff7d628..89cd638a6 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx @@ -1,14 +1,16 @@ import { Button, Checkbox, Group, Modal, Radio, Select, SimpleGrid, Stack, Text } from "@mantine/core"; import { Download, FileSpreadsheet, FileText } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { reportsService } from "@/services/reports.service"; -import type { ReportCatalogEntry, ReportRunParams } from "@/types/reports"; +import type { ReportCatalogEntry, ReportColumn, ReportRunParams } from "@/types/reports"; interface ReportExportButtonProps { def: ReportCatalogEntry; /** Filters + sort currently applied on screen — no key/page/pageSize. */ params: Omit; + /** The columns on screen, which for a report with `hideWhen` is not all of them. */ + columns: ReportColumn[]; } const RECORD_OPTIONS = [ @@ -31,17 +33,21 @@ function saveBlob(blob: Blob, filename: string) { /** One export button: format, which fields, how many records — applies the * filters/sort already on screen. Record count defaults to all (capped * server-side per format). */ -export function ReportExportButton({ def, params }: ReportExportButtonProps) { +export function ReportExportButton({ def, params, columns }: ReportExportButtonProps) { const [opened, setOpened] = useState(false); const [format, setFormat] = useState<"xlsx" | "pdf">("xlsx"); - const [fields, setFields] = useState(def.columns.map((c) => c.key)); + const [fields, setFields] = useState(columns.map((c) => c.key)); const [records, setRecords] = useState("all"); const [exporting, setExporting] = useState(false); - const allSelected = fields.length === def.columns.length; + // A filter change can change which columns exist at all — start over from the + // new set rather than exporting keys the query no longer selects. + useEffect(() => setFields(columns.map((c) => c.key)), [columns]); + + const allSelected = fields.length === columns.length; const toggleField = (key: string) => setFields((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); - const toggleAll = () => setFields(allSelected ? [] : def.columns.map((c) => c.key)); + const toggleAll = () => setFields(allSelected ? [] : columns.map((c) => c.key)); const handleDownload = async () => { setExporting(true); @@ -110,7 +116,7 @@ export function ReportExportButton({ def, params }: ReportExportButtonProps) { - {def.columns.map((col) => ( + {columns.map((col) => ( { + const applied = appliedParams as Record; + const valueOf = (key: string) => applied[key] ?? def?.filters.find((f) => f.key === key)?.defaultValue; + return (def?.columns ?? []).filter((col) => + Object.entries(col.hideWhen ?? {}).every(([key, value]) => valueOf(key) !== value), + ); + }, [def?.columns, def?.filters, appliedParams]); + + /** A chart whose x or y column is hidden has nothing to plot — drop the toggle. */ + const chartDef = useMemo(() => { + if (!def?.chart) return undefined; + const shown = new Set(visibleColumns.map((c) => c.key)); + return shown.has(def.chart.x) && def.chart.y.every((k) => shown.has(k)) ? def.chart : undefined; + }, [def?.chart, visibleColumns]); + const columns: ColumnDef>[] = useMemo( () => - (def?.columns ?? []).map((col) => ({ + visibleColumns.map((col) => ({ id: col.key, accessorKey: col.key, header: col.sortable @@ -188,7 +208,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R ), })), - [def?.columns], + [visibleColumns], ); if (!def) { @@ -197,7 +217,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R ) : null; } - const chartToggle = def.chart ? ( + const chartToggle = chartDef ? ( ); - const exportButton = ; + const exportButton = ; return ( @@ -269,8 +289,8 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R - {view === "chart" && def.chart ? ( - + {view === "chart" && chartDef ? ( + ) : ( ; } -export type ReportFilterType = "daterange" | "date" | "select" | "multiselect" | "text"; +export type ReportFilterType = + | "daterange" + | "date" + | "select" + | "multiselect" + | "text"; export interface ReportFilterOption { value: string; @@ -25,6 +32,8 @@ export interface ReportFilterDef { label: string; type: ReportFilterType; options?: ReportFilterOption[]; + /** Value the server assumes when the filter is unset. */ + defaultValue?: string; } export interface ReportIdKey {