feat(reports): header actions, single export dialog, date-range presets

- ReportPage drops its own PageHeader (and the back arrow); ReportView
  now optionally renders the header itself (pageHeader prop) with
  export/refresh as its actions. Embedded ReportSection usage is
  unaffected (keeps the inline toolbar next to filters).
- Replace the two xlsx/pdf icon buttons with one Export button opening
  a dialog: format as large icon radio cards, fields as checkboxes
  (select-all toggle), record count (default all, capped per format).
  Export applies the report's current filters and sort.
- Backend: export route accepts fields (whitelisted against the
  report's own columns) and limit; ReportExportService takes an
  optional column subset instead of always dumping every column.
- Fixed a real bug found while wiring this up: runAll() ignored the
  caller's sortBy/sortOrder and always used the report's default sort,
  so exports silently didn't match whatever order was on screen.
- Report daterange filters now use DatePickerInput + the shared
  getDateRangePresets() (Today/Last 7 days/This month/...) instead of
  two bare DateInputs, matching every other date-range filter in the
  app.
- Removed the reports hub grid page. /dashboard/reports now redirects
  to the first report the caller has access to, or /dashboard if they
  have none.
This commit is contained in:
Nathnael
2026-08-13 08:53:03 +00:00
parent 58b47318e9
commit 6a102bf938
10 changed files with 293 additions and 187 deletions

View File

@@ -36,6 +36,7 @@ export class ReportExportService {
def: ReportDefinition,
rows: Record<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[] = def.columns,
): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(def.title.slice(0, 31));
@@ -45,14 +46,14 @@ export class ReportExportService {
sheet.addRow([]);
}
const headerRow = sheet.addRow(def.columns.map((c) => c.label));
const headerRow = sheet.addRow(columns.map((c) => c.label));
headerRow.font = { bold: true };
for (const row of rows) {
sheet.addRow(def.columns.map((c) => row[c.key] ?? null));
sheet.addRow(columns.map((c) => row[c.key] ?? null));
}
def.columns.forEach((col, i) => {
columns.forEach((col, i) => {
const format = NUMBER_FORMAT[col.type];
const excelCol = sheet.getColumn(i + 1);
excelCol.width = Math.max(col.label.length + 2, 12);
@@ -67,8 +68,9 @@ export class ReportExportService {
def: ReportDefinition,
rows: Record<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[] = def.columns,
): Promise<Buffer> {
const html = this.buildHtml(def, rows, kpis);
const html = this.buildHtml(def, rows, kpis, columns);
return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true });
}
@@ -76,6 +78,7 @@ export class ReportExportService {
def: ReportDefinition,
rows: Record<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[],
): string {
const esc = (v: unknown) =>
String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -89,11 +92,11 @@ export class ReportExportService {
.join('')}</div>`
: '';
const head = def.columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
const body = rows
.map(
(row) =>
`<tr>${def.columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
)
.join('');

View File

@@ -132,7 +132,9 @@ export class ReportRunnerService {
const params = coerceParams(def, raw);
const ctx = { ds: this.ds, params, directions };
const qb = def.query(ctx);
const sort = resolveSort(def, undefined, undefined);
// Same sort the on-screen table is using, not always the default — an
// export is supposed to match what the user is looking at.
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
if (sort) qb.orderBy(sort.expr, sort.dir);
const items = await qb.limit(limit).getRawMany();
if (items.length >= limit) {

View File

@@ -53,20 +53,30 @@ export class ReportsController {
@ApiOperation({ summary: 'Export a report to xlsx or pdf' })
async export(
@Param('key') key: string,
@Query() query: RawReportQuery & { format?: string },
@Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string },
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
): Promise<void> {
const def = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = query.format === 'pdf' ? 'pdf' : 'xlsx';
const cap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP;
const formatCap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP;
const requestedLimit = Number(query.limit);
const cap = requestedLimit > 0 ? Math.min(requestedLimit, formatCap) : formatCap;
// Whitelist against the report's own columns — an unknown/empty `fields`
// value falls back to every column rather than shipping a blank sheet.
const requestedFields = query.fields?.split(',').filter(Boolean);
const columns = requestedFields?.length
? def.columns.filter((c) => requestedFields.includes(c.key))
: def.columns;
const exportColumns = columns.length ? columns : def.columns;
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
const buffer =
format === 'pdf'
? await this.exportService.toPdf(def, items, kpis)
: await this.exportService.toXlsx(def, items, kpis);
? await this.exportService.toPdf(def, items, kpis, exportColumns)
: await this.exportService.toXlsx(def, items, kpis, exportColumns);
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);

View File

@@ -41,7 +41,7 @@ import InvoicesPage from "./pages/invoices/InvoicesPage";
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import ReportsHubPage from "./pages/reports/ReportsHubPage";
import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect";
import ReportPage from "./pages/reports/ReportPage";
import AuditLogsPage from "./pages/AuditLogsPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
@@ -214,7 +214,7 @@ const App = () => {
<Route path="/dashboard" element={<Navigate to={landingPath} replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsIndexRedirect /></RequirePermission>} />
<Route path="reports/:reportKey" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportPage /></RequirePermission>} />
<Route path="audit-logs" element={<RequirePermission permission={FREIGHT_PERMS.auditLog.view}><AuditLogsPage /></RequirePermission>} />
{/* Dev/testing page for the mock AI booking assistant. */}

View File

@@ -0,0 +1,158 @@
import { Button, Checkbox, Group, Modal, Radio, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { Download, FileSpreadsheet, FileText } from "lucide-react";
import { useState } from "react";
import { reportsService } from "@/services/reports.service";
import type { ReportCatalogEntry, ReportRunParams } from "@/types/reports";
interface ReportExportButtonProps {
def: ReportCatalogEntry;
/** Filters + sort currently applied on screen — no key/page/pageSize. */
params: Omit<ReportRunParams, "key" | "page" | "pageSize">;
}
const RECORD_OPTIONS = [
{ value: "all", label: "All (up to format limit)" },
{ value: "100", label: "First 100" },
{ value: "500", label: "First 500" },
{ value: "1000", label: "First 1,000" },
];
/** 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);
}
/** One export button: format, which fields, how many records — applies the
* filters/sort already on screen. Record count defaults to all (capped
* server-side per format). */
export function ReportExportButton({ def, params }: ReportExportButtonProps) {
const [opened, setOpened] = useState(false);
const [format, setFormat] = useState<"xlsx" | "pdf">("xlsx");
const [fields, setFields] = useState<string[]>(def.columns.map((c) => c.key));
const [records, setRecords] = useState("all");
const [exporting, setExporting] = useState(false);
const allSelected = fields.length === def.columns.length;
const toggleField = (key: string) =>
setFields((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
const toggleAll = () => setFields(allSelected ? [] : def.columns.map((c) => c.key));
const handleDownload = async () => {
setExporting(true);
try {
const blob = await reportsService.download(def.key, format, {
...params,
fields: allSelected ? undefined : fields.join(","),
limit: records === "all" ? undefined : records,
});
saveBlob(blob, `${def.key}.${format}`);
setOpened(false);
} finally {
setExporting(false);
}
};
return (
<>
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Download size={16} />}
onClick={() => setOpened(true)}
>
Export
</Button>
<Modal opened={opened} onClose={() => setOpened(false)} title="Export report" radius="md" size="md">
<Stack gap="lg">
<div>
<Text size="sm" fw={600} mb="xs">
Format
</Text>
<Radio.Group value={format} onChange={(v) => setFormat(v as "xlsx" | "pdf")}>
<SimpleGrid cols={2}>
<Radio.Card value="xlsx" radius="md" p="md">
<Group wrap="nowrap" gap="sm">
<Radio.Indicator />
<FileSpreadsheet size={22} />
<Text size="sm" fw={500}>
Excel (.xlsx)
</Text>
</Group>
</Radio.Card>
<Radio.Card value="pdf" radius="md" p="md">
<Group wrap="nowrap" gap="sm">
<Radio.Indicator />
<FileText size={22} />
<Text size="sm" fw={500}>
PDF
</Text>
</Group>
</Radio.Card>
</SimpleGrid>
</Radio.Group>
</div>
<div>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={600}>
Fields
</Text>
<Button variant="subtle" size="compact-sm" onClick={toggleAll}>
{allSelected ? "Clear all" : "Select all"}
</Button>
</Group>
<SimpleGrid cols={2} spacing="xs">
{def.columns.map((col) => (
<Checkbox
key={col.key}
label={col.label}
checked={fields.includes(col.key)}
onChange={() => toggleField(col.key)}
/>
))}
</SimpleGrid>
</div>
<Select
label="Records"
value={records}
onChange={(v) => setRecords(v ?? "all")}
data={RECORD_OPTIONS}
allowDeselect={false}
radius="md"
size="sm"
/>
<Text size="xs" c="dimmed">
Uses the filters and sorting currently applied to the report.
</Text>
<Group justify="flex-end">
<Button variant="default" radius="md" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
radius="md"
loading={exporting}
disabled={!fields.length}
leftSection={<Download size={16} />}
onClick={() => void handleDownload()}
>
Download
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
export default ReportExportButton;

View File

@@ -1,7 +1,8 @@
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
import { DateInput } from "@mantine/dates";
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 {
@@ -29,26 +30,20 @@ export function ReportFilters({ filters, values, onChange }: ReportFiltersProps)
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) })}
<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={150}
w={230}
/>
<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 (

View File

@@ -2,16 +2,17 @@ import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Too
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, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
import { ArrowDown, ArrowUp, ArrowUpDown, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { PageHeader } from "@/components/page";
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 { ReportChart } from "./ReportChart";
import { ReportExportButton } from "./ReportExportButton";
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
import { formatKpiValue, formatReportCell } from "./report-format";
@@ -35,24 +36,18 @@ 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);
/** Full-page usage: renders the title/description as a PageHeader (no back
* arrow) with export/refresh as its actions, instead of inline above the
* table. Off by default for embedded sections. */
pageHeader?: boolean;
}
/**
* 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.
* filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export.
* Adding a report never touches this file.
*/
export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProps) {
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
@@ -60,12 +55,22 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
const [exporting, setExporting] = useState<"xlsx" | "pdf" | null>(null);
const [view, setView] = useState<"table" | "chart">("table");
// 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.
const appliedParams = useMemo(() => {
const sort = sorting[0];
return {
sortBy: sort?.id,
sortOrder: sort ? (sort.desc ? "DESC" as const : "ASC" as const) : undefined,
...debouncedFilters,
...(def?.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
};
}, [def, sorting, debouncedFilters, idKeyValue]);
const runParams: ReportRunParams | undefined = useMemo(() => {
if (!def) return undefined;
const sort = sorting[0];
return {
key: def.key,
// Chart view isn't paginated on screen — pull the server's max page (100)
@@ -73,12 +78,9 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
// so the chart doesn't silently plot a fraction of the filtered rows.
page: view === "chart" ? 1 : pagination.pageIndex + 1,
pageSize: view === "chart" ? 100 : pagination.pageSize,
sortBy: sort?.id,
sortOrder: sort ? (sort.desc ? "DESC" : "ASC") : undefined,
...debouncedFilters,
...(def.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
...appliedParams,
};
}, [def, view, pagination, sorting, debouncedFilters, idKeyValue]);
}, [def, view, pagination, appliedParams]);
const { data, isLoading, isError, isFetching, refetch } = useQuery({
...api.reports.run.queryOptions({ input: runParams as ReportRunParams }),
@@ -106,26 +108,55 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
[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;
}
const chartToggle = def.chart ? (
<SegmentedControl
size="xs"
value={view}
onChange={(v) => setView(v as "table" | "chart")}
data={[
{ label: <LayoutGrid size={14} />, value: "table" },
{ label: <LineChart size={14} />, value: "chart" },
]}
/>
) : null;
const refreshButton = (
<Tooltip label="Refresh">
<ActionIcon
variant="default"
radius="md"
loading={isFetching}
onClick={() => void refetch()}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Tooltip>
);
const exportButton = <ReportExportButton def={def} params={appliedParams} />;
return (
<Stack gap="md">
{pageHeader ? (
<PageHeader
title={def.title}
subtitle={def.description}
action={
<Group gap="xs">
{exportButton}
{refreshButton}
</Group>
}
/>
) : null}
{data?.kpis.length ? (
<KpiStrip
loading={isLoading}
@@ -146,50 +177,13 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
}}
/>
<Group gap="xs">
{def.chart ? (
<SegmentedControl
size="xs"
value={view}
onChange={(v) => setView(v as "table" | "chart")}
data={[
{ label: <LayoutGrid size={14} />, value: "table" },
{ label: <LineChart size={14} />, value: "chart" },
]}
/>
) : null}
<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>
{chartToggle}
{pageHeader ? null : (
<>
{exportButton}
{refreshButton}
</>
)}
</Group>
</Group>
</Box>

View File

@@ -1,23 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { ReportView } from "@/components/reports/ReportView";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
export default function ReportPage() {
const { reportKey = "" } = useParams<{ reportKey: string }>();
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
return (
<PageContainer>
<PageHeader
title={def?.title ?? "Report"}
subtitle={def?.description}
backTo="/dashboard/reports"
/>
<ReportView reportKey={reportKey} />
<ReportView reportKey={reportKey} pageHeader />
</PageContainer>
);
}

View File

@@ -1,64 +0,0 @@
import { Alert, Card, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { ReportCatalogEntry } from "@/types/reports";
const GROUP_ORDER: ReportCatalogEntry["group"][] = ["Commercial", "Operations", "Finance"];
export default function ReportsHubPage() {
const navigate = useNavigate();
const { data: catalog, isLoading, isError } = useQuery(api.reports.catalog.queryOptions());
const groups = GROUP_ORDER.map((group) => ({
group,
reports: (catalog ?? []).filter((r) => r.group === group),
})).filter((g) => g.reports.length);
return (
<PageContainer>
<PageHeader title="Reports" subtitle="Every report you have access to, grouped by area." />
{isError ? <Alert color="red">Failed to load the report catalog.</Alert> : null}
{isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height={96} radius="md" />
))}
</SimpleGrid>
) : null}
{!isLoading && !isError && !groups.length ? (
<Text c="dimmed">You don't have access to any reports yet.</Text>
) : null}
{groups.map(({ group, reports }) => (
<Stack key={group} gap="sm">
<Title order={4}>{group}</Title>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{reports.map((report) => (
<Card
key={report.key}
withBorder
radius="md"
p="md"
className="cursor-pointer transition-colors hover:bg-gray-50"
onClick={() => navigate(`/dashboard/reports/${report.key}`)}
>
<Text fw={600} c="edr-text">
{report.title}
</Text>
<Text size="sm" c="dimmed" mt={4}>
{report.description}
</Text>
</Card>
))}
</SimpleGrid>
</Stack>
))}
</PageContainer>
);
}

View File

@@ -0,0 +1,17 @@
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 />;
}