From 6a102bf9383bb7705adfa48aeb9d9ed2c4148882 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 08:53:03 +0000 Subject: [PATCH] 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. --- .../modules/reports/report-export.service.ts | 15 +- .../modules/reports/report-runner.service.ts | 4 +- .../src/modules/reports/reports.controller.ts | 18 +- apps/edr-freight-web/backoffice/src/App.tsx | 4 +- .../components/reports/ReportExportButton.tsx | 158 ++++++++++++++++++ .../src/components/reports/ReportFilters.tsx | 37 ++-- .../src/components/reports/ReportView.tsx | 150 ++++++++--------- .../src/pages/reports/ReportPage.tsx | 13 +- .../src/pages/reports/ReportsHubPage.tsx | 64 ------- .../pages/reports/ReportsIndexRedirect.tsx | 17 ++ 10 files changed, 293 insertions(+), 187 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/reports/ReportsIndexRedirect.tsx diff --git a/apps/edr-freight-api/src/modules/reports/report-export.service.ts b/apps/edr-freight-api/src/modules/reports/report-export.service.ts index f07317134..f0919c9c7 100644 --- a/apps/edr-freight-api/src/modules/reports/report-export.service.ts +++ b/apps/edr-freight-api/src/modules/reports/report-export.service.ts @@ -36,6 +36,7 @@ export class ReportExportService { def: ReportDefinition, rows: Record[], kpis: ReportKpi[], + columns: ReportColumn[] = def.columns, ): Promise { 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[], kpis: ReportKpi[], + columns: ReportColumn[] = def.columns, ): Promise { - 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[], kpis: ReportKpi[], + columns: ReportColumn[], ): string { const esc = (v: unknown) => String(v ?? '').replace(/&/g, '&').replace(//g, '>'); @@ -89,11 +92,11 @@ export class ReportExportService { .join('')}` : ''; - const head = def.columns.map((c) => `${esc(c.label)}`).join(''); + const head = columns.map((c) => `${esc(c.label)}`).join(''); const body = rows .map( (row) => - `${def.columns.map((c) => `${esc(formatCell(row[c.key], c.type))}`).join('')}`, + `${columns.map((c) => `${esc(formatCell(row[c.key], c.type))}`).join('')}`, ) .join(''); diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts index 09933de42..a9b662077 100644 --- a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts index 49d0307eb..7941c7583 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -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 { 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}"`); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 94ad11f1b..fa2798f5c 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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 = () => { } /> }> } /> - } /> + } /> } /> } /> {/* Dev/testing page for the mock AI booking assistant. */} diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx new file mode 100644 index 000000000..76ff7d628 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx @@ -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; +} + +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(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 ( + <> + + + setOpened(false)} title="Export report" radius="md" size="md"> + +
+ + Format + + setFormat(v as "xlsx" | "pdf")}> + + + + + + + Excel (.xlsx) + + + + + + + + + PDF + + + + + +
+ +
+ + + Fields + + + + + {def.columns.map((col) => ( + toggleField(col.key)} + /> + ))} + +
+ +