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:
@@ -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 { 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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user