Files
pn-new-crm/src/components/reports/builders/dashboard-report-builder.tsx
Matt 3f9c4589e0 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>
2026-05-25 16:35:13 +02:00

369 lines
13 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Eye, FileDown, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { DatePicker } from '@/components/ui/date-picker';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
PDF_DASHBOARD_WIDGETS,
PDF_DASHBOARD_CATEGORY_LABELS,
type PdfDashboardWidgetId,
type PdfDashboardWidgetCategory,
} from '@/lib/services/dashboard-report-widgets';
import { triggerBlobDownload } from '@/lib/utils/download';
import { resolvePortIdFromSlug } from '@/lib/api/client';
import {
SavedTemplatesPicker,
type SavedTemplate,
} from '@/components/reports/saved-templates-picker';
import { PdfPreviewModal } from '@/components/reports/pdf-preview-modal';
function toIsoLocal(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
interface Props {
portSlug: string;
/** YYYY-MM-DD from a URL search-param so deep-links from the dashboard
* Export button can pre-fill the range the rep was already viewing. */
initialFrom?: string;
initialTo?: string;
}
/**
* Page-mounted Dashboard report builder. Migrates the export dialog body
* into a dedicated page (Reports P4). Same widget grouping + date-range
* controls + preview + saved-templates picker; the only behaviour change
* is that submit no longer closes a Dialog — it stays on the builder so
* the rep can tweak + re-export.
*/
export function DashboardReportBuilder({ portSlug, initialFrom, initialTo }: Props) {
const router = useRouter();
const [title, setTitle] = useState(`Report - ${new Date().toLocaleDateString(undefined)}`);
const [selected, setSelected] = useState<PdfDashboardWidgetId[]>(
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
);
const today = new Date();
const last30 = new Date(today);
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);
const previewPayload = useMemo(
() => ({
title: title.trim() || 'Report',
config: {
kind: 'dashboard' as const,
widgetIds: selected,
...(dateFrom ? { dateFrom } : {}),
...(dateTo ? { dateTo } : {}),
},
}),
[title, selected, dateFrom, dateTo],
);
function toggle(id: PdfDashboardWidgetId) {
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
async function portHeader(): Promise<HeadersInit> {
const headers = new Headers({ 'Content-Type': 'application/json' });
const portId = await resolvePortIdFromSlug(portSlug);
if (portId) headers.set('X-Port-Id', portId);
return headers;
}
async function handleDownload() {
if (selected.length === 0) {
toast.error('Pick at least one section to include.');
return;
}
setLoading(true);
try {
const res = await fetch('/api/v1/reports/generate', {
method: 'POST',
headers: await portHeader(),
body: JSON.stringify({
title: title.trim() || 'Report',
config: {
kind: 'dashboard',
widgetIds: selected,
...(dateFrom ? { dateFrom } : {}),
...(dateTo ? { dateTo } : {}),
},
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Export failed (${res.status})`);
}
const blob = await res.blob();
const filename = title.trim().replace(/[\\/]/g, '_') + '.pdf';
triggerBlobDownload(blob, filename);
toast.success('Report downloaded');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Export failed');
} finally {
setLoading(false);
}
}
/**
* P3 path: enqueue a `report_runs` row instead of doing a synchronous
* download. The BullMQ worker picks it up, renders, and parks the file
* on the report row. The rep then sees it in the /reports/runs list.
*/
async function handleEnqueueRun() {
if (selected.length === 0) {
toast.error('Pick at least one section to include.');
return;
}
setEnqueuing(true);
try {
const res = await fetch('/api/v1/reports/runs', {
method: 'POST',
headers: await portHeader(),
body: JSON.stringify({
kind: 'dashboard',
config: {
kind: 'dashboard',
widgetIds: selected,
...(dateFrom ? { dateFrom } : {}),
...(dateTo ? { dateTo } : {}),
},
outputFormat,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Enqueue failed (${res.status})`);
}
toast.success('Report queued — track progress in Runs.');
router.push(`/${portSlug}/reports/runs`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Enqueue failed');
} finally {
setEnqueuing(false);
}
}
return (
<div className="space-y-4">
<SavedTemplatesPicker
kind="dashboard"
currentConfig={{ widgetIds: selected }}
onApply={(t: SavedTemplate) => {
const cfg = t.config as { widgetIds?: string[] };
if (Array.isArray(cfg.widgetIds)) {
setSelected(
cfg.widgetIds.filter((id): id is PdfDashboardWidgetId =>
PDF_DASHBOARD_WIDGETS.some((w) => w.id === id),
),
);
}
if (t.name) setTitle(t.name);
}}
/>
<Card>
<CardHeader>
<CardTitle className="text-sm">Title + window</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="export-title">Title</Label>
<Input
id="export-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="max-w-md"
/>
</div>
<div className="space-y-1">
<Label>Report window</Label>
<div className="flex flex-wrap items-center gap-2">
<DatePicker
id="export-date-from"
value={dateFrom}
onChange={setDateFrom}
placeholder="Start"
size="sm"
className="w-[150px]"
/>
<span className="text-xs text-muted-foreground"></span>
<DatePicker
id="export-date-to"
value={dateTo}
onChange={setDateTo}
placeholder="End"
size="sm"
className="w-[150px]"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const t = new Date();
const start = new Date(t);
start.setDate(start.getDate() - 30);
setDateFrom(toIsoLocal(start));
setDateTo(toIsoLocal(t));
}}
className="h-8 text-xs"
>
Last 30 days
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const t = new Date();
const start = new Date(t);
start.setDate(start.getDate() - 90);
setDateFrom(toIsoLocal(start));
setDateTo(toIsoLocal(t));
}}
className="h-8 text-xs"
>
Last 90 days
</Button>
</div>
<p className="text-xs text-muted-foreground">
Drives time-period sections (new clients, berths sold, occupancy timeline, etc.).
Sections marked &ldquo;needs date range&rdquo; 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>
<Card>
<CardHeader>
<CardTitle className="text-sm">Sections</CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-[60vh] space-y-3 overflow-y-auto rounded-md border p-2">
{(
Object.entries(PDF_DASHBOARD_CATEGORY_LABELS) as Array<
[PdfDashboardWidgetCategory, string]
>
).map(([category, label]) => {
const items = PDF_DASHBOARD_WIDGETS.filter((w) => w.category === category);
if (items.length === 0) return null;
return (
<div key={category} className="space-y-1">
<div className="px-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="space-y-0.5">
{items.map((w) => (
<label
key={w.id}
className="flex items-start gap-2 cursor-pointer rounded-sm p-1 hover:bg-muted/50"
>
<Checkbox
checked={selected.includes(w.id)}
onCheckedChange={() => toggle(w.id)}
aria-label={w.label}
/>
<div className="text-sm leading-tight">
<div className="flex items-center gap-1.5">
<span className="font-medium">{w.label}</span>
{w.isChart ? (
<span className="shrink-0 whitespace-nowrap rounded-full bg-primary/10 px-1.5 py-px text-[8px] font-medium uppercase tracking-wide leading-none text-primary">
chart
</span>
) : null}
{w.requiresPeriod ? (
<span className="shrink-0 whitespace-nowrap rounded-full bg-amber-100 px-1.5 py-px text-[8px] font-medium uppercase tracking-wide leading-none text-amber-800">
needs date range
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground">{w.description}</div>
</div>
</label>
))}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => setPreviewOpen(true)}
disabled={loading || enqueuing || selected.length === 0}
>
<Eye className="mr-1.5 h-4 w-4" aria-hidden />
Preview
</Button>
<Button
variant="outline"
onClick={handleEnqueueRun}
disabled={loading || enqueuing || selected.length === 0}
>
{enqueuing ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden /> : null}
Queue + go to Runs
</Button>
<Button onClick={handleDownload} disabled={loading || enqueuing || selected.length === 0}>
{loading ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
)}
Download PDF
</Button>
</div>
{previewOpen ? (
<PdfPreviewModal
open
onOpenChange={setPreviewOpen}
payload={previewPayload}
filename={`${title.trim().replace(/[\\/]/g, '_') || 'report'}.pdf`}
title={`Preview: ${title.trim() || 'Report'}`}
/>
) : null}
</div>
);
}