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.
+
+
Output format
+
+ {(['pdf', 'csv'] as const).map((f) => (
+ setOutputFormat(f)}
+ >
+ {f.toUpperCase()}
+
+ ))}
+
+
+ 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) {
/>
+
+
Output format
+
+ {(['pdf', 'csv'] as const).map((f) => (
+ setOutputFormat(f)}
+ >
+ {f.toUpperCase()}
+
+ ))}
+
+
+ PDF for printable / branded reports; CSV for spreadsheet analysis.
+
+
{enqueuing ? : null}
diff --git a/src/lib/services/report-render.service.ts b/src/lib/services/report-render.service.ts
index 9d7d4ded..ac204126 100644
--- a/src/lib/services/report-render.service.ts
+++ b/src/lib/services/report-render.service.ts
@@ -55,6 +55,24 @@ interface RenderCtx {
interface KindRenderer {
fetchData: (portId: string, params: Record) => Promise;
render: (data: unknown, ctx: RenderCtx) => Promise;
+ /** CSV serializer for P6. Each kind emits rows shaped to its data; the
+ * helper below converts a 2-D string array to RFC-4180 bytes. */
+ toCsv: (data: unknown) => Buffer;
+}
+
+/**
+ * Escape a single cell for RFC-4180 CSV: double-quote always, double
+ * any internal quotes. Null/undefined → empty quoted cell.
+ */
+function csvCell(value: unknown): string {
+ if (value === null || value === undefined) return '""';
+ const s = String(value);
+ return `"${s.replace(/"/g, '""')}"`;
+}
+
+function rowsToCsv(rows: Array>): Buffer {
+ const lines = rows.map((r) => r.map(csvCell).join(','));
+ return Buffer.from(lines.join('\r\n') + '\r\n', 'utf-8');
}
const REPORT_RENDER_MAP: Record = {
@@ -68,6 +86,18 @@ const REPORT_RENDER_MAP: Record = {
data: data as PipelineData,
}),
),
+ toCsv: (data) => {
+ const d = data as PipelineData;
+ const rows: Array> = [['Section', 'Key', 'Value']];
+ for (const [stage, ct] of Object.entries(d.stageCounts ?? {})) {
+ rows.push(['Stage count', stage, ct]);
+ }
+ for (const i of d.topInterests ?? []) {
+ rows.push(['Top interest', i.id, i.berthPrice ?? '']);
+ }
+ rows.push(['Meta', 'generatedAt', d.generatedAt ?? '']);
+ return rowsToCsv(rows);
+ },
},
clients: {
fetchData: fetchActivityData as KindRenderer['fetchData'],
@@ -79,6 +109,23 @@ const REPORT_RENDER_MAP: Record = {
data: data as ActivityData,
}),
),
+ toCsv: (data) => {
+ const d = data as ActivityData;
+ const rows: Array> = [
+ ['id', 'createdAt', 'action', 'entityType', 'entityId', 'userId'],
+ ];
+ for (const l of d.logs ?? []) {
+ rows.push([
+ l.id,
+ l.createdAt instanceof Date ? l.createdAt.toISOString() : String(l.createdAt),
+ l.action,
+ l.entityType,
+ l.entityId ?? '',
+ l.userId ?? '',
+ ]);
+ }
+ return rowsToCsv(rows);
+ },
},
berths: {
fetchData: fetchOccupancyData as KindRenderer['fetchData'],
@@ -90,6 +137,17 @@ const REPORT_RENDER_MAP: Record = {
data: data as OccupancyData,
}),
),
+ toCsv: (data) => {
+ const d = data as OccupancyData;
+ const rows: Array> = [['metric', 'value']];
+ rows.push(['totalBerths', d.totalBerths]);
+ rows.push(['occupancyRate', d.occupancyRate]);
+ for (const [status, ct] of Object.entries(d.statusCounts ?? {})) {
+ rows.push([`status:${status}`, ct]);
+ }
+ rows.push(['generatedAt', d.generatedAt ?? '']);
+ return rowsToCsv(rows);
+ },
},
interests: {
fetchData: fetchRevenueData as KindRenderer['fetchData'],
@@ -101,6 +159,20 @@ const REPORT_RENDER_MAP: Record = {
data: data as RevenueData,
}),
),
+ toCsv: (data) => {
+ const d = data as RevenueData;
+ const rows: Array> = [['metric', 'value']];
+ rows.push(['totalCompleted', d.totalCompleted]);
+ rows.push(['totalForecast', d.totalForecast]);
+ for (const [stage, amt] of Object.entries(d.stageRevenue ?? {})) {
+ rows.push([`stageRevenue:${stage}`, amt]);
+ }
+ for (const [stage, w] of Object.entries(d.pipelineWeights ?? {})) {
+ rows.push([`pipelineWeight:${stage}`, w]);
+ }
+ rows.push(['generatedAt', d.generatedAt ?? '']);
+ return rowsToCsv(rows);
+ },
},
};
@@ -137,25 +209,46 @@ export async function renderReportRun(reportRunId: string): Promise {
const params = (run.config as Record) ?? {};
const data = await renderer.fetchData(run.portId, params);
- const pdfBytes = await renderer.render(data, ctx);
+
+ // P6: branch on outputFormat. PDF is the v1 default; CSV serializes
+ // the same fetched data via the kind-specific toCsv mapper. PNG is
+ // deferred — fail the run with a descriptive message so the rep
+ // sees it land as 'failed' in /reports/runs (no partial blob lands).
+ let bytes: Buffer;
+ let extension: 'pdf' | 'csv';
+ let contentType: string;
+ if (run.outputFormat === 'csv') {
+ bytes = renderer.toCsv(data);
+ extension = 'csv';
+ contentType = 'text/csv; charset=utf-8';
+ } else if (run.outputFormat === 'png') {
+ throw new CodedError('VALIDATION_ERROR', {
+ internalMessage:
+ 'PNG output is not yet implemented. Use PDF or CSV for now (Reports P6 PNG deferred).',
+ });
+ } else {
+ bytes = await renderer.render(data, ctx);
+ extension = 'pdf';
+ contentType = 'application/pdf';
+ }
const fileId = crypto.randomUUID();
- const storagePath = buildStoragePath(port.slug, 'reports', run.id, fileId, 'pdf');
+ const storagePath = buildStoragePath(port.slug, 'reports', run.id, fileId, extension);
const backend = await getStorageBackend();
- await backend.put(storagePath, pdfBytes, {
- contentType: 'application/pdf',
- sizeBytes: pdfBytes.length,
+ await backend.put(storagePath, bytes, {
+ contentType,
+ sizeBytes: bytes.length,
});
putStoragePath = storagePath;
await db.insert(files).values({
id: fileId,
portId: run.portId,
- filename: `${run.kind}-${run.id.slice(0, 8)}.pdf`,
- originalName: `${run.kind}-report.pdf`,
- mimeType: 'application/pdf',
- sizeBytes: String(pdfBytes.length),
+ filename: `${run.kind}-${run.id.slice(0, 8)}.${extension}`,
+ originalName: `${run.kind}-report.${extension}`,
+ mimeType: contentType,
+ sizeBytes: String(bytes.length),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'misc',
@@ -165,7 +258,7 @@ export async function renderReportRun(reportRunId: string): Promise {
const updated = await updateReportRunStatus(run.id, run.portId, {
status: 'complete',
storageKey: fileId,
- sizeBytes: pdfBytes.length,
+ sizeBytes: bytes.length,
});
putStoragePath = null;
return updated;