add reports module with controller, service, and repository

This commit is contained in:
Marshal
2026-08-03 21:43:23 +00:00
parent e68bdb7a1a
commit c3979b08b6
26 changed files with 1771 additions and 136 deletions

View File

@@ -5,6 +5,7 @@ import {
FileSignature,
FileText,
Train,
TrainFront,
UserCheck,
Users,
} from "lucide-react";
@@ -13,7 +14,6 @@ import {
Badge,
Button,
Container,
Paper,
Skeleton,
Stack,
Tabs,
@@ -22,7 +22,6 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/auth/useAuth";
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
import "@/components/overview/overview.css";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -82,6 +81,18 @@ const TAB_ITEMS: Array<{
FREIGHT_PERMS.lastMile.view,
],
},
{
value: "fleet",
label: "Fleet",
icon: TrainFront,
kpiKey: "operations",
metricKey: "wagonsAvailable",
permission: [
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.wagons.view,
FREIGHT_PERMS.trainScheduling.view,
],
},
{
value: "customers",
label: "Customers",
@@ -174,6 +185,12 @@ const OverviewPage = () => {
</Alert>
)}
{visibleTabs.length === 0 && !isLoading && !isError && (
<Alert color="gray" variant="light" title="No dashboard sections available">
Your role has no access to any overview section.
</Alert>
)}
{visibleTabs.length > 0 && (
<Tabs
value={currentTab}
@@ -220,10 +237,6 @@ const OverviewPage = () => {
))}
</Tabs>
)}
<Paper p="lg" radius="lg" withBorder>
<OverviewQuickLinks />
</Paper>
</Stack>
</Container>
);

View File

@@ -0,0 +1,441 @@
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 { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { overviewChartColors } from "@/components/overview/overview.styles";
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";
return (
<PageContainer>
<PageHeader
title={config.title}
subtitle={config.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="30 days ago"
/>
<DateInput
label="To"
size="xs"
clearable
value={toDate(params.get("dateTo"))}
minDate={toDate(params.get("dateFrom")) ?? undefined}
onChange={(d) => setParam("dateTo", toParam(d))}
placeholder="Today"
/>
{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={["IMPORT", "EXPORT"]}
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" } }}
/>
)}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,159 @@
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 { useNavigate } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import {
REPORT_CONFIGS,
REPORT_DOMAINS,
type ReportConfig,
} from "./reportConfigs";
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>
);
}
export default function ReportsHubPage() {
const [search, setSearch] = useState("");
const [favorites, setFavorites] = useState<string[]>(loadFavorites);
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>
);
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)}
/>
}
/>
{pinned.length ? (
<Stack gap="sm">
<Title order={4}>Favorites</Title>
{renderGrid(pinned)}
</Stack>
) : 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>
);
})}
{visible.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No reports match {search}
</Text>
) : null}
</PageContainer>
);
}

View File

@@ -0,0 +1,295 @@
export type ReportDomain = "Commercial" | "Operations" | "Finance";
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[];
}
const BOOKING_STATUSES = [
"SUBMITTED",
"PENDING_APPROVAL",
"APPROVED",
"INVOICED",
"PAID",
"IN_TRANSIT",
"ARRIVED",
"COMPLETED",
"CANCELLED",
"REJECTED",
];
const CONTRACT_STATUSES = [
"SUBMITTED",
"PENDING_APPROVAL",
"APPROVED",
"CONTRACT_ACTIVE",
"ACTIVE_SHIPMENT_IN_PROGRESS",
"SUSPENDED",
"CONTRACT_CLOSED",
"EXPIRED",
"CANCELLED",
];
const INVOICE_STATUSES = [
"ISSUED",
"PENDING",
"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" },
],
},
];
export const REPORT_CONFIG_BY_KEY = new Map(
REPORT_CONFIGS.map((c) => [c.key, c]),
);
export const REPORT_DOMAINS: ReportDomain[] = [
"Commercial",
"Operations",
"Finance",
];