feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports
End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1 in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial) remain deferred per the gap audit at the bottom of that doc. Highlights: - Sales performance report: 7 KPI tiles, pipeline funnel + stage velocity + win-rate-over-time + source conversion + rep leaderboard charts, deal-heat section, 5 detail tables, stage / lead-cat / outcome filters. - Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy churn, tenure histogram, signing box plot, occupancy by area, docs in pipeline), 4 tables. Module-OFF banner when tenancies disabled. - Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths, tenancies), column-whitelist composer, date filter, CSV download, save-as-template. Registry-only extension path for the remaining 6 entities documented at src/lib/reports/custom/registry.ts. - Templates: load / modify / save / save-as on Sales / Operational / Custom. ?templateId= URL deep-link hydration via useRef guard. Active-template badge clears when the user drives view-state via wrapped setters; raw setters used on template apply so the badge survives. - Scheduled runs: BullMQ poll fires due schedules, mints report_runs, renders, optionally emails. Recipients optional (zero-recipient schedules archive without sending). PDF-only output for v1. Schedule dialog re-mounts via key prop on schedule.id transitions to avoid setState-in-effect reset patterns. - Server-side PDF endpoint + shared payload renderer (lib/pdf/reports/payload-report.tsx) so client + scheduler share one rendering path. - Shared currency formatter (lib/reports/format-currency.ts) consolidates 5 duplicated formatMoney helpers; fixes hardcoded 'USD' in detail tables; pre-formats money rows so PDF export (which strips column.format callbacks at the JSON boundary) renders consistently with CSV / XLSX. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
86
src/lib/reports/exporters/pdf.ts
Normal file
86
src/lib/reports/exporters/pdf.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ExportResult, ReportPayload } from '@/lib/reports/types';
|
||||
|
||||
/**
|
||||
* PDF export. Unlike CSV + Excel (which can serialise in the browser),
|
||||
* PDF generation runs server-side via `@react-pdf/renderer` so the
|
||||
* client posts the payload to `/api/v1/reports/export-pdf` and receives
|
||||
* the rendered bytes back.
|
||||
*
|
||||
* The server resolves the active port's branding (logo + primary
|
||||
* color + name) so per-port theming flows through automatically — the
|
||||
* client doesn't need to send branding fields.
|
||||
*/
|
||||
|
||||
interface PdfExportOptions {
|
||||
/** Filename override mirroring the CSV / Excel exporters. */
|
||||
filenameOverride?: string;
|
||||
}
|
||||
|
||||
export async function exportReportAsPdf(
|
||||
payload: ReportPayload,
|
||||
options: PdfExportOptions = {},
|
||||
): Promise<ExportResult> {
|
||||
// Serialise dates to ISO so they survive the JSON trip.
|
||||
const wireBody = {
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
filenameSlug: payload.filenameSlug,
|
||||
range: {
|
||||
from: payload.range.from.toISOString(),
|
||||
to: payload.range.to.toISOString(),
|
||||
},
|
||||
kpis: payload.kpis,
|
||||
sections: payload.sections.map((s) => ({
|
||||
title: s.title,
|
||||
columns: s.columns.map((c) => ({
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
align: c.align,
|
||||
// `format` is a function and isn't serialisable; the server
|
||||
// falls back to plain stringification, which matches the CSV
|
||||
// exporter's default behaviour when no format is set.
|
||||
})),
|
||||
// Apply client-side format functions BEFORE serialising so the
|
||||
// server sees pre-formatted strings. This preserves money /
|
||||
// percentage / date formatting that the original ReportPayload
|
||||
// declared.
|
||||
rows: s.rows.map((row) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const col of s.columns) {
|
||||
out[col.key] = col.format ? col.format(row[col.key]) : row[col.key];
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
})),
|
||||
filenameOverride: options.filenameOverride,
|
||||
};
|
||||
|
||||
const res = await fetch('/api/v1/reports/export-pdf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(wireBody),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(text || `PDF generation failed (${res.status})`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const cdHeader = res.headers.get('content-disposition') ?? '';
|
||||
const match = cdHeader.match(/filename="([^"]+)"/);
|
||||
const filename = match?.[1] ?? options.filenameOverride ?? defaultPdfFilename(payload);
|
||||
|
||||
return {
|
||||
filename,
|
||||
mimeType: 'application/pdf',
|
||||
body: blob,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultPdfFilename(payload: ReportPayload): string {
|
||||
const fromIso = payload.range.from.toISOString().slice(0, 10);
|
||||
const toIso = payload.range.to.toISOString().slice(0, 10);
|
||||
return `${payload.filenameSlug}-${fromIso}_${toIso}.pdf`;
|
||||
}
|
||||
Reference in New Issue
Block a user