mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -77,6 +77,7 @@ export const bookingStatusBreakdownReport: ReportDefinition = {
|
|||||||
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
|
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
|
||||||
],
|
],
|
||||||
defaultSort: { key: 'bookings', dir: 'DESC' },
|
defaultSort: { key: 'bookings', dir: 'DESC' },
|
||||||
|
chart: { type: 'bar', x: 'status', y: ['bookings'] },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return baseQuery(ctx)
|
||||||
.select('b.status', 'status')
|
.select('b.status', 'status')
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const globalLogisticsWagonsReport: ReportDefinition = {
|
|||||||
{ key: 'cancelled', label: 'Cancelled', type: 'number', sortable: true },
|
{ key: 'cancelled', label: 'Cancelled', type: 'number', sortable: true },
|
||||||
],
|
],
|
||||||
defaultSort: { key: 'date', dir: 'DESC' },
|
defaultSort: { key: 'date', dir: 'DESC' },
|
||||||
|
chart: { type: 'line', x: 'date', y: ['allocated', 'cancelled'] },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return baseQuery(ctx)
|
||||||
.select(`to_char(date_trunc('day', l.occurred_at), 'YYYY-MM-DD')`, 'date')
|
.select(`to_char(date_trunc('day', l.occurred_at), 'YYYY-MM-DD')`, 'date')
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const locomotiveFleetStatusReport: ReportDefinition = {
|
|||||||
{ key: 'count', label: 'Count', type: 'number', sortable: true },
|
{ key: 'count', label: 'Count', type: 'number', sortable: true },
|
||||||
],
|
],
|
||||||
defaultSort: { key: 'count', dir: 'DESC' },
|
defaultSort: { key: 'count', dir: 'DESC' },
|
||||||
|
chart: { type: 'bar', x: 'status', y: ['count'] },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return baseQuery(ctx)
|
||||||
.select('l.locomotive_type', 'locomotiveType')
|
.select('l.locomotive_type', 'locomotiveType')
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const revenueSummaryReport: ReportDefinition = {
|
|||||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||||
],
|
],
|
||||||
defaultSort: { key: 'revenue', dir: 'DESC' },
|
defaultSort: { key: 'revenue', dir: 'DESC' },
|
||||||
|
chart: { type: 'bar', x: 'direction', y: ['revenue'] },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return baseQuery(ctx)
|
||||||
.select('b.trade_direction', 'direction')
|
.select('b.trade_direction', 'direction')
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const wagonFleetStatusReport: ReportDefinition = {
|
|||||||
{ key: 'count', label: 'Count', type: 'number', sortable: true },
|
{ key: 'count', label: 'Count', type: 'number', sortable: true },
|
||||||
],
|
],
|
||||||
defaultSort: { key: 'count', dir: 'DESC' },
|
defaultSort: { key: 'count', dir: 'DESC' },
|
||||||
|
chart: { type: 'bar', x: 'status', y: ['count'] },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
return baseQuery(ctx)
|
return baseQuery(ctx)
|
||||||
.select('COALESCE(wt.name, \'Unknown\')', 'wagonType')
|
.select('COALESCE(wt.name, \'Unknown\')', 'wagonType')
|
||||||
|
|||||||
@@ -38,4 +38,15 @@ describe('REPORTS', () => {
|
|||||||
expect(def.filters.some((f) => f.key === def.idKey!.key)).toBe(false);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ export interface ReportKpi {
|
|||||||
unit?: string;
|
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
|
* Optional entity scope a report can be embedded against — e.g. a
|
||||||
* contract-utilization report shown on a single contract's detail page.
|
* contract-utilization report shown on a single contract's detail page.
|
||||||
@@ -73,6 +88,8 @@ export interface ReportDefinition {
|
|||||||
query(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral>;
|
query(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral>;
|
||||||
/** KPIs over the same filtered set; shown above the table and in exports. */
|
/** KPIs over the same filtered set; shown above the table and in exports. */
|
||||||
summary?(ctx: ReportContext): Promise<ReportKpi[]>;
|
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. */
|
/** Catalog shape served by GET /reports — metadata only, no rows. */
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { Column, SortingState } from "@tanstack/react-table";
|
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 { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||||
@@ -11,6 +11,7 @@ import { reportsService } from "@/services/reports.service";
|
|||||||
import type { ReportRunParams } from "@/types/reports";
|
import type { ReportRunParams } from "@/types/reports";
|
||||||
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
|
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { ReportChart } from "./ReportChart";
|
||||||
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
|
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
|
||||||
import { formatKpiValue, formatReportCell } from "./report-format";
|
import { formatKpiValue, formatReportCell } from "./report-format";
|
||||||
|
|
||||||
@@ -60,20 +61,24 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
|
|||||||
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
|
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
|
||||||
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
|
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
|
||||||
const [exporting, setExporting] = useState<"xlsx" | "pdf" | null>(null);
|
const [exporting, setExporting] = useState<"xlsx" | "pdf" | null>(null);
|
||||||
|
const [view, setView] = useState<"table" | "chart">("table");
|
||||||
|
|
||||||
const runParams: ReportRunParams | undefined = useMemo(() => {
|
const runParams: ReportRunParams | undefined = useMemo(() => {
|
||||||
if (!def) return undefined;
|
if (!def) return undefined;
|
||||||
const sort = sorting[0];
|
const sort = sorting[0];
|
||||||
return {
|
return {
|
||||||
key: def.key,
|
key: def.key,
|
||||||
page: pagination.pageIndex + 1,
|
// Chart view isn't paginated on screen — pull the server's max page (100)
|
||||||
pageSize: pagination.pageSize,
|
// 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,
|
sortBy: sort?.id,
|
||||||
sortOrder: sort ? (sort.desc ? "DESC" : "ASC") : undefined,
|
sortOrder: sort ? (sort.desc ? "DESC" : "ASC") : undefined,
|
||||||
...debouncedFilters,
|
...debouncedFilters,
|
||||||
...(def.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
|
...(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({
|
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||||
...api.reports.run.queryOptions({ input: runParams as ReportRunParams }),
|
...api.reports.run.queryOptions({ input: runParams as ReportRunParams }),
|
||||||
@@ -141,6 +146,17 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Group gap="xs">
|
<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">
|
<Tooltip label="Export to Excel">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -178,31 +194,35 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
{view === "chart" && def.chart ? (
|
||||||
<DataTable
|
<ReportChart chart={def.chart} items={data?.items ?? []} columns={def.columns} total={total} />
|
||||||
columns={columns}
|
) : (
|
||||||
data={data?.items ?? []}
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
<DataTable
|
||||||
emptyMessage="No data for the selected filters."
|
columns={columns}
|
||||||
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}
|
data={data?.items ?? []}
|
||||||
pagination={{
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
pageIndex: pagination.pageIndex,
|
emptyMessage="No data for the selected filters."
|
||||||
pageSize: pagination.pageSize,
|
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}
|
||||||
pageCount,
|
pagination={{
|
||||||
totalCount: total,
|
pageIndex: pagination.pageIndex,
|
||||||
}}
|
pageSize: pagination.pageSize,
|
||||||
tableOptions={{
|
pageCount,
|
||||||
state: { sorting },
|
totalCount: total,
|
||||||
onSortingChange: setSorting,
|
}}
|
||||||
onPaginationChange: setPagination,
|
tableOptions={{
|
||||||
manualPagination: true,
|
state: { sorting },
|
||||||
manualSorting: true,
|
onSortingChange: setSorting,
|
||||||
pageCount,
|
onPaginationChange: setPagination,
|
||||||
}}
|
manualPagination: true,
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
manualSorting: true,
|
||||||
footer={DataTableFooter}
|
pageCount,
|
||||||
/>
|
}}
|
||||||
</Box>
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ export interface ReportKpi {
|
|||||||
unit?: string;
|
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. */
|
/** Mirrors the backend's ReportCatalogEntry — one entry per GET /reports item. */
|
||||||
export interface ReportCatalogEntry {
|
export interface ReportCatalogEntry {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -49,6 +57,7 @@ export interface ReportCatalogEntry {
|
|||||||
columns: ReportColumn[];
|
columns: ReportColumn[];
|
||||||
defaultSort?: { key: string; dir: "ASC" | "DESC" };
|
defaultSort?: { key: string; dir: "ASC" | "DESC" };
|
||||||
hasSummary: boolean;
|
hasSummary: boolean;
|
||||||
|
chart?: ReportChartDef;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportPageMeta {
|
export interface ReportPageMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user