feat(reports): PDF report exporter foundation + dashboard report (phase A)
Production-grade PDF reporting for the CRM. Phase A ships the
foundation (branded layout, render pipeline, API route) plus the
first report kind — the dashboard summary. Phases B, C, D add the
remaining report kinds, saved templates, and the preview modal.
Stack: @react-pdf/renderer (already in package.json). Single primary
font (Helvetica/Helvetica-Bold), per-port primary color + logo,
table-based section layout. Charts will become tables here on
purpose; reports are for printed reference and review, where
exact numbers beat at-a-glance shapes. We can revisit Recharts-as-
SVG embedding if a stakeholder asks for chart visuals.
New files:
- src/lib/pdf/reports/types.ts: discriminated-union ReportConfig
covering dashboard / clients / berths / interests kinds. Only
dashboard is wired in phase A; the others throw a clear
not-implemented error from pickDocument().
- src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off
branding.primaryColor. Computes a readable foreground color
(luminance check) for the accent stripe so dark-brand ports
still read at AA.
- src/lib/pdf/reports/branded-document.tsx: page wrapper with
fixed footer (port name, generated-at timestamp, page numbers
via react-pdf's render-prop pattern).
- src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget
SimpleTable sections. Each section gated on the widget id being
present in config.widgetIds AND data being supplied.
- src/lib/pdf/reports/render-report.ts: single entry point that
resolves branding (logoUrl + primaryColor + portName from
getPortBrandingConfig + ports.name), dispatches via
discriminated-union switch, returns Buffer via renderToBuffer.
Exhaustiveness check at the bottom catches unhandled variants
at compile time.
- src/lib/services/dashboard-report-data.service.ts: server-side
data resolver. PDF_DASHBOARD_WIDGETS is the public widget list
for the dialog picker; each id maps to a dashboard.service.ts
fetcher invoked only when the rep selected that widget.
- src/app/api/v1/reports/generate/route.ts: POST endpoint, zod
discriminated-union body schema, withAuth + withPermission
'reports.export' gating, audit-log write on success, RFC 5987
Content-Disposition for unicode-safe filenames.
- src/components/reports/export-dashboard-pdf-button.tsx: dialog
with section checkboxes + title input. Permission-gated client-
side (server re-checks). Raw fetch (not apiFetch) to pull the
binary blob with X-Port-Id header attached manually.
- tests/unit/pdf-report-renderer.test.ts: renders three fixture
cases — full set / sparse / no-logo — and asserts the buffer
starts with the `%PDF-` magic bytes and is non-trivial in size.
DashboardShell gains an Export PDF button between the date-range
picker and the Customize widgets menu (gated on reports.export).
Verified: tsc clean, vitest 1451/1451 (3 new render tests included).
The first end-to-end manual test (export a real dashboard) is in
Phase D after the preview modal lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ExportDashboardPdfButton } from '@/components/reports/export-dashboard-pdf-button';
|
||||
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
||||
import { DateRangePicker } from './date-range-picker';
|
||||
import { TimezoneDriftBanner } from './timezone-drift-banner';
|
||||
@@ -165,6 +166,7 @@ export function DashboardShell({
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<DateRangePicker value={range} onChange={setRange} />
|
||||
<ExportDashboardPdfButton />
|
||||
<CustomizeWidgetsMenu />
|
||||
</div>
|
||||
}
|
||||
|
||||
159
src/components/reports/export-dashboard-pdf-button.tsx
Normal file
159
src/components/reports/export-dashboard-pdf-button.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FileDown, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
PDF_DASHBOARD_WIDGETS,
|
||||
type PdfDashboardWidgetId,
|
||||
} from '@/lib/services/dashboard-report-data.service';
|
||||
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||
|
||||
/**
|
||||
* Dashboard "Export as PDF" affordance. Per-export dialog lets reps
|
||||
* pick which sections to include + set a custom title. Saved-template
|
||||
* support lands in Phase C; for now, the dialog defaults all widgets
|
||||
* checked + the current date in the title.
|
||||
*
|
||||
* Permission-gated client-side on `reports.export`; the server route
|
||||
* re-checks via withPermission so a tampered client can't bypass.
|
||||
*/
|
||||
export function ExportDashboardPdfButton() {
|
||||
const { can } = usePermissions();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState(
|
||||
`Dashboard report — ${new Date().toLocaleDateString('en-GB')}`,
|
||||
);
|
||||
const [selected, setSelected] = useState<PdfDashboardWidgetId[]>(
|
||||
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!can('reports', 'export')) return null;
|
||||
|
||||
function toggle(id: PdfDashboardWidgetId) {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (selected.length === 0) {
|
||||
toast.error('Pick at least one section to include.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
// FormData isn't required (this is a JSON body), but we DO need
|
||||
// to forward the X-Port-Id header so the server-side resolver
|
||||
// knows which port's data to use. apiFetch is JSON-only and
|
||||
// doesn't expose the raw response body; we need the buffer here
|
||||
// so do a raw fetch with the same header convention.
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (typeof window !== 'undefined') {
|
||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api') {
|
||||
const portId = await resolvePortIdFromSlug(slug);
|
||||
if (portId) headers.set('X-Port-Id', portId);
|
||||
}
|
||||
}
|
||||
const res = await fetch('/api/v1/reports/generate', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: title.trim() || 'Dashboard report',
|
||||
config: {
|
||||
kind: 'dashboard',
|
||||
widgetIds: selected,
|
||||
},
|
||||
}),
|
||||
});
|
||||
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');
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Export failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
Export PDF
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export dashboard as PDF</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick which sections to include and set a title. The PDF inherits the active
|
||||
port's logo and primary color.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div 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)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Sections</Label>
|
||||
<div className="space-y-1.5 rounded-md border p-2">
|
||||
{PDF_DASHBOARD_WIDGETS.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="font-medium">{w.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{w.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={loading || 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>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user