mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
feat(reports): publish per-stop times when loading & unloading groups by train
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ReportRunParams, "key" | "page" | "pageSize">;
|
||||
/** 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<string[]>(def.columns.map((c) => c.key));
|
||||
const [fields, setFields] = useState<string[]>(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) {
|
||||
</Button>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{def.columns.map((col) => (
|
||||
{columns.map((col) => (
|
||||
<Checkbox
|
||||
key={col.key}
|
||||
label={col.label}
|
||||
|
||||
@@ -173,9 +173,29 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R
|
||||
const total = data?.meta.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
/**
|
||||
* Columns the applied filters don't hide — see ReportColumn.hideWhen. A
|
||||
* filter the user hasn't touched counts as its declared default, which is
|
||||
* the value the server will have used to shape the rows.
|
||||
*/
|
||||
const visibleColumns = useMemo(() => {
|
||||
const applied = appliedParams as Record<string, unknown>;
|
||||
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<Record<string, unknown>>[] = 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
|
||||
</Text>
|
||||
),
|
||||
})),
|
||||
[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 ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={view}
|
||||
@@ -223,7 +243,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const exportButton = <ReportExportButton def={def} params={appliedParams} />;
|
||||
const exportButton = <ReportExportButton def={def} params={appliedParams} columns={visibleColumns} />;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -269,8 +289,8 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
{view === "chart" && def.chart ? (
|
||||
<ReportChart chart={def.chart} items={data?.items ?? []} columns={def.columns} total={total} />
|
||||
{view === "chart" && chartDef ? (
|
||||
<ReportChart chart={chartDef} items={data?.items ?? []} columns={visibleColumns} total={total} />
|
||||
) : (
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
|
||||
@@ -11,9 +11,16 @@ export interface ReportColumn {
|
||||
label: string;
|
||||
type: ReportColumnType;
|
||||
sortable?: boolean;
|
||||
/** Hide the column while these filter values are applied. */
|
||||
hideWhen?: Record<string, string>;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user