From 3f9c4589e048c4af9de0cf3f2b1df08457baf420 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 16:35:13 +0200 Subject: [PATCH] feat(reports-p6): CSV output renderer + per-kind serializers + UI selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - report-render.service.ts: KindRenderer now carries a per-kind toCsv serializer alongside the PDF renderer. renderReportRun branches on run.outputFormat — 'pdf' (existing path), 'csv' (new), 'png' (throws with a clear "deferred" message so the run lands as 'failed' without a partial blob). Storage path, mime type, filename + extension all pick up the output-format suffix; the file row mirror records the matching mime so the standard download surface serves it correctly. - csvCell / rowsToCsv helpers: RFC-4180 escaping (always double-quoted, doubles internal quotes, CRLF newlines). - 4 per-kind serializers: - dashboard: stage-count + top-interests + meta as 3-col CSV - clients: activity log rows (id/createdAt/action/entityType/entityId/userId) - berths: occupancy metrics (totalBerths + occupancyRate + status counts) - interests: revenue metrics (completed + forecast + per-stage breakdown) - DashboardReportBuilder + SimpleReportBuilder gain an Output-format toggle (PDF | CSV). DashboardReportBuilder threads it into the queued- run POST; SimpleReportBuilder threads it directly. Synchronous PDF download path (Dashboard "Download PDF" button) stays PDF-only since /api/v1/reports/generate returns a blob, not a run row. PNG remains deferred — flagged with a follow-up TODO inside the render branch + the builder selector deliberately omits PNG so reps don't pick it and watch a run fail. Verified: tsc clean, 1493/1493 vitest. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../builders/dashboard-report-builder.tsx | 23 +++- .../builders/simple-report-builder.tsx | 22 +++- src/lib/services/report-render.service.ts | 113 ++++++++++++++++-- 3 files changed, 146 insertions(+), 12 deletions(-) diff --git a/src/components/reports/builders/dashboard-report-builder.tsx b/src/components/reports/builders/dashboard-report-builder.tsx index f22ad3fc..10708809 100644 --- a/src/components/reports/builders/dashboard-report-builder.tsx +++ b/src/components/reports/builders/dashboard-report-builder.tsx @@ -58,6 +58,7 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro last30.setDate(last30.getDate() - 30); const [dateFrom, setDateFrom] = useState(initialFrom ?? toIsoLocal(last30)); const [dateTo, setDateTo] = useState(initialTo ?? toIsoLocal(today)); + const [outputFormat, setOutputFormat] = useState<'pdf' | 'csv'>('pdf'); const [loading, setLoading] = useState(false); const [enqueuing, setEnqueuing] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); @@ -144,7 +145,7 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), }, - outputFormat: 'pdf', + outputFormat, }), }); if (!res.ok) { @@ -248,6 +249,26 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro Sections marked “needs date range” only render when both dates are set.

+
+ +
+ {(['pdf', 'csv'] as const).map((f) => ( + + ))} +
+

+ PDF carries the brand kit + section layout; CSV emits a flat metric-per-row dump + suitable for spreadsheet analysis. CSV applies to the queued-run path only. +

+
diff --git a/src/components/reports/builders/simple-report-builder.tsx b/src/components/reports/builders/simple-report-builder.tsx index 7dac7df3..474cfc04 100644 --- a/src/components/reports/builders/simple-report-builder.tsx +++ b/src/components/reports/builders/simple-report-builder.tsx @@ -55,6 +55,7 @@ export function SimpleReportBuilder({ portSlug, kind }: Props) { last30.setDate(last30.getDate() - 30); const [dateFrom, setDateFrom] = useState(toIsoLocal(last30)); const [dateTo, setDateTo] = useState(toIsoLocal(today)); + const [outputFormat, setOutputFormat] = useState<'pdf' | 'csv'>('pdf'); const [enqueuing, setEnqueuing] = useState(false); async function handleEnqueue() { @@ -65,7 +66,7 @@ export function SimpleReportBuilder({ portSlug, kind }: Props) { body: { kind, config: { kind, dateFrom, dateTo }, - outputFormat: 'pdf', + outputFormat, }, }); toast.success('Report queued — track progress in Runs.'); @@ -106,6 +107,25 @@ export function SimpleReportBuilder({ portSlug, kind }: Props) { /> +
+ +
+ {(['pdf', 'csv'] as const).map((f) => ( + + ))} +
+

+ PDF for printable / branded reports; CSV for spreadsheet analysis. +

+