mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user