diff --git a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts index a01f714ec..449c47181 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts index e23ce5469..d3fd759f7 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts index f2b9377f6..cf971cf2c 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts index 684d4e2e5..512e05be8 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts index 1371936f5..8855c491b 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts index 84bbfb27a..ccec53c3d 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts @@ -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); + } + } + }); }); 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 85b73040c..a709ac654 100644 --- a/apps/edr-freight-api/src/modules/reports/report.types.ts +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -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; /** KPIs over the same filtered set; shown above the table and in exports. */ summary?(ctx: ReportContext): Promise; + /** Optional chart view of the same rows. Table remains the default view. */ + chart?: ReportChartDef; } /** Catalog shape served by GET /reports — metadata only, no rows. */ diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportChart.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportChart.tsx new file mode 100644 index 000000000..0be5384be --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportChart.tsx @@ -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[]; + 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 ( + + No data for the selected filters. + + ); + } + + const Chart = chart.type === "line" ? LineChart : BarChart; + + const truncated = typeof total === "number" && total > items.length; + + return ( + + {truncated ? ( + + Showing first {items.length} of {total} rows. Narrow the filters to see the rest charted. + + ) : null} + + + + + + [formatReportCell(value, yType(String(name))), yLabel(String(name))]} /> + {chart.y.length > 1 ? yLabel(String(name))} /> : null} + {chart.y.map((key, i) => + chart.type === "line" ? ( + + ) : ( + + ), + )} + + + + ); +} + +export default ReportChart; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx index 0e28cd5fe..b1c5822fc 100644 --- a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx @@ -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({}); 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) { }} /> + {def.chart ? ( + setView(v as "table" | "chart")} + data={[ + { label: , value: "table" }, + { label: , value: "chart" }, + ]} + /> + ) : null} - - 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} - /> - + {view === "chart" && def.chart ? ( + + ) : ( + + 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} + /> + + )} diff --git a/apps/edr-freight-web/backoffice/src/types/reports.ts b/apps/edr-freight-web/backoffice/src/types/reports.ts index 854b1a801..8bdd6bd23 100644 --- a/apps/edr-freight-web/backoffice/src/types/reports.ts +++ b/apps/edr-freight-web/backoffice/src/types/reports.ts @@ -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 {