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. */