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

@@ -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}"`);