feat(reports-p6): CSV output renderer + per-kind serializers + UI selector
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,7 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro
|
|||||||
last30.setDate(last30.getDate() - 30);
|
last30.setDate(last30.getDate() - 30);
|
||||||
const [dateFrom, setDateFrom] = useState(initialFrom ?? toIsoLocal(last30));
|
const [dateFrom, setDateFrom] = useState(initialFrom ?? toIsoLocal(last30));
|
||||||
const [dateTo, setDateTo] = useState(initialTo ?? toIsoLocal(today));
|
const [dateTo, setDateTo] = useState(initialTo ?? toIsoLocal(today));
|
||||||
|
const [outputFormat, setOutputFormat] = useState<'pdf' | 'csv'>('pdf');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [enqueuing, setEnqueuing] = useState(false);
|
const [enqueuing, setEnqueuing] = useState(false);
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
@@ -144,7 +145,7 @@ export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Pro
|
|||||||
...(dateFrom ? { dateFrom } : {}),
|
...(dateFrom ? { dateFrom } : {}),
|
||||||
...(dateTo ? { dateTo } : {}),
|
...(dateTo ? { dateTo } : {}),
|
||||||
},
|
},
|
||||||
outputFormat: 'pdf',
|
outputFormat,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
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.
|
Sections marked “needs date range” only render when both dates are set.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Output format</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['pdf', 'csv'] as const).map((f) => (
|
||||||
|
<Button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={outputFormat === f ? 'default' : 'outline'}
|
||||||
|
onClick={() => setOutputFormat(f)}
|
||||||
|
>
|
||||||
|
{f.toUpperCase()}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function SimpleReportBuilder({ portSlug, kind }: Props) {
|
|||||||
last30.setDate(last30.getDate() - 30);
|
last30.setDate(last30.getDate() - 30);
|
||||||
const [dateFrom, setDateFrom] = useState(toIsoLocal(last30));
|
const [dateFrom, setDateFrom] = useState(toIsoLocal(last30));
|
||||||
const [dateTo, setDateTo] = useState(toIsoLocal(today));
|
const [dateTo, setDateTo] = useState(toIsoLocal(today));
|
||||||
|
const [outputFormat, setOutputFormat] = useState<'pdf' | 'csv'>('pdf');
|
||||||
const [enqueuing, setEnqueuing] = useState(false);
|
const [enqueuing, setEnqueuing] = useState(false);
|
||||||
|
|
||||||
async function handleEnqueue() {
|
async function handleEnqueue() {
|
||||||
@@ -65,7 +66,7 @@ export function SimpleReportBuilder({ portSlug, kind }: Props) {
|
|||||||
body: {
|
body: {
|
||||||
kind,
|
kind,
|
||||||
config: { kind, dateFrom, dateTo },
|
config: { kind, dateFrom, dateTo },
|
||||||
outputFormat: 'pdf',
|
outputFormat,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.success('Report queued — track progress in Runs.');
|
toast.success('Report queued — track progress in Runs.');
|
||||||
@@ -106,6 +107,25 @@ export function SimpleReportBuilder({ portSlug, kind }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Output format</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['pdf', 'csv'] as const).map((f) => (
|
||||||
|
<Button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={outputFormat === f ? 'default' : 'outline'}
|
||||||
|
onClick={() => setOutputFormat(f)}
|
||||||
|
>
|
||||||
|
{f.toUpperCase()}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
PDF for printable / branded reports; CSV for spreadsheet analysis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleEnqueue} disabled={enqueuing}>
|
<Button onClick={handleEnqueue} disabled={enqueuing}>
|
||||||
{enqueuing ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden /> : null}
|
{enqueuing ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||||
|
|||||||
@@ -55,6 +55,24 @@ interface RenderCtx {
|
|||||||
interface KindRenderer {
|
interface KindRenderer {
|
||||||
fetchData: (portId: string, params: Record<string, unknown>) => Promise<unknown>;
|
fetchData: (portId: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||||
render: (data: unknown, ctx: RenderCtx) => Promise<Buffer>;
|
render: (data: unknown, ctx: RenderCtx) => Promise<Buffer>;
|
||||||
|
/** 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<Array<string | number | null | undefined>>): 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<string, KindRenderer> = {
|
const REPORT_RENDER_MAP: Record<string, KindRenderer> = {
|
||||||
@@ -68,6 +86,18 @@ const REPORT_RENDER_MAP: Record<string, KindRenderer> = {
|
|||||||
data: data as PipelineData,
|
data: data as PipelineData,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
toCsv: (data) => {
|
||||||
|
const d = data as PipelineData;
|
||||||
|
const rows: Array<Array<string | number>> = [['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: {
|
clients: {
|
||||||
fetchData: fetchActivityData as KindRenderer['fetchData'],
|
fetchData: fetchActivityData as KindRenderer['fetchData'],
|
||||||
@@ -79,6 +109,23 @@ const REPORT_RENDER_MAP: Record<string, KindRenderer> = {
|
|||||||
data: data as ActivityData,
|
data: data as ActivityData,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
toCsv: (data) => {
|
||||||
|
const d = data as ActivityData;
|
||||||
|
const rows: Array<Array<string | number>> = [
|
||||||
|
['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: {
|
berths: {
|
||||||
fetchData: fetchOccupancyData as KindRenderer['fetchData'],
|
fetchData: fetchOccupancyData as KindRenderer['fetchData'],
|
||||||
@@ -90,6 +137,17 @@ const REPORT_RENDER_MAP: Record<string, KindRenderer> = {
|
|||||||
data: data as OccupancyData,
|
data: data as OccupancyData,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
toCsv: (data) => {
|
||||||
|
const d = data as OccupancyData;
|
||||||
|
const rows: Array<Array<string | number>> = [['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: {
|
interests: {
|
||||||
fetchData: fetchRevenueData as KindRenderer['fetchData'],
|
fetchData: fetchRevenueData as KindRenderer['fetchData'],
|
||||||
@@ -101,6 +159,20 @@ const REPORT_RENDER_MAP: Record<string, KindRenderer> = {
|
|||||||
data: data as RevenueData,
|
data: data as RevenueData,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
toCsv: (data) => {
|
||||||
|
const d = data as RevenueData;
|
||||||
|
const rows: Array<Array<string | number>> = [['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<ReportRun> {
|
|||||||
const params = (run.config as Record<string, unknown>) ?? {};
|
const params = (run.config as Record<string, unknown>) ?? {};
|
||||||
|
|
||||||
const data = await renderer.fetchData(run.portId, params);
|
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 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();
|
const backend = await getStorageBackend();
|
||||||
await backend.put(storagePath, pdfBytes, {
|
await backend.put(storagePath, bytes, {
|
||||||
contentType: 'application/pdf',
|
contentType,
|
||||||
sizeBytes: pdfBytes.length,
|
sizeBytes: bytes.length,
|
||||||
});
|
});
|
||||||
putStoragePath = storagePath;
|
putStoragePath = storagePath;
|
||||||
|
|
||||||
await db.insert(files).values({
|
await db.insert(files).values({
|
||||||
id: fileId,
|
id: fileId,
|
||||||
portId: run.portId,
|
portId: run.portId,
|
||||||
filename: `${run.kind}-${run.id.slice(0, 8)}.pdf`,
|
filename: `${run.kind}-${run.id.slice(0, 8)}.${extension}`,
|
||||||
originalName: `${run.kind}-report.pdf`,
|
originalName: `${run.kind}-report.${extension}`,
|
||||||
mimeType: 'application/pdf',
|
mimeType: contentType,
|
||||||
sizeBytes: String(pdfBytes.length),
|
sizeBytes: String(bytes.length),
|
||||||
storagePath,
|
storagePath,
|
||||||
storageBucket: env.MINIO_BUCKET,
|
storageBucket: env.MINIO_BUCKET,
|
||||||
category: 'misc',
|
category: 'misc',
|
||||||
@@ -165,7 +258,7 @@ export async function renderReportRun(reportRunId: string): Promise<ReportRun> {
|
|||||||
const updated = await updateReportRunStatus(run.id, run.portId, {
|
const updated = await updateReportRunStatus(run.id, run.portId, {
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
storageKey: fileId,
|
storageKey: fileId,
|
||||||
sizeBytes: pdfBytes.length,
|
sizeBytes: bytes.length,
|
||||||
});
|
});
|
||||||
putStoragePath = null;
|
putStoragePath = null;
|
||||||
return updated;
|
return updated;
|
||||||
|
|||||||
Reference in New Issue
Block a user