mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
@@ -45,7 +45,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";
|
||||
@@ -248,7 +248,7 @@ const App = () => {
|
||||
path="reports"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.reports.view}>
|
||||
<ReportsIndexRedirect />
|
||||
<ReportsLandingPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,11 @@ import type { FilterBodyProps } from "./TextBody";
|
||||
// i18n.language !== "en" branch here when this body is first wired into a
|
||||
// record-management page (Phase 4 of the filter-bar rollout).
|
||||
export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<DateFilterDef>) {
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.date);
|
||||
// DEFAULT_OP.date is always "between" — a def restricted to a single
|
||||
// non-default operator (e.g. `operators: ["before"]` for an exact-date
|
||||
// filter) would otherwise open on the range UI with no way to switch off
|
||||
// it, since OperatorSelect hides itself when there's only one choice.
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? def.operators?.[0] ?? DEFAULT_OP.date);
|
||||
// Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects.
|
||||
const [from, setFrom] = useState<string | null>(value?.v[0]?.slice(0, 10) ?? null);
|
||||
const [to, setTo] = useState<string | null>(value?.v[1]?.slice(0, 10) ?? null);
|
||||
|
||||
@@ -50,15 +50,15 @@ export function PageHeader({
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ minWidth: 0, maxWidth: 640 }}>
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<Title order={2} className="truncate">
|
||||
<Title order={2} className="truncate" style={{ minWidth: 0 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{meta}
|
||||
</Group>
|
||||
{subtitle ? (
|
||||
<Text c="dimmed" size="sm" mt={4}>
|
||||
<Text c="dimmed" size="sm" mt={4} className="truncate">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
|
||||
import { DateInput, DatePickerInput } from "@mantine/dates";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||
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 (
|
||||
<DatePickerInput
|
||||
key={filter.key}
|
||||
type="range"
|
||||
placeholder={filter.label}
|
||||
value={[values[`${filter.key}From`] ?? null, values[`${filter.key}To`] ?? null]}
|
||||
onChange={([from, to]) =>
|
||||
set({ [`${filter.key}From`]: fromDate(from), [`${filter.key}To`]: fromDate(to) })
|
||||
}
|
||||
presets={getDateRangePresets()}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={230}
|
||||
/>
|
||||
);
|
||||
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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,55 @@
|
||||
import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, 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, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { PageHeader } from "@/components/page";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
|
||||
import { api } from "@/services/api";
|
||||
import type { ReportRunParams } from "@/types/reports";
|
||||
import type { ReportFilterDef, ReportRunParams } from "@/types/reports";
|
||||
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { ReportChart } from "./ReportChart";
|
||||
import { ReportExportButton } from "./ReportExportButton";
|
||||
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
|
||||
import { formatKpiValue, formatReportCell } from "./report-format";
|
||||
|
||||
/**
|
||||
* Maps the report catalog's own filter vocabulary onto the shared FilterBar's
|
||||
* `FilterDef`. "search" is skipped — FilterBar already renders its own search
|
||||
* box wired to the same `search` param, so keeping the catalog's declared
|
||||
* "search" filter too would just double it up as a redundant pill.
|
||||
*/
|
||||
function toFilterDefs(filters: ReportFilterDef[]): FilterDef[] {
|
||||
return filters
|
||||
.filter((f) => f.key !== "search")
|
||||
.map((f): FilterDef => {
|
||||
switch (f.type) {
|
||||
case "daterange":
|
||||
return {
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
type: "date",
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams(`${f.key}From`, `${f.key}To`),
|
||||
};
|
||||
case "date":
|
||||
// Every report's single-date filter (e.g. "as of") is an exact
|
||||
// cutoff, not a range — one fixed operator keeps DateBody on its
|
||||
// single-date UI instead of the range picker.
|
||||
return { key: f.key, label: f.label, type: "date", operators: ["before"] };
|
||||
case "select":
|
||||
return { key: f.key, label: f.label, type: "enum", multiple: false, options: f.options ?? [] };
|
||||
case "multiselect":
|
||||
return { key: f.key, label: f.label, type: "enum", multiple: true, options: f.options ?? [] };
|
||||
default:
|
||||
return { key: f.key, label: f.label, type: "text" };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -40,6 +74,8 @@ interface ReportViewProps {
|
||||
* arrow) with export/refresh as its actions, instead of inline above the
|
||||
* table. Off by default for embedded sections. */
|
||||
pageHeader?: boolean;
|
||||
/** Opens on the chart instead of the table — for dashboard tiles. */
|
||||
defaultView?: "table" | "chart";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,15 +83,29 @@ interface ReportViewProps {
|
||||
* filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export.
|
||||
* Adding a report never touches this file.
|
||||
*/
|
||||
export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProps) {
|
||||
export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: ReportViewProps) {
|
||||
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
|
||||
const def = catalog?.find((r) => r.key === reportKey);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 20 });
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
|
||||
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
|
||||
const [view, setView] = useState<"table" | "chart">("table");
|
||||
const [view, setView] = useState<"table" | "chart">(defaultView ?? "table");
|
||||
|
||||
// FilterBar's own state — reads/writes the URL directly, same as
|
||||
// BookingRequestsPage, so a drilled-into or shared report URL opens
|
||||
// already filtered.
|
||||
const reportFilterDefs = useMemo(() => toFilterDefs(def?.filters ?? []), [def?.filters]);
|
||||
const controls = useFilters(reportFilterDefs);
|
||||
// useFilters also tracks its own page/pageSize — unused here, this report
|
||||
// view paginates itself (and overrides pageSize for the chart view below).
|
||||
const filterParams = useMemo(() => {
|
||||
const rest = { ...controls.params };
|
||||
delete rest.page;
|
||||
delete rest.pageSize;
|
||||
return rest;
|
||||
}, [controls.params]);
|
||||
|
||||
// Filters + sort as the user currently has them — independent of the view
|
||||
// toggle's paging, so export always matches what's on screen either way.
|
||||
@@ -64,10 +114,18 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
return {
|
||||
sortBy: sort?.id,
|
||||
sortOrder: sort ? (sort.desc ? "DESC" as const : "ASC" as const) : undefined,
|
||||
...debouncedFilters,
|
||||
...filterParams,
|
||||
...(def?.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
|
||||
};
|
||||
}, [def, sorting, debouncedFilters, idKeyValue]);
|
||||
}, [def, sorting, filterParams, idKeyValue]);
|
||||
|
||||
// A filter change should land back on page 1, same as every other
|
||||
// FilterBar page — but pagination here is local (not URL-driven via
|
||||
// controls.tableProps), so it needs an explicit reset.
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterParams]);
|
||||
|
||||
const runParams: ReportRunParams | undefined = useMemo(() => {
|
||||
if (!def) return undefined;
|
||||
@@ -87,6 +145,30 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
enabled: Boolean(runParams),
|
||||
});
|
||||
|
||||
/**
|
||||
* Row click carries this row's values into the target report as filter
|
||||
* params — the "summary to transaction level" drill-down. Undefined unless
|
||||
* the report declares `drill`, which is what leaves the row unclickable.
|
||||
*/
|
||||
const handleRowClick = useMemo(() => {
|
||||
const drill = def?.drill;
|
||||
if (!drill) return undefined;
|
||||
return (row: Record<string, unknown>) => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [column, filterKey] of Object.entries(drill.carry)) {
|
||||
const value = row[column];
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
params.set(filterKey, String(value));
|
||||
}
|
||||
}
|
||||
// Carry the filters already applied, so the drill narrows rather than resets.
|
||||
for (const [key, value] of Object.entries(appliedParams)) {
|
||||
if (typeof value === "string" && value && !params.has(key)) params.set(key, value);
|
||||
}
|
||||
navigate(`/dashboard/reports/${drill.to}?${params.toString()}`);
|
||||
};
|
||||
}, [def?.drill, appliedParams, navigate]);
|
||||
|
||||
const total = data?.meta.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
@@ -167,25 +249,23 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
<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">
|
||||
{chartToggle}
|
||||
{pageHeader ? null : (
|
||||
<>
|
||||
{exportButton}
|
||||
{refreshButton}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<FilterBar
|
||||
defs={reportFilterDefs}
|
||||
controls={controls}
|
||||
// Only a handful of reports actually implement the `search`
|
||||
// param server-side (see toFilterDefs) — showing the box on
|
||||
// every report would be a dead control on the rest.
|
||||
showSearch={def.filters.some((f) => f.key === "search")}
|
||||
searchPlaceholder="Search…"
|
||||
>
|
||||
{chartToggle}
|
||||
{pageHeader ? null : (
|
||||
<>
|
||||
{exportButton}
|
||||
{refreshButton}
|
||||
</>
|
||||
)}
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
{view === "chart" && def.chart ? (
|
||||
@@ -195,6 +275,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.items ?? []}
|
||||
onRowClick={handleRowClick}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
emptyMessage="No data for the selected filters."
|
||||
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user