feat(freight-backoffice): render reports from the server-driven catalog

Drop reportConfigs.ts (per-report FE config duplicating the backend) and
the chart-drawing ReportPage. Replace with ReportView: one engine that
renders any report the GET /reports catalog describes — filters, KPI
strip, sortable/paginated DataTable, xlsx/pdf export via blob download.
ReportSection embeds a report inline on any page, scoped by idKey, and
renders nothing if the caller lacks that report's permission.

Sidebar Reports submenu is now built from the live catalog
(sidebar-sections.tsx + App.tsx) instead of a hand-listed key — no FE
edit needed to add or hide a report.
This commit is contained in:
Nathnael
2026-08-13 07:52:29 +00:00
parent b7583df426
commit 081b9945cb
13 changed files with 586 additions and 1050 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Navigate,
Outlet,
@@ -10,6 +11,7 @@ import {
} from "react-router-dom";
import { FreightDashboardLayout, type SidebarItem } from "@/components/layout";
import { api } from "@/services/api";
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
@@ -139,8 +141,18 @@ const DashboardShell = () => {
const demoItems: SidebarItem[] = [];
const { data: reportCatalog } = useQuery(api.reports.catalog.queryOptions());
const reportItems: SidebarItem[] = useMemo(
() =>
(reportCatalog ?? []).map((report) => ({
label: report.title,
href: `/dashboard/reports/${report.key}`,
})),
[reportCatalog],
);
const sidebarSections = filterSidebarByPermission(
buildSidebarSections(demoItems),
buildSidebarSections(demoItems, reportItems),
user,
);
const displayName = user?.name?.en || user?.username || user?.email || "User";

View File

@@ -51,7 +51,10 @@ import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources"
* a user's first reachable route without importing the route tree (App.tsx
* imports RequirePermission, which imports landing — that would cycle).
*/
export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
export const buildSidebarSections = (
demoItems: SidebarItem[],
reportItems: SidebarItem[] = [],
): SidebarSection[] => [
{
title: "Main menu",
items: [
@@ -66,6 +69,9 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
href: "/dashboard/reports",
icon: <BarChart3 />,
permission: FREIGHT_PERMS.reports.view,
// Populated from the live GET /reports catalog (already permission-
// filtered server-side) — no report key is ever hand-listed here.
...(reportItems.length ? { children: reportItems } : {}),
},
{
label: "Customers",

View File

@@ -0,0 +1,115 @@
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { Search } from "lucide-react";
import type { ReportFilterDef } from "@/types/reports";
export interface ReportFilterValues {
[param: string]: string | undefined;
}
interface ReportFiltersProps {
filters: ReportFilterDef[];
values: ReportFilterValues;
onChange: (values: ReportFilterValues) => void;
}
const toDate = (value: string | undefined): Date | null => (value ? new Date(value) : null);
const fromDate = (value: string | null): string | undefined => value ?? undefined;
/** Renders one widget per report-declared filter and reports raw param values back up. */
export function ReportFilters({ filters, values, onChange }: ReportFiltersProps) {
if (!filters.length) return null;
const set = (patch: ReportFilterValues) => onChange({ ...values, ...patch });
return (
<Group gap="sm" wrap="wrap">
{filters.map((filter) => {
switch (filter.type) {
case "daterange":
return (
<Group key={filter.key} gap="xs" wrap="nowrap">
<DateInput
placeholder={`${filter.label} from`}
value={toDate(values[`${filter.key}From`])}
onChange={(d) => set({ [`${filter.key}From`]: fromDate(d) })}
radius="md"
size="sm"
clearable
w={150}
/>
<DateInput
placeholder={`${filter.label} to`}
value={toDate(values[`${filter.key}To`])}
onChange={(d) => set({ [`${filter.key}To`]: fromDate(d) })}
radius="md"
size="sm"
clearable
w={150}
/>
</Group>
);
case "date":
return (
<DateInput
key={filter.key}
placeholder={filter.label}
value={toDate(values[filter.key])}
onChange={(d) => set({ [filter.key]: fromDate(d) })}
radius="md"
size="sm"
clearable
w={150}
/>
);
case "select":
return (
<Select
key={filter.key}
placeholder={filter.label}
data={filter.options ?? []}
value={values[filter.key] ?? null}
onChange={(v) => set({ [filter.key]: v ?? undefined })}
radius="md"
size="sm"
clearable
w={170}
/>
);
case "multiselect":
return (
<MultiSelect
key={filter.key}
placeholder={filter.label}
data={filter.options ?? []}
value={values[filter.key]?.split(",").filter(Boolean) ?? []}
onChange={(v) => set({ [filter.key]: v.length ? v.join(",") : undefined })}
radius="md"
size="sm"
clearable
w={200}
/>
);
case "text":
return (
<TextInput
key={filter.key}
placeholder={filter.label}
leftSection={<Search size={16} />}
value={values[filter.key] ?? ""}
onChange={(e) => set({ [filter.key]: e.target.value || undefined })}
radius="md"
size="sm"
w={220}
/>
);
default:
return null;
}
})}
</Group>
);
}
export default ReportFilters;

View File

@@ -0,0 +1,39 @@
import { Stack, Text, Title } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { ReportView } from "./ReportView";
interface ReportSectionProps {
reportKey: string;
/** Scopes the report to one entity, e.g. the contract this page is showing. */
idKeyValue?: string;
}
/**
* Drops a report inline on any page — a contract detail page embedding
* `contract-utilization`, for instance. Renders nothing while the catalog is
* loading or if the caller lacks the report's permission, so pages can embed
* it unconditionally without their own permission check.
*/
export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) {
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
if (!def) return null;
return (
<Stack gap="xs">
<div>
<Title order={4}>{def.title}</Title>
<Text size="sm" c="dimmed">
{def.description}
</Text>
</div>
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} />
</Stack>
);
}
export default ReportSection;

View File

@@ -0,0 +1,212 @@
import { ActionIcon, Alert, Box, Card, Group, 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 { useMemo, useState } from "react";
import { KpiStrip } from "@/components/page/KpiStrip";
import { api } from "@/services/api";
import { reportsService } from "@/services/reports.service";
import type { ReportRunParams } from "@/types/reports";
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
import { formatKpiValue, formatReportCell } from "./report-format";
function SortableHeader({ label, column }: { label: string; column: Column<Record<string, unknown>, unknown> }) {
const sorted = column.getIsSorted();
const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
return (
<UnstyledButton
onClick={column.getToggleSortingHandler()}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text size="sm" fw={600} c="edr-text">
{label}
</Text>
<Icon size={13} opacity={sorted ? 1 : 0.4} />
</UnstyledButton>
);
}
interface ReportViewProps {
reportKey: string;
/** Scopes the report to one entity when embedded (e.g. a contract detail page). */
idKeyValue?: string;
}
/** Triggers a browser save for a blob without leaving the SPA. */
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/**
* The report engine: one component renders any report the catalog describes —
* filters, KPI strip, sortable/paginated table, xlsx/pdf export. Adding a
* report never touches this file.
*/
export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const [sorting, setSorting] = useState<SortingState>([]);
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
const [exporting, setExporting] = useState<"xlsx" | "pdf" | null>(null);
const runParams: ReportRunParams | undefined = useMemo(() => {
if (!def) return undefined;
const sort = sorting[0];
return {
key: def.key,
page: pagination.pageIndex + 1,
pageSize: 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]);
const { data, isLoading, isError, isFetching, refetch } = useQuery({
...api.reports.run.queryOptions({ input: runParams as ReportRunParams }),
enabled: Boolean(runParams),
});
const total = data?.meta.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<Record<string, unknown>>[] = useMemo(
() =>
(def?.columns ?? []).map((col) => ({
id: col.key,
accessorKey: col.key,
header: col.sortable
? ({ column }) => <SortableHeader label={col.label} column={column} />
: col.label,
enableSorting: col.sortable,
cell: ({ row }) => (
<Text size="sm" c="edr-text">
{formatReportCell(row.original[col.key], col.type)}
</Text>
),
})),
[def?.columns],
);
const handleExport = async (format: "xlsx" | "pdf") => {
if (!def) return;
setExporting(format);
try {
const { key: _key, page: _page, pageSize: _pageSize, ...filters } = runParams ?? {};
const blob = await reportsService.download(def.key, format, filters);
saveBlob(blob, `${def.key}.${format}`);
} finally {
setExporting(null);
}
};
if (!def) {
return catalog ? (
<Alert color="red">You don't have access to this report.</Alert>
) : null;
}
return (
<Stack gap="md">
{data?.kpis.length ? (
<KpiStrip
loading={isLoading}
items={data.kpis.map((k) => ({ label: k.label, value: formatKpiValue(k.value, k.unit) }))}
/>
) : null}
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<ReportFilters
filters={def.filters}
values={filterValues}
onChange={(v) => {
setFilterValues(v);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
/>
<Group gap="xs">
<Tooltip label="Export to Excel">
<ActionIcon
variant="default"
radius="md"
loading={exporting === "xlsx"}
onClick={() => void handleExport("xlsx")}
aria-label="Export to Excel"
>
<FileSpreadsheet size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Export to PDF">
<ActionIcon
variant="default"
radius="md"
loading={exporting === "pdf"}
onClick={() => void handleExport("pdf")}
aria-label="Export to PDF"
>
<FileText size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Refresh">
<ActionIcon
variant="default"
radius="md"
loading={isFetching}
onClick={() => void refetch()}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Tooltip>
</Group>
</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>
</Stack>
</Card>
</Stack>
);
}
export default ReportView;

View File

@@ -0,0 +1,42 @@
import type { ReportColumnType } from "@/types/reports";
/** Cell formatting shared by the on-screen table and (indirectly) exports. */
export function formatReportCell(value: unknown, type: ReportColumnType): string {
if (value === null || value === undefined || value === "") return "—";
switch (type) {
case "money":
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 2,
}).format(Number(value));
case "tons":
return `${Number(value).toLocaleString()} t`;
case "percent":
return `${value}%`;
case "number":
return Number(value).toLocaleString();
case "date": {
const d = new Date(String(value));
return Number.isNaN(d.getTime())
? String(value)
: d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
default:
return String(value);
}
}
export function formatKpiValue(value: number, unit?: string): string {
const formatted = value.toLocaleString(undefined, { maximumFractionDigits: 1 });
if (unit === "ETB") {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
}).format(value);
}
if (unit === "%") return `${formatted}%`;
if (unit === "t") return `${formatted} t`;
return formatted;
}

View File

@@ -128,7 +128,9 @@ export const URL_CONSTANTS = {
},
REPORTS: {
CATALOG: "/reports",
RUN: (key: string) => `/reports/${key}`,
EXPORT: (key: string) => `/reports/${key}/export`,
},
OVERVIEW: {

View File

@@ -1,445 +1,23 @@
import {
Button,
Card,
Group,
MultiSelect,
Select,
Text,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { Download, FileSpreadsheet, Printer, RotateCcw } from "lucide-react";
import { useMemo } from "react";
import { useParams, useSearchParams, Link } from "react-router-dom";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import * as XLSX from "xlsx";
import { ALL_TRADE_DIRECTIONS, TRADE_DIRECTION_LABELS } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { overviewChartColors } from "@/components/overview/overview.styles";
import { ReportView } from "@/components/reports/ReportView";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { ReportQueryInput, ReportRow } from "@/types/reports";
import {
REPORT_CONFIG_BY_KEY,
type ReportColumn,
type ReportConfig,
} from "./reportConfigs";
const compact = new Intl.NumberFormat("en", { notation: "compact" });
const UNIT_SUFFIX = { ETB: " ETB", t: " t", "%": "%", min: " min" } as const;
function formatCell(value: unknown, col: ReportColumn): string {
if (value === null || value === undefined || value === "") return "—";
if (col.unit || col.numeric) {
const n = Number(value);
if (!Number.isNaN(n)) {
return `${n.toLocaleString()}${col.unit ? UNIT_SUFFIX[col.unit] : ""}`;
}
}
return String(value);
}
const toDate = (s: string | null): Date | null => (s ? new Date(s) : null);
// Mantine DateInput onChange emits a date string (or null).
const toParam = (d: Date | string | null): string | null => {
if (!d) return null;
return typeof d === "string" ? d.slice(0, 10) : d.toISOString().slice(0, 10);
};
function downloadBlob(content: BlobPart, type: string, filename: string) {
const url = URL.createObjectURL(new Blob([content], { type }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function exportCsv(config: ReportConfig, rows: ReportRow[]) {
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
const lines = [
config.columns.map((c) => esc(c.label)).join(","),
...rows.map((r) => config.columns.map((c) => esc(r[c.key])).join(",")),
];
downloadBlob(lines.join("\n"), "text/csv;charset=utf-8", `${config.key}.csv`);
}
function exportXlsx(config: ReportConfig, rows: ReportRow[]) {
const sheetRows = rows.map((r) =>
Object.fromEntries(config.columns.map((c) => [c.label, r[c.key] ?? ""])),
);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
wb,
XLSX.utils.json_to_sheet(sheetRows),
config.title.slice(0, 31),
);
XLSX.writeFile(wb, `${config.key}.xlsx`);
}
function ReportChartView({
config,
rows,
}: {
config: ReportConfig;
rows: ReportRow[];
}) {
const chart = config.chart;
const data = useMemo(() => {
if (!chart) return [];
const sliced = chart.topN ? rows.slice(0, chart.topN) : rows;
// xKey "a+b" concatenates columns (e.g. origin+destination → "A → B").
const keys = chart.xKey.split("+");
return sliced.map((r) => ({
...r,
__x:
keys.length > 1
? keys.map((k) => String(r[k] ?? "")).join(" → ")
: String(r[chart.xKey] ?? ""),
}));
}, [chart, rows]);
if (!chart) return null;
if (data.length === 0) {
return (
<Card withBorder shadow="sm">
<Text c="dimmed" ta="center" py="xl">
No data for the selected filters
</Text>
</Card>
);
}
const ChartComponent =
chart.type === "bar" ? BarChart : chart.type === "line" ? LineChart : AreaChart;
return (
<Card withBorder shadow="sm">
<ResponsiveContainer width="100%" height={280}>
<ChartComponent data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="__x" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
<YAxis
tick={{ fontSize: 12 }}
tickFormatter={(v: number) => compact.format(v)}
width={56}
/>
<Tooltip formatter={(value) => Number(value ?? 0).toLocaleString()} />
{chart.series.length > 1 ? <Legend /> : null}
{chart.series.map((s, i) => {
const color =
overviewChartColors.pipeline[i % overviewChartColors.pipeline.length];
if (chart.type === "bar") {
return (
<Bar key={s.key} dataKey={s.key} name={s.label} fill={color} radius={[4, 4, 0, 0]} />
);
}
if (chart.type === "line") {
return (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={color}
strokeWidth={2}
dot={false}
/>
);
}
return (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={color}
fill={color}
fillOpacity={0.15}
strokeWidth={2}
/>
);
})}
</ChartComponent>
</ResponsiveContainer>
</Card>
);
}
export default function ReportPage() {
const { reportKey = "" } = useParams<{ reportKey: string }>();
const config = REPORT_CONFIG_BY_KEY.get(reportKey);
const [params, setParams] = useSearchParams();
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const setParam = (name: string, value: string | null) => {
setParams(
(prev) => {
if (value) prev.set(name, value);
else prev.delete(name);
return prev;
},
{ replace: true },
);
setPagination((p) => ({ ...p, pageIndex: 0 }));
};
const input: ReportQueryInput = {
key: reportKey,
dateFrom: params.get("dateFrom") ?? undefined,
dateTo: params.get("dateTo") ?? undefined,
granularity:
(params.get("granularity") as ReportQueryInput["granularity"]) ?? undefined,
yardIds: params.get("yardIds") ?? undefined,
statuses: params.get("statuses") ?? undefined,
direction: params.get("direction") ?? undefined,
freightType: params.get("freightType") ?? undefined,
};
const reportQuery = useQuery(
api.reports.run.queryOptions({
input,
placeholderData: keepPreviousData,
staleTime: 30_000,
enabled: Boolean(config),
}),
);
const yardsQuery = useQuery(
api.routes.yards.queryOptions({
staleTime: 5 * 60_000,
enabled: Boolean(config?.filters.includes("yards")),
}),
);
if (!config) {
return (
<PageContainer>
<PageHeader title="Unknown report" backTo="/dashboard/reports" />
<Text>
This report does not exist. <Link to="/dashboard/reports">Back to reports</Link>
</Text>
</PageContainer>
);
}
const rows = reportQuery.data?.rows ?? [];
const kpis = reportQuery.data?.kpis ?? [];
const pageCount = Math.max(1, Math.ceil(rows.length / pagination.pageSize));
const columns: ColumnDef<ReportRow, unknown>[] = config.columns.map((col) => ({
accessorKey: col.key,
header: col.label,
cell: (info) => formatCell(info.getValue(), col),
}));
const tableStatus = reportQuery.isLoading
? "loading"
: reportQuery.isError
? "error"
: "success";
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
return (
<PageContainer>
<PageHeader
title={config.title}
subtitle={config.description}
title={def?.title ?? "Report"}
subtitle={def?.description}
backTo="/dashboard/reports"
action={
<Group gap="xs">
<Button
variant="default"
size="xs"
leftSection={<Download size={14} />}
onClick={() => exportCsv(config, rows)}
disabled={rows.length === 0}
>
CSV
</Button>
<Button
variant="default"
size="xs"
leftSection={<FileSpreadsheet size={14} />}
onClick={() => exportXlsx(config, rows)}
disabled={rows.length === 0}
>
Excel
</Button>
<Button
variant="default"
size="xs"
leftSection={<Printer size={14} />}
onClick={() => window.print()}
>
Print
</Button>
</Group>
}
/>
<Card withBorder shadow="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<DateInput
label="From"
size="xs"
clearable
value={toDate(params.get("dateFrom"))}
maxDate={toDate(params.get("dateTo")) ?? undefined}
onChange={(d) => setParam("dateFrom", toParam(d))}
placeholder="All time"
/>
<DateInput
label="To"
size="xs"
clearable
value={toDate(params.get("dateTo"))}
minDate={toDate(params.get("dateFrom")) ?? undefined}
onChange={(d) => setParam("dateTo", toParam(d))}
placeholder="All time"
/>
{config.filters.includes("granularity") ? (
<Select
label="Group by"
size="xs"
data={[
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
]}
value={params.get("granularity") ?? "day"}
onChange={(v) => setParam("granularity", v)}
allowDeselect={false}
/>
) : null}
{config.filters.includes("yards") ? (
<MultiSelect
label="Yards"
size="xs"
searchable
clearable
w={220}
data={(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label,
}))}
value={params.get("yardIds")?.split(",").filter(Boolean) ?? []}
onChange={(v) => setParam("yardIds", v.length ? v.join(",") : null)}
placeholder="All yards"
/>
) : null}
{config.filters.includes("direction") ? (
<Select
label="Direction"
size="xs"
clearable
data={ALL_TRADE_DIRECTIONS.map((d) => ({
value: d,
label: TRADE_DIRECTION_LABELS[d],
}))}
value={params.get("direction")}
onChange={(v) => setParam("direction", v)}
placeholder="All"
/>
) : null}
{config.filters.includes("freightType") ? (
<Select
label="Freight type"
size="xs"
clearable
data={["CONTAINER", "BULK"]}
value={params.get("freightType")}
onChange={(v) => setParam("freightType", v)}
placeholder="All"
/>
) : null}
{config.filters.includes("statuses") && config.statusOptions ? (
<MultiSelect
label="Status"
size="xs"
searchable
clearable
w={220}
data={config.statusOptions}
value={params.get("statuses")?.split(",").filter(Boolean) ?? []}
onChange={(v) => setParam("statuses", v.length ? v.join(",") : null)}
placeholder="Default (active)"
/>
) : null}
<Button
variant="subtle"
size="xs"
leftSection={<RotateCcw size={14} />}
onClick={() => setParams({}, { replace: true })}
>
Reset
</Button>
</Group>
</Card>
<KpiStrip
loading={reportQuery.isLoading}
items={kpis.map((k) => ({
label: k.label,
value: k.value.toLocaleString(),
hint: k.unit,
}))}
/>
<ReportChartView config={config} rows={rows} />
<DataTable
columns={columns}
data={rows}
status={tableStatus}
emptyMessage="No data for the selected filters"
error={
reportQuery.isError
? {
message: "Failed to load report",
onRetry: () => void reportQuery.refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: rows.length,
}}
tableOptions={{
manualPagination: false,
state: { pagination },
onPaginationChange: setPagination,
autoResetPageIndex: false,
}}
footer={({ table, pagination: p }) => (
<DataTableFooter
table={table}
pagination={p}
options={{ labels: { items: "rows" } }}
/>
)}
/>
<ReportView reportKey={reportKey} />
</PageContainer>
);
}

View File

@@ -1,159 +1,64 @@
import {
ActionIcon,
Badge,
Card,
Group,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Search, Star } from "lucide-react";
import { useMemo, useState } from "react";
import { Alert, Card, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import {
REPORT_CONFIGS,
REPORT_DOMAINS,
type ReportConfig,
} from "./reportConfigs";
import { api } from "@/services/api";
import type { ReportCatalogEntry } from "@/types/reports";
const FAVORITES_KEY = "reports.favorites";
const loadFavorites = (): string[] => {
try {
return JSON.parse(localStorage.getItem(FAVORITES_KEY) ?? "[]");
} catch {
return [];
}
};
function ReportCard({
config,
favorite,
onToggleFavorite,
}: {
config: ReportConfig;
favorite: boolean;
onToggleFavorite: () => void;
}) {
const navigate = useNavigate();
return (
<Card
withBorder
shadow="sm"
className="cursor-pointer transition-colors hover:bg-gray-50"
onClick={() => navigate(`/dashboard/reports/${config.key}`)}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{config.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{config.description}
</Text>
</div>
<ActionIcon
variant="subtle"
color={favorite ? "yellow" : "gray"}
aria-label={favorite ? "Remove from favorites" : "Add to favorites"}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite();
}}
>
<Star size={16} fill={favorite ? "currentColor" : "none"} />
</ActionIcon>
</Group>
<Badge mt="sm" size="sm" variant="light">
{config.domain}
</Badge>
</Card>
);
}
const GROUP_ORDER: ReportCatalogEntry["group"][] = ["Commercial", "Operations", "Finance"];
export default function ReportsHubPage() {
const [search, setSearch] = useState("");
const [favorites, setFavorites] = useState<string[]>(loadFavorites);
const navigate = useNavigate();
const { data: catalog, isLoading, isError } = useQuery(api.reports.catalog.queryOptions());
const toggleFavorite = (key: string) => {
setFavorites((prev) => {
const next = prev.includes(key)
? prev.filter((k) => k !== key)
: [...prev, key];
localStorage.setItem(FAVORITES_KEY, JSON.stringify(next));
return next;
});
};
const visible = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return REPORT_CONFIGS;
return REPORT_CONFIGS.filter(
(c) =>
c.title.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q),
);
}, [search]);
const pinned = visible.filter((c) => favorites.includes(c.key));
const renderGrid = (configs: ReportConfig[]) => (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{configs.map((c) => (
<ReportCard
key={c.key}
config={c}
favorite={favorites.includes(c.key)}
onToggleFavorite={() => toggleFavorite(c.key)}
/>
))}
</SimpleGrid>
);
const groups = GROUP_ORDER.map((group) => ({
group,
reports: (catalog ?? []).filter((r) => r.group === group),
})).filter((g) => g.reports.length);
return (
<PageContainer>
<PageHeader
title="Reports"
subtitle="Operational, commercial and financial reporting"
action={
<TextInput
size="xs"
w={240}
leftSection={<Search size={14} />}
placeholder="Search reports…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
}
/>
<PageHeader title="Reports" subtitle="Every report you have access to, grouped by area." />
{pinned.length ? (
<Stack gap="sm">
<Title order={4}>Favorites</Title>
{renderGrid(pinned)}
</Stack>
{isError ? <Alert color="red">Failed to load the report catalog.</Alert> : null}
{isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height={96} radius="md" />
))}
</SimpleGrid>
) : null}
{REPORT_DOMAINS.map((domain) => {
const configs = visible.filter((c) => c.domain === domain);
if (!configs.length) return null;
return (
<Stack key={domain} gap="sm">
<Title order={4}>{domain}</Title>
{renderGrid(configs)}
</Stack>
);
})}
{!isLoading && !isError && !groups.length ? (
<Text c="dimmed">You don't have access to any reports yet.</Text>
) : null}
{visible.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No reports match {search}
{groups.map(({ group, reports }) => (
<Stack key={group} gap="sm">
<Title order={4}>{group}</Title>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{reports.map((report) => (
<Card
key={report.key}
withBorder
radius="md"
p="md"
className="cursor-pointer transition-colors hover:bg-gray-50"
onClick={() => navigate(`/dashboard/reports/${report.key}`)}
>
<Text fw={600} c="edr-text">
{report.title}
</Text>
) : null}
<Text size="sm" c="dimmed" mt={4}>
{report.description}
</Text>
</Card>
))}
</SimpleGrid>
</Stack>
))}
</PageContainer>
);
}

View File

@@ -1,445 +0,0 @@
import { BookingStatus } from "@edr/types";
export type ReportDomain = "Commercial" | "Operations" | "Finance" | "Data";
export type ReportColumnUnit = "ETB" | "t" | "%" | "min";
export interface ReportColumn {
key: string;
label: string;
/** Numeric unit — formats the cell (thousands separators, suffix). */
unit?: ReportColumnUnit;
numeric?: boolean;
}
export interface ReportChart {
type: "area" | "line" | "bar";
xKey: string;
series: { key: string; label: string }[];
/** Chart only the first N rows (rows arrive sorted by the backend). */
topN?: number;
}
export type ReportFilterKey =
| "granularity"
| "yards"
| "direction"
| "freightType"
| "statuses";
export interface ReportConfig {
key: string;
title: string;
description: string;
domain: ReportDomain;
filters: ReportFilterKey[];
/** Options for the `statuses` filter, when enabled. */
statusOptions?: string[];
chart?: ReportChart;
columns: ReportColumn[];
}
// Full enum from @edr/types; Set dedupes the deprecated AwaitingPayment alias.
const BOOKING_STATUSES = [...new Set(Object.values(BookingStatus))];
// Full list mirroring CONTRACT_STATUSES in contract.entity.ts (no shared enum
// in @edr/types yet).
const CONTRACT_STATUSES = [
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"PENDING_APPROVAL",
"APPROVED",
"APPROVED_PENDING_SIGNATURE",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
"SUSPENDED",
"CONTRACT_CLOSED",
"EXPIRED",
"REJECTED",
"CANCELLED",
"RENEWAL_DRAFT",
"RENEWAL_SUBMITTED",
"RENEWAL_PENDING_APPROVAL",
"AMENDMENTS_PROPOSED",
"ARCHIVED",
];
const INVOICE_STATUSES = [
"ISSUED",
"PENDING",
"PAYMENT_PROCESSING",
"PARTIALLY_PAID",
"PAID",
"OVERDUE",
"REFUNDED",
];
export const REPORT_CONFIGS: ReportConfig[] = [
{
key: "bookings-trend",
title: "Bookings Trend",
description: "Booking volume, tonnage and revenue over time",
domain: "Commercial",
filters: ["granularity", "yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "area",
xKey: "period",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
},
columns: [
{ key: "period", label: "Period" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "revenue-by-customer",
title: "Revenue by Customer",
description: "Ranked customers by booking revenue",
domain: "Commercial",
filters: ["yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "bar",
xKey: "customer",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
topN: 10,
},
columns: [
{ key: "customer", label: "Customer" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "revenue-by-lane",
title: "Revenue by Lane",
description: "Origin → destination lanes by tonnage and revenue",
domain: "Commercial",
filters: ["direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
topN: 10,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "contract-utilization",
title: "Contract Utilization",
description: "Committed scope caps vs booked tonnage per contract",
domain: "Commercial",
filters: ["direction", "statuses"],
statusOptions: CONTRACT_STATUSES,
columns: [
{ key: "reference", label: "Contract" },
{ key: "customer", label: "Customer" },
{ key: "status", label: "Status" },
{ key: "kind", label: "Kind" },
{ key: "valid_from", label: "Valid from" },
{ key: "valid_until", label: "Valid until" },
{ key: "committed", label: "Committed", unit: "t" },
{ key: "booked_tons", label: "Booked", unit: "t" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "utilization_pct", label: "Utilization", unit: "%" },
],
},
{
key: "train-on-time",
title: "Train On-Time Performance",
description: "Departure punctuality and delays by lane (60-min grace)",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "on_time_pct", label: "On-time %" }],
topN: 15,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "trips", label: "Trips", numeric: true },
{ key: "departed", label: "Departed", numeric: true },
{ key: "avg_dep_delay_min", label: "Avg dep. delay", unit: "min" },
{ key: "avg_arr_delay_min", label: "Avg arr. delay", unit: "min" },
{ key: "on_time_pct", label: "On-time", unit: "%" },
],
},
{
key: "schedule-fill-rate",
title: "Schedule Fill Rate",
description: "Booked tonnage vs wagon capacity per train schedule",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "line",
xKey: "departure",
series: [{ key: "fill_pct", label: "Fill %" }],
},
columns: [
{ key: "train_number", label: "Train" },
{ key: "departure", label: "Departure" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "direction", label: "Direction" },
{ key: "status", label: "Status" },
{ key: "wagon_count", label: "Wagons", numeric: true },
{ key: "capacity_tons", label: "Capacity", unit: "t" },
{ key: "booked_tons", label: "Booked", unit: "t" },
{ key: "fill_pct", label: "Fill", unit: "%" },
],
},
{
key: "trips-per-route",
title: "Trips per Route",
description: "Completed trips and tonnage hauled per lane",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "trips", label: "Trips" }],
topN: 15,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "direction", label: "Direction" },
{ key: "trips", label: "Trips", numeric: true },
{ key: "tons_hauled", label: "Tonnage hauled", unit: "t" },
{ key: "avg_tons_per_trip", label: "Avg per trip", unit: "t" },
],
},
{
key: "invoiced-vs-collected",
title: "Invoiced vs Collected",
description: "Billing issued vs payments received over time",
domain: "Finance",
filters: ["granularity", "direction"],
chart: {
type: "line",
xKey: "period",
series: [
{ key: "invoiced", label: "Invoiced (ETB)" },
{ key: "collected", label: "Collected (ETB)" },
],
},
columns: [
{ key: "period", label: "Period" },
{ key: "invoices", label: "Invoices", numeric: true },
{ key: "invoiced", label: "Invoiced", unit: "ETB" },
{ key: "collected", label: "Collected", unit: "ETB" },
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
],
},
{
key: "aging-receivables",
title: "Aging Receivables",
description: "Outstanding invoice balances by age bucket per customer",
domain: "Finance",
filters: ["direction", "statuses"],
statusOptions: INVOICE_STATUSES,
chart: {
type: "bar",
xKey: "customer",
series: [{ key: "outstanding", label: "Outstanding (ETB)" }],
topN: 10,
},
columns: [
{ key: "customer", label: "Customer" },
{ key: "invoices", label: "Invoices", numeric: true },
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
{ key: "current", label: "Current", unit: "ETB" },
{ key: "overdue_0_30", label: "030d", unit: "ETB" },
{ key: "overdue_31_60", label: "3160d", unit: "ETB" },
{ key: "overdue_61_90", label: "6190d", unit: "ETB" },
{ key: "overdue_90_plus", label: "90d+", unit: "ETB" },
],
},
{
key: "revenue-by-payment-method",
title: "Revenue by Payment Method",
description: "Successful payments broken down by method",
domain: "Finance",
filters: ["direction"],
chart: {
type: "bar",
xKey: "method",
series: [{ key: "amount", label: "Amount (ETB)" }],
},
columns: [
{ key: "method", label: "Method" },
{ key: "payments", label: "Payments", numeric: true },
{ key: "amount", label: "Amount", unit: "ETB" },
],
},
// --- Record-level list exports (Data domain) — filtered or full dumps ---
{
key: "bookings-list",
title: "Bookings Export",
description: "Booking records with customer, lane, cargo, amounts",
domain: "Data",
filters: ["yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
columns: [
{ key: "reference", label: "Reference" },
{ key: "created", label: "Created" },
{ key: "customer", label: "Customer" },
{ key: "status", label: "Status" },
{ key: "freight_type", label: "Freight" },
{ key: "direction", label: "Direction" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "cargo", label: "Cargo" },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "amount", label: "Amount", unit: "ETB" },
{ key: "payment_status", label: "Payment" },
{ key: "scheduling_status", label: "Scheduling" },
],
},
{
key: "contracts-list",
title: "Contracts Export",
description: "Contract records with validity, status, customer",
domain: "Data",
filters: ["direction", "statuses"],
statusOptions: CONTRACT_STATUSES,
columns: [
{ key: "reference", label: "Reference" },
{ key: "customer", label: "Customer" },
{ key: "kind", label: "Kind" },
{ key: "status", label: "Status" },
{ key: "direction", label: "Direction" },
{ key: "freight_type", label: "Freight" },
{ key: "valid_from", label: "Valid from" },
{ key: "valid_until", label: "Valid until" },
{ key: "created", label: "Created" },
],
},
{
key: "schedules-list",
title: "Train Schedules Export",
description: "Schedule records with planned vs actual times",
domain: "Data",
filters: ["yards", "direction", "statuses"],
statusOptions: ["DRAFT", "SCHEDULED", "DISPATCHED", "ARRIVED", "CANCELLED"],
columns: [
{ key: "train_number", label: "Train" },
{ key: "reference", label: "Reference" },
{ key: "direction", label: "Direction" },
{ key: "status", label: "Status" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "scheduled_departure", label: "Sched. departure" },
{ key: "actual_departure", label: "Actual departure" },
{ key: "scheduled_arrival", label: "Sched. arrival" },
{ key: "actual_arrival", label: "Actual arrival" },
{ key: "max_wagons", label: "Max wagons", numeric: true },
{ key: "wagon_count", label: "Wagons", numeric: true },
],
},
{
key: "fleet-wagons",
title: "Wagons Export",
description: "Wagon fleet with type, capacity, status, location",
domain: "Data",
filters: ["yards", "statuses"],
statusOptions: ["AVAILABLE", "ASSIGNED", "MAINTENANCE"],
columns: [
{ key: "wagon_number", label: "Wagon" },
{ key: "type", label: "Type" },
{ key: "capacity_tons", label: "Capacity", unit: "t" },
{ key: "status", label: "Status" },
{ key: "current_yard", label: "Current yard" },
],
},
{
key: "fleet-locomotives",
title: "Locomotives Export",
description: "Locomotive fleet with type, pull capacity, status",
domain: "Data",
filters: ["yards", "statuses"],
statusOptions: ["AVAILABLE", "OUT_OF_SERVICE"],
columns: [
{ key: "code", label: "Code" },
{ key: "name", label: "Name" },
{ key: "locomotive_type", label: "Type" },
{ key: "max_pull_tons", label: "Max pull", unit: "t" },
{ key: "status", label: "Status" },
{ key: "current_yard", label: "Current yard" },
],
},
{
key: "customers-list",
title: "Customers Export",
description: "Company records with type, status, TIN",
domain: "Data",
filters: ["statuses"],
statusOptions: ["pending", "active"],
columns: [
{ key: "name", label: "Name" },
{ key: "type", label: "Type" },
{ key: "kind", label: "Kind" },
{ key: "status", label: "Status" },
{ key: "tin", label: "TIN" },
{ key: "approved", label: "Approved" },
{ key: "created", label: "Created" },
],
},
{
key: "payments-list",
title: "Payments Export",
description: "Payment transactions with method, status, references",
domain: "Data",
filters: ["direction", "statuses"],
statusOptions: [
"action-required",
"processing",
"success",
"failed",
"canceled",
"refunded",
],
columns: [
{ key: "created", label: "Created" },
{ key: "method", label: "Method" },
{ key: "status", label: "Status" },
{ key: "currency", label: "Currency" },
{ key: "amount", label: "Amount", unit: "ETB" },
{ key: "transaction_id", label: "Transaction" },
{ key: "merchant_order_id", label: "Merchant order" },
{ key: "paid", label: "Paid" },
],
},
];
export const REPORT_CONFIG_BY_KEY = new Map(
REPORT_CONFIGS.map((c) => [c.key, c]),
);
export const REPORT_DOMAINS: ReportDomain[] = [
"Commercial",
"Operations",
"Finance",
"Data",
];

View File

@@ -174,7 +174,7 @@ import {
} from "./locomotives.service";
import { overviewService } from "./overview.service";
import { reportsService } from "./reports.service";
import type { ReportQueryInput, ReportResult } from "@/types/reports";
import type { ReportCatalogEntry, ReportRunParams, ReportRunResult } from "@/types/reports";
import {
paymentsService,
type PaginatedPayments,
@@ -3030,7 +3030,10 @@ export const api = {
},
reports: {
run: endpoint<ReportQueryInput, ReportResult>(
catalog: endpoint<void, ReportCatalogEntry[]>("reports", "catalog", () =>
reportsService.catalog(),
),
run: endpoint<ReportRunParams, ReportRunResult>(
"reports",
"run",
(input) => reportsService.run(input),

View File

@@ -1,14 +1,31 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ReportQueryInput, ReportResult } from "@/types/reports";
import type { ReportCatalogEntry, ReportRunParams, ReportRunResult } from "@/types/reports";
const R = URL_CONSTANTS.REPORTS;
export const reportsService = {
run: async ({ key, ...params }: ReportQueryInput): Promise<ReportResult> => {
const response = await client.get<ReportResult>(
URL_CONSTANTS.REPORTS.RUN(key),
{ params },
);
catalog: async (): Promise<ReportCatalogEntry[]> => {
const response = await client.get(R.CATALOG);
return unwrap(response.data);
},
run: async ({ key, ...params }: ReportRunParams): Promise<ReportRunResult> => {
const response = await client.get(R.RUN(key), { params });
return unwrap(response.data);
},
/** Streams the export file as a blob — caller triggers the browser save. */
download: async (
key: string,
format: "xlsx" | "pdf",
params: Omit<ReportRunParams, "key" | "page" | "pageSize">,
): Promise<Blob> => {
const response = await client.get(R.EXPORT(key), {
params: { ...params, format },
responseType: "blob",
});
return response.data as Blob;
},
};

View File

@@ -1,27 +1,77 @@
export type ReportColumnType =
| "string"
| "number"
| "money"
| "tons"
| "percent"
| "date";
export interface ReportColumn {
key: string;
label: string;
type: ReportColumnType;
sortable?: boolean;
}
export type ReportFilterType = "daterange" | "date" | "select" | "multiselect" | "text";
export interface ReportFilterOption {
value: string;
label: string;
}
export interface ReportFilterDef {
key: string;
label: string;
type: ReportFilterType;
options?: ReportFilterOption[];
}
export interface ReportIdKey {
key: string;
label: string;
}
export interface ReportKpi {
label: string;
value: number;
unit?: string;
}
export type ReportRow = Record<string, unknown>;
export interface ReportResult {
kpis: ReportKpi[];
rows: ReportRow[];
}
/** Query params for GET /reports/:key. List filters are comma-separated. */
export interface ReportQueryInput {
/** Mirrors the backend's ReportCatalogEntry — one entry per GET /reports item. */
export interface ReportCatalogEntry {
key: string;
dateFrom?: string;
dateTo?: string;
granularity?: "day" | "week" | "month";
companyIds?: string;
routeIds?: string;
yardIds?: string;
cargoTypeIds?: string;
statuses?: string;
direction?: string;
freightType?: string;
title: string;
description: string;
group: "Commercial" | "Operations" | "Finance";
idKey?: ReportIdKey;
filters: ReportFilterDef[];
columns: ReportColumn[];
defaultSort?: { key: string; dir: "ASC" | "DESC" };
hasSummary: boolean;
}
export interface ReportPageMeta {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
export interface ReportRunResult {
columns: ReportColumn[];
items: Record<string, unknown>[];
meta: ReportPageMeta;
kpis: ReportKpi[];
}
/** Query params for GET /reports/:key — page/sort plus whatever filters the report declares. */
export type ReportRunParams = Record<string, string | number | undefined> & {
key: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
};