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 {