chore: more reporting

This commit is contained in:
Nathnael
2026-08-19 10:14:46 +00:00
parent 54f52f077c
commit e964a9b8f4
20 changed files with 1707 additions and 25 deletions

View File

@@ -44,7 +44,7 @@ import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import OverviewDomainPage from "./pages/dashboard/OverviewDomainPage";
import { OVERVIEW_DOMAINS } from "./components/overview/overview-domains.config";
import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect";
import ReportsLandingPage from "./pages/reports/ReportsLandingPage";
import ReportPage from "./pages/reports/ReportPage";
import AuditLogsPage from "./pages/AuditLogsPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
@@ -245,7 +245,7 @@ const App = () => {
path="reports"
element={
<RequirePermission permission={FREIGHT_PERMS.reports.view}>
<ReportsIndexRedirect />
<ReportsLandingPage />
</RequirePermission>
}
/>

View File

@@ -9,6 +9,8 @@ interface ReportSectionProps {
reportKey: string;
/** Scopes the report to one entity, e.g. the contract this page is showing. */
idKeyValue?: string;
/** Opens on the chart instead of the table — for dashboard tiles. */
defaultView?: "table" | "chart";
}
/**
@@ -17,7 +19,7 @@ interface ReportSectionProps {
* 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) {
export function ReportSection({ reportKey, idKeyValue, defaultView }: ReportSectionProps) {
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
@@ -31,7 +33,7 @@ export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) {
{def.description}
</Text>
</div>
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} />
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} defaultView={defaultView} />
</Stack>
);
}

View File

@@ -1,17 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { api } from "@/services/api";
/**
* `/dashboard/reports` has no page of its own — it forwards to the first
* report the caller has access to (catalog order = registration order,
* already permission-filtered server-side), or home if they have none.
*/
export default function ReportsIndexRedirect() {
const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions());
if (isLoading) return null;
const first = catalog?.[0];
return <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
}

View File

@@ -0,0 +1,51 @@
import { SimpleGrid, Stack } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import { ReportSection } from "@/components/reports/ReportSection";
import { api } from "@/services/api";
/**
* The revenue dashboard the reporting spec asks for, assembled from reports
* that already exist rather than a second aggregation API: each tile is a
* `ReportSection` opened on its chart, and each one permission-gates itself by
* rendering nothing when the caller's catalog lacks that report.
*/
const TILES = [
"revenue-by-period",
"revenue-by-category",
"revenue-by-route",
"revenue-top-customers",
];
export default function ReportsLandingPage() {
const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions());
if (isLoading) return null;
const visible = TILES.filter((key) => catalog?.some((r) => r.key === key));
// No revenue reports for this user — fall back to the old behaviour and send
// them to the first report they can actually open.
if (!visible.length) {
const first = catalog?.[0];
return <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
}
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Revenue dashboard"
subtitle="Billed rail revenue by period, category, corridor and customer. Pick any report in the sidebar for the full table, filters and export."
/>
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="lg">
{visible.map((key) => (
<ReportSection key={key} reportKey={key} defaultView="chart" />
))}
</SimpleGrid>
</Stack>
</PageContainer>
);
}

View File

@@ -46,6 +46,16 @@ export interface ReportChartDef {
y: string[];
}
/**
* Makes a summary row clickable: the row's values are carried into another
* report as filter params. Keys are this report's column keys; values are the
* target report's filter keys.
*/
export interface ReportDrillDef {
to: string;
carry: Record<string, string>;
}
/** Mirrors the backend's ReportCatalogEntry — one entry per GET /reports item. */
export interface ReportCatalogEntry {
key: string;
@@ -58,6 +68,7 @@ export interface ReportCatalogEntry {
defaultSort?: { key: string; dir: "ASC" | "DESC" };
hasSummary: boolean;
chart?: ReportChartDef;
drill?: ReportDrillDef;
}
export interface ReportPageMeta {