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);
|
||||
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.
|
||||
</p>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
/>
|
||||
</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">
|
||||
<Button onClick={handleEnqueue} disabled={enqueuing}>
|
||||
{enqueuing ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||
|
||||
Reference in New Issue
Block a user