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

@@ -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;
}