feat(reports): add optional chart view to the report engine

ReportDefinition gets an optional chart {type: line|bar, x, y[]} field —
plots the same rows the table gets, no separate query. Frontend adds a
table/chart toggle (defaults to table) using the existing recharts
dependency, no new package.

Chart view fetches up to 100 rows (the API's page-size ceiling) instead
of the table's current page, so it doesn't silently plot a fraction of
the filtered set; shows a truncation note past that cap.

Wired onto 5 reports as proof: wagon-fleet-status, locomotive-fleet-
status, booking-status-breakdown, revenue-summary (bar), and
global-logistics-wagons (line). Everything else stays table-only —
charting is opt-in per report, not a default.
This commit is contained in:
Nathnael
2026-08-13 08:34:08 +00:00
parent 6f14cf8bbf
commit efc5a24380
10 changed files with 175 additions and 30 deletions

View File

@@ -77,6 +77,7 @@ export const bookingStatusBreakdownReport: ReportDefinition = {
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'bookings', dir: 'DESC' },
chart: { type: 'bar', x: 'status', y: ['bookings'] },
query(ctx) {
return baseQuery(ctx)
.select('b.status', 'status')

View File

@@ -46,6 +46,7 @@ export const globalLogisticsWagonsReport: ReportDefinition = {
{ key: 'cancelled', label: 'Cancelled', type: 'number', sortable: true },
],
defaultSort: { key: 'date', dir: 'DESC' },
chart: { type: 'line', x: 'date', y: ['allocated', 'cancelled'] },
query(ctx) {
return baseQuery(ctx)
.select(`to_char(date_trunc('day', l.occurred_at), 'YYYY-MM-DD')`, 'date')

View File

@@ -32,6 +32,7 @@ export const locomotiveFleetStatusReport: ReportDefinition = {
{ key: 'count', label: 'Count', type: 'number', sortable: true },
],
defaultSort: { key: 'count', dir: 'DESC' },
chart: { type: 'bar', x: 'status', y: ['count'] },
query(ctx) {
return baseQuery(ctx)
.select('l.locomotive_type', 'locomotiveType')

View File

@@ -37,6 +37,7 @@ export const revenueSummaryReport: ReportDefinition = {
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
],
defaultSort: { key: 'revenue', dir: 'DESC' },
chart: { type: 'bar', x: 'direction', y: ['revenue'] },
query(ctx) {
return baseQuery(ctx)
.select('b.trade_direction', 'direction')

View File

@@ -38,6 +38,7 @@ export const wagonFleetStatusReport: ReportDefinition = {
{ key: 'count', label: 'Count', type: 'number', sortable: true },
],
defaultSort: { key: 'count', dir: 'DESC' },
chart: { type: 'bar', x: 'status', y: ['count'] },
query(ctx) {
return baseQuery(ctx)
.select('COALESCE(wt.name, \'Unknown\')', 'wagonType')

View File

@@ -38,4 +38,15 @@ describe('REPORTS', () => {
expect(def.filters.some((f) => f.key === def.idKey!.key)).toBe(false);
}
});
it('chart.x and chart.y, when declared, point at real column keys', () => {
for (const def of REPORTS) {
if (!def.chart) continue;
const columnKeys = new Set(def.columns.map((c) => c.key));
expect(columnKeys.has(def.chart.x)).toBe(true);
for (const y of def.chart.y) {
expect(columnKeys.has(y)).toBe(true);
}
}
});
});

View File

@@ -42,6 +42,21 @@ export interface ReportKpi {
unit?: string;
}
export type ReportChartType = 'line' | 'bar';
/**
* Plots the SAME rows the table gets — no separate query. `x` and `y` are
* column keys from `columns`. A report whose group-by has dimensions beyond
* `x` will render one mark per row (e.g. two rows sharing a date because they
* differ by direction), which is a busier chart, not a wrong one. Pivoting
* rows into one-per-x series is a later add if a report actually needs it.
*/
export interface ReportChartDef {
type: ReportChartType;
x: string;
y: string[];
}
/**
* Optional entity scope a report can be embedded against — e.g. a
* contract-utilization report shown on a single contract's detail page.
@@ -73,6 +88,8 @@ export interface ReportDefinition {
query(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral>;
/** KPIs over the same filtered set; shown above the table and in exports. */
summary?(ctx: ReportContext): Promise<ReportKpi[]>;
/** Optional chart view of the same rows. Table remains the default view. */
chart?: ReportChartDef;
}
/** Catalog shape served by GET /reports — metadata only, no rows. */

View File

@@ -0,0 +1,83 @@
import { Box, Text } from "@mantine/core";
import {
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { overviewChartColors } from "@/components/overview/overview.styles";
import type { ReportChartDef, ReportColumn } from "@/types/reports";
import { formatReportCell } from "./report-format";
interface ReportChartProps {
chart: ReportChartDef;
items: Record<string, unknown>[];
columns: ReportColumn[];
/** Filtered row count on the server. Chart is capped at 100 rows (the API's
* page-size ceiling) — surface it plainly rather than silently truncate. */
total?: number;
}
const COLORS = overviewChartColors.pipeline;
/** Plots the same rows the table gets — chart.x/chart.y are just column keys. */
export function ReportChart({ chart, items, columns, total }: ReportChartProps) {
const columnByKey = new Map(columns.map((c) => [c.key, c]));
const yLabel = (key: string) => columnByKey.get(key)?.label ?? key;
const yType = (key: string) => columnByKey.get(key)?.type ?? "number";
if (!items.length) {
return (
<Text size="sm" c="dimmed" ta="center" py="xl">
No data for the selected filters.
</Text>
);
}
const Chart = chart.type === "line" ? LineChart : BarChart;
const truncated = typeof total === "number" && total > items.length;
return (
<Box px="md" pb="md">
{truncated ? (
<Text size="xs" c="dimmed" mb="xs">
Showing first {items.length} of {total} rows. Narrow the filters to see the rest charted.
</Text>
) : null}
<ResponsiveContainer width="100%" height={320}>
<Chart data={items} margin={{ top: 8, right: 16, left: 0, bottom: 24 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey={chart.x}
tick={{ fontSize: 11 }}
angle={-20}
textAnchor="end"
height={50}
stroke="#94a3b8"
/>
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip formatter={(value, name) => [formatReportCell(value, yType(String(name))), yLabel(String(name))]} />
{chart.y.length > 1 ? <Legend formatter={(name) => yLabel(String(name))} /> : null}
{chart.y.map((key, i) =>
chart.type === "line" ? (
<Line key={key} type="monotone" dataKey={key} stroke={COLORS[i % COLORS.length]} strokeWidth={2} dot={false} />
) : (
<Bar key={key} dataKey={key} fill={COLORS[i % COLORS.length]} radius={[4, 4, 0, 0]} />
),
)}
</Chart>
</ResponsiveContainer>
</Box>
);
}
export default ReportChart;

View File

@@ -1,8 +1,8 @@
import { ActionIcon, Alert, Box, Card, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import type { Column, SortingState } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ArrowUpDown, FileSpreadsheet, FileText, RefreshCw } from "lucide-react";
import { ArrowDown, ArrowUp, ArrowUpDown, FileSpreadsheet, FileText, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { KpiStrip } from "@/components/page/KpiStrip";
@@ -11,6 +11,7 @@ import { reportsService } from "@/services/reports.service";
import type { ReportRunParams } from "@/types/reports";
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
import { ReportChart } from "./ReportChart";
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
import { formatKpiValue, formatReportCell } from "./report-format";
@@ -60,20 +61,24 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
const [exporting, setExporting] = useState<"xlsx" | "pdf" | null>(null);
const [view, setView] = useState<"table" | "chart">("table");
const runParams: ReportRunParams | undefined = useMemo(() => {
if (!def) return undefined;
const sort = sorting[0];
return {
key: def.key,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
// Chart view isn't paginated on screen — pull the server's max page (100)
// in one shot instead of just whatever page the table happens to be on,
// so the chart doesn't silently plot a fraction of the filtered rows.
page: view === "chart" ? 1 : pagination.pageIndex + 1,
pageSize: view === "chart" ? 100 : pagination.pageSize,
sortBy: sort?.id,
sortOrder: sort ? (sort.desc ? "DESC" : "ASC") : undefined,
...debouncedFilters,
...(def.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
};
}, [def, pagination, sorting, debouncedFilters, idKeyValue]);
}, [def, view, pagination, sorting, debouncedFilters, idKeyValue]);
const { data, isLoading, isError, isFetching, refetch } = useQuery({
...api.reports.run.queryOptions({ input: runParams as ReportRunParams }),
@@ -141,6 +146,17 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
}}
/>
<Group gap="xs">
{def.chart ? (
<SegmentedControl
size="xs"
value={view}
onChange={(v) => setView(v as "table" | "chart")}
data={[
{ label: <LayoutGrid size={14} />, value: "table" },
{ label: <LineChart size={14} />, value: "chart" },
]}
/>
) : null}
<Tooltip label="Export to Excel">
<ActionIcon
variant="default"
@@ -178,31 +194,35 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={data?.items ?? []}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No data for the selected filters."
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { sorting },
onSortingChange: setSorting,
onPaginationChange: setPagination,
manualPagination: true,
manualSorting: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
{view === "chart" && def.chart ? (
<ReportChart chart={def.chart} items={data?.items ?? []} columns={def.columns} total={total} />
) : (
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={data?.items ?? []}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No data for the selected filters."
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { sorting },
onSortingChange: setSorting,
onPaginationChange: setPagination,
manualPagination: true,
manualSorting: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>
</Card>
</Stack>

View File

@@ -38,6 +38,14 @@ export interface ReportKpi {
unit?: string;
}
export type ReportChartType = "line" | "bar";
export interface ReportChartDef {
type: ReportChartType;
x: string;
y: string[];
}
/** Mirrors the backend's ReportCatalogEntry — one entry per GET /reports item. */
export interface ReportCatalogEntry {
key: string;
@@ -49,6 +57,7 @@ export interface ReportCatalogEntry {
columns: ReportColumn[];
defaultSort?: { key: string; dir: "ASC" | "DESC" };
hasSummary: boolean;
chart?: ReportChartDef;
}
export interface ReportPageMeta {