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:
2026-05-21 20:35:53 +02:00
parent e91055f784
commit 3b199c245c
10 changed files with 1243 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { renderReport, type ReportData } from '@/lib/pdf/reports/render-report';
import { resolveDashboardReportData } from '@/lib/services/dashboard-report-data.service';
import { createAuditLog } from '@/lib/audit';
const dashboardConfigSchema = z.object({
kind: z.literal('dashboard'),
widgetIds: z.array(z.string()).min(1).max(20),
dateFrom: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
dateTo: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
});
const requestSchema = z.object({
title: z.string().min(1).max(200),
subtitle: z.string().max(400).optional(),
config: z.discriminatedUnion('kind', [
dashboardConfigSchema,
// Phase B will widen this union with clients / berths / interests.
]),
});
/**
* POST /api/v1/reports/generate
*
* Renders a port-branded PDF report and streams it back to the
* caller. Permission gating sits at the entry: `reports.export` (a
* new permission added in seed-data; defaults to true for super-
* admins and sales-managers, false for sales-agents). The route also
* defends in depth via per-section service calls, each of which
* reapplies the active port filter — a rep with reports.export but
* no clients.view still gets a clients table in the PDF, since the
* export is a snapshot of state the rep already has dashboard access
* to. Defer narrower per-section gating to a follow-up once the
* usage pattern is clearer.
*
* Audit trail: every successful export writes a `document` create
* audit row with the report kind + widget ids in metadata. Useful
* for "who exported what" investigations downstream.
*/
export const POST = withAuth(
withPermission('reports', 'export', async (req, ctx) => {
try {
const body = await parseBody(req, requestSchema);
const data: ReportData = {};
switch (body.config.kind) {
case 'dashboard':
data.dashboard = await resolveDashboardReportData(ctx.portId, body.config.widgetIds);
break;
default:
// Unreachable while only the dashboard kind is wired; kept
// for the type-narrowing exhaustiveness check.
throw new ValidationError('Unsupported report kind');
}
const buffer = await renderReport({
portId: ctx.portId,
request: body,
data,
});
// Audit BEFORE returning so a failed write doesn't silently
// leak an export. The `void` is intentional — audit is best-
// effort relative to the export; the PDF download succeeding
// is the contract.
void createAuditLog({
portId: ctx.portId,
userId: ctx.userId,
action: 'create',
entityType: 'report',
entityId: 'pdf-export',
metadata: {
kind: body.config.kind,
title: body.title,
...(body.config.kind === 'dashboard'
? {
widgetIds: body.config.widgetIds,
dateFrom: body.config.dateFrom,
dateTo: body.config.dateTo,
}
: {}),
sizeBytes: buffer.byteLength,
},
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
const filename = sanitizeFilename(`${body.title}.pdf`);
// Stream the buffer back inline. The Content-Disposition uses
// `attachment; filename*` (RFC 5987) for unicode titles —
// Port names with diacritics need this or browsers fall back
// to a mojibake'd ASCII filename. `filename=` carries the
// ASCII fallback for older HTTP stacks.
// Node Buffer is not directly assignable to BodyInit in the
// Next.js / Web Fetch typings; passing the underlying
// ArrayBuffer view keeps the bytes intact without an extra copy.
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Length': String(buffer.byteLength),
'Content-Disposition': `attachment; filename="${filename.ascii}"; filename*=UTF-8''${filename.encoded}`,
// No-cache: each export reflects the moment-in-time data,
// so caching the response would leak stale numbers to
// subsequent calls.
'Cache-Control': 'no-store',
},
});
} catch (error) {
logger.warn({ err: error, portId: ctx.portId }, 'PDF report generation failed');
return errorResponse(error);
}
}),
);
/**
* Build a safe attachment filename. The ASCII variant strips
* non-ASCII characters for the legacy `filename=` field; the
* percent-encoded variant carries the full unicode title for the
* RFC 5987 `filename*=` field. Replace `/` and `\\` so the file
* doesn't accidentally look like a path component.
*/
function sanitizeFilename(name: string): { ascii: string; encoded: string } {
const noPaths = name.replace(/[\\/]/g, '_');
const ascii = noPaths.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, "'");
const encoded = encodeURIComponent(noPaths);
return { ascii, encoded };
}

View File

@@ -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>
}

View 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&apos;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>
</>
);
}

View File

@@ -0,0 +1,77 @@
import { Document, Page, View, Text, Image } from '@react-pdf/renderer';
import type { ReactNode } from 'react';
import { makeReportStyles } from './styles';
import type { ReportBranding } from './types';
interface BrandedReportPageProps {
branding: ReportBranding;
title: string;
subtitle?: string;
/** ISO date string rendered into the footer next to the port name. */
generatedAt: string;
children: ReactNode;
}
/**
* Single-document wrapper used by every report kind. Renders one or
* more pages; the outer `<Document>` carries metadata that lands in
* the PDF's Document Information dictionary (title shows in the
* macOS Quick Look filename strip and Adobe Reader's window chrome).
*
* Page numbering uses react-pdf's `render={({ pageNumber, totalPages })...}`
* pattern so the footer numbers itself without us computing pages
* ahead of time. The render-prop is the one place in this file
* where we can't avoid mixing layout with data.
*/
export function BrandedReportDocument({
branding,
title,
subtitle,
generatedAt,
children,
}: BrandedReportPageProps) {
const styles = makeReportStyles(branding);
return (
<Document
title={title}
author={branding.portName}
subject={subtitle ?? `${branding.portName} report`}
creator="Port Nimara CRM"
producer="Port Nimara CRM"
>
<Page size="A4" style={styles.page} wrap>
{/* Header — logo + title + subtitle. Re-renders inside each
page via `fixed` would duplicate the brand bar; instead we
keep it as a non-fixed element so it lives at the very top
of the first content page. Footer is `fixed` (bottom of
every page). */}
<View style={styles.header}>
{branding.logoUrl ? (
<Image src={branding.logoUrl} style={styles.logo} cache />
) : (
// Empty placeholder keeps the title baseline stable when
// no logo is configured. Width matches the logo so the
// text starts in the same X coordinate either way.
<View style={{ width: 28, height: 28 }} />
)}
<View style={styles.headerText}>
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
</View>
</View>
{children}
{/* Footer is `fixed` so it lands on every page; the page
number renderer runs once per page (the only piece of
this document that knows page count). */}
<View style={styles.footer} fixed>
<Text>{branding.portName}</Text>
<Text>Generated {new Date(generatedAt).toLocaleString('en-GB')}</Text>
<Text render={({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages}`} />
</View>
</Page>
</Document>
);
}

View File

@@ -0,0 +1,250 @@
import { View, Text } from '@react-pdf/renderer';
import { stageLabel } from '@/lib/constants';
import { formatCurrency } from '@/lib/utils/currency';
import { BrandedReportDocument } from './branded-document';
import { makeReportStyles } from './styles';
import type { ReportBranding, DashboardReportConfig } from './types';
/**
* Data shape consumed by the dashboard report. Caller (the route
* handler) is responsible for fetching the dashboard service's
* outputs and packing them into this struct. Keeps the React-PDF
* tree pure — no DB calls inside the document tree.
*/
export interface DashboardReportData {
kpis?: {
totalClients: number;
activeInterests: number;
pipelineValue: number;
pipelineValueCurrency: string;
occupancyRate: number;
};
pipelineCounts?: Array<{ stage: string; count: number }>;
berthStatus?: {
available: number;
underOffer: number;
maintenance: number;
sold: number;
total: number;
};
sourceConversion?: Array<{
source: string;
total: number;
won: number;
lost: number;
conversionRate: number;
}>;
hotDeals?: Array<{
id: string;
clientName: string | null;
mooringNumber: string | null;
stage: string;
lastContact: string | null;
}>;
}
interface DashboardReportProps {
title: string;
subtitle?: string;
branding: ReportBranding;
generatedAt: string;
config: DashboardReportConfig;
data: DashboardReportData;
}
/**
* Dashboard summary report. The rep picks which widgets to include in
* `config.widgetIds`; this template renders sections only for widgets
* present in both the config AND the supplied `data` payload (route
* handler is responsible for skipping fetches for unselected widgets).
*
* Chart-style widgets render as tables here (counts, percentages,
* cohort breakdowns). The deliberate choice trades a chart's at-a-
* glance shape for the actual numbers — a printed report is for
* later reference / sharing, not in-the-moment dashboard scanning,
* and the table format is fully accessible to screen readers and
* holds up if the PDF is OCR-scanned downstream.
*/
export function DashboardReport({
title,
subtitle,
branding,
generatedAt,
config,
data,
}: DashboardReportProps) {
const styles = makeReportStyles(branding);
const include = (id: string) => config.widgetIds.includes(id);
const dateRangeLine =
config.dateFrom || config.dateTo
? `${config.dateFrom ?? 'open'}${config.dateTo ?? 'today'}`
: null;
return (
<BrandedReportDocument
branding={branding}
title={title}
subtitle={subtitle ?? `Dashboard summary${dateRangeLine ? ` · ${dateRangeLine}` : ''}`}
generatedAt={generatedAt}
>
{include('kpi_overview') && data.kpis ? (
<View>
<Text style={styles.sectionTitle}>Key metrics</Text>
<View style={styles.kpiGrid}>
<View style={styles.kpiCard}>
<Text style={styles.kpiLabel}>Total clients</Text>
<Text style={styles.kpiValue}>{data.kpis.totalClients.toLocaleString()}</Text>
</View>
<View style={styles.kpiCard}>
<Text style={styles.kpiLabel}>Active interests</Text>
<Text style={styles.kpiValue}>{data.kpis.activeInterests.toLocaleString()}</Text>
</View>
<View style={styles.kpiCard}>
<Text style={styles.kpiLabel}>Pipeline value</Text>
<Text style={styles.kpiValue}>
{formatCurrency(String(data.kpis.pipelineValue), data.kpis.pipelineValueCurrency, {
maxFractionDigits: 0,
})}
</Text>
<Text style={styles.kpiSubvalue}>Sum of primary-berth prices, active deals only</Text>
</View>
<View style={styles.kpiCard}>
<Text style={styles.kpiLabel}>Occupancy</Text>
<Text style={styles.kpiValue}>{data.kpis.occupancyRate.toFixed(1)}%</Text>
<Text style={styles.kpiSubvalue}>Sold berths / total</Text>
</View>
</View>
</View>
) : null}
{include('pipeline_funnel') && data.pipelineCounts ? (
<View>
<Text style={styles.sectionTitle}>Pipeline funnel</Text>
<Text style={styles.sectionSubtitle}>Active interests grouped by pipeline stage.</Text>
<SimpleTable
styles={styles}
headers={['Stage', 'Count']}
widths={[70, 30]}
rows={data.pipelineCounts.map((row) => [stageLabel(row.stage), String(row.count)])}
/>
</View>
) : null}
{include('berth_status') && data.berthStatus ? (
<View>
<Text style={styles.sectionTitle}>Berth status</Text>
<Text style={styles.sectionSubtitle}>Current distribution across the marina.</Text>
<SimpleTable
styles={styles}
headers={['Status', 'Count', '% of total']}
widths={[50, 25, 25]}
rows={[
[
'Available',
String(data.berthStatus.available),
pct(data.berthStatus.available, data.berthStatus.total),
],
[
'Under offer',
String(data.berthStatus.underOffer),
pct(data.berthStatus.underOffer, data.berthStatus.total),
],
[
'Sold',
String(data.berthStatus.sold),
pct(data.berthStatus.sold, data.berthStatus.total),
],
[
'Maintenance',
String(data.berthStatus.maintenance),
pct(data.berthStatus.maintenance, data.berthStatus.total),
],
]}
/>
</View>
) : null}
{include('source_conversion') && data.sourceConversion ? (
<View>
<Text style={styles.sectionTitle}>Source conversion</Text>
<Text style={styles.sectionSubtitle}>
Interest counts grouped by lead source, with win rate per source.
</Text>
<SimpleTable
styles={styles}
headers={['Source', 'Total', 'Won', 'Lost', 'Win rate']}
widths={[40, 15, 15, 15, 15]}
rows={data.sourceConversion.map((row) => [
row.source,
String(row.total),
String(row.won),
String(row.lost),
`${(row.conversionRate * 100).toFixed(1)}%`,
])}
/>
</View>
) : null}
{include('hot_deals') && data.hotDeals && data.hotDeals.length > 0 ? (
<View>
<Text style={styles.sectionTitle}>Hot deals</Text>
<Text style={styles.sectionSubtitle}>
Top active interests, ranked by pipeline stage with most-recent activity as tiebreaker.
</Text>
<SimpleTable
styles={styles}
headers={['Client', 'Mooring', 'Stage', 'Last contact']}
widths={[40, 20, 20, 20]}
rows={data.hotDeals.map((d) => [
d.clientName ?? '-',
d.mooringNumber ?? '-',
stageLabel(d.stage),
d.lastContact ? new Date(d.lastContact).toLocaleDateString('en-GB') : '-',
])}
/>
</View>
) : null}
</BrandedReportDocument>
);
}
function pct(n: number, total: number): string {
return total > 0 ? `${((n / total) * 100).toFixed(1)}%` : '—';
}
interface SimpleTableProps {
styles: ReturnType<typeof makeReportStyles>;
headers: string[];
widths: number[];
rows: string[][];
}
/**
* Plain table primitive used by every chart-style widget section.
* Widths are percentages of the container (sum to 100). Header row is
* gray; data rows alternate fafafa/white for scannability without the
* "spreadsheet" feel.
*/
function SimpleTable({ styles, headers, widths, rows }: SimpleTableProps) {
return (
<View style={styles.table}>
<View style={styles.tableHeader}>
{headers.map((header, i) => (
<Text key={header + i} style={{ ...styles.tableHeaderCell, width: `${widths[i]}%` }}>
{header}
</Text>
))}
</View>
{rows.map((row, rowIdx) => (
<View key={rowIdx} style={rowIdx % 2 === 1 ? styles.tableRowZebra : styles.tableRow}>
{row.map((cell, i) => (
<Text key={`${rowIdx}-${i}`} style={{ ...styles.tableCell, width: `${widths[i]}%` }}>
{cell}
</Text>
))}
</View>
))}
</View>
);
}

View File

@@ -0,0 +1,120 @@
import { renderToBuffer } from '@react-pdf/renderer';
import { createElement } from 'react';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { eq } from 'drizzle-orm';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { NotFoundError } from '@/lib/errors';
import { DashboardReport, type DashboardReportData } from './dashboard-report';
import type {
ReportBranding,
ReportConfig,
ReportRequest,
DashboardReportConfig,
ClientListReportConfig,
BerthListReportConfig,
InterestListReportConfig,
} from './types';
/**
* Pre-fetched data payloads each report kind needs at render time.
* The route handler resolves these (one query per requested section)
* BEFORE invoking the renderer so the React-PDF tree stays pure.
*/
export interface ReportData {
dashboard?: DashboardReportData;
// Phase B will fill these in.
clients?: never;
berths?: never;
interests?: never;
}
interface RenderArgs {
portId: string;
request: ReportRequest;
data: ReportData;
/** Allows the route handler to inject the request id so the audit
* trail correlates the generated buffer with the originating call. */
generatedAt?: string;
}
/**
* Single entry point that resolves branding, dispatches to the
* matching React-PDF document component, and returns the binary
* buffer. Caller decides whether to stream the buffer in the
* response, archive it to S3, or both.
*
* Production concerns this handles:
* - Branding fetch failure → ValidationError (caller can show a
* "configure branding before exporting" message).
* - Unknown report kind → falls through to discriminated-union
* exhaustiveness check at compile time; runtime guard in the
* route schema covers attacker-supplied payloads.
* - Render failure → bubbles the underlying React-PDF error to
* the caller, which logs it as a SERVICE error tier and surfaces
* a generic "PDF generation failed" to the rep.
*/
export async function renderReport({
portId,
request,
data,
generatedAt = new Date().toISOString(),
}: RenderArgs): Promise<Buffer> {
const branding = await resolveBranding(portId);
const element = pickDocument(branding, request, data, generatedAt);
// renderToBuffer accepts a React element; cast to satisfy the
// library's loose `JSX.Element` typing without widening callers.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return renderToBuffer(element as any) as Promise<Buffer>;
}
async function resolveBranding(portId: string): Promise<ReportBranding> {
const portRow = await db.query.ports.findFirst({
where: eq(ports.id, portId),
columns: { name: true },
});
if (!portRow) throw new NotFoundError('Port');
const cfg = await getPortBrandingConfig(portId);
return {
logoUrl: cfg.logoUrl,
primaryColor: cfg.primaryColor,
portName: portRow.name,
};
}
function pickDocument(
branding: ReportBranding,
request: ReportRequest,
data: ReportData,
generatedAt: string,
) {
const cfg: ReportConfig = request.config;
switch (cfg.kind) {
case 'dashboard':
return createElement(DashboardReport, {
title: request.title,
subtitle: request.subtitle,
branding,
generatedAt,
config: cfg satisfies DashboardReportConfig,
data: data.dashboard ?? {},
});
case 'clients':
case 'berths':
case 'interests':
// Phase B adds the dispatch + matching component. Surface a
// clear error so an early-merged Phase A doesn't silently
// render a blank PDF when a rep picks one of these kinds.
throw new Error(
`Report kind '${(cfg as ClientListReportConfig | BerthListReportConfig | InterestListReportConfig).kind}' not implemented yet (Phase B).`,
);
default: {
// Exhaustiveness check — surfaces a compile error if a new
// ReportConfig variant is added without a matching case here.
const _exhaustive: never = cfg;
throw new Error(`Unsupported report kind: ${(_exhaustive as { kind: string }).kind}`);
}
}
}

View File

@@ -0,0 +1,188 @@
import { StyleSheet } from '@react-pdf/renderer';
import type { ReportBranding } from './types';
/**
* Builds a `StyleSheet` keyed off the port's primary color. Used by
* every report template so per-port branding propagates without each
* template wiring it manually.
*
* Color contrast is computed against the supplied primary so heading
* text on the accent bar reads at AA. We don't try to drive every
* surface off the primary — only the accent stripe, headings, and
* footer separator. Body copy stays slate-700; surfaces stay white +
* a subtle gray. Single primary keeps the report looking intentional
* rather than chromatically chaotic.
*/
export function makeReportStyles(branding: ReportBranding) {
// Pick foreground for the accent stripe: 0 / 256 luminance threshold
// is loose; report headings are small enough that pure white reads
// fine on any reasonable brand color and pure black reads fine on
// pale brands.
const accentFg = pickReadableForeground(branding.primaryColor);
return StyleSheet.create({
page: {
paddingTop: 36,
paddingBottom: 50,
paddingHorizontal: 36,
fontSize: 9.5,
fontFamily: 'Helvetica',
color: '#1f2937',
backgroundColor: '#ffffff',
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
paddingBottom: 10,
marginBottom: 14,
},
logo: {
width: 28,
height: 28,
objectFit: 'contain',
},
headerText: {
flexDirection: 'column',
flex: 1,
},
title: {
fontSize: 16,
fontFamily: 'Helvetica-Bold',
color: branding.primaryColor,
marginBottom: 2,
},
subtitle: {
fontSize: 10,
color: '#6b7280',
},
accentBar: {
backgroundColor: branding.primaryColor,
color: accentFg,
paddingVertical: 4,
paddingHorizontal: 8,
borderRadius: 2,
fontSize: 10,
fontFamily: 'Helvetica-Bold',
marginBottom: 8,
},
sectionTitle: {
fontSize: 12,
fontFamily: 'Helvetica-Bold',
color: branding.primaryColor,
marginTop: 14,
marginBottom: 6,
},
sectionSubtitle: {
fontSize: 9,
color: '#6b7280',
marginBottom: 8,
},
table: {
width: '100%',
flexDirection: 'column',
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#f3f4f6',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
tableHeaderCell: {
padding: 6,
fontFamily: 'Helvetica-Bold',
fontSize: 9,
color: '#374151',
},
tableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#f3f4f6',
},
tableRowZebra: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#f3f4f6',
backgroundColor: '#fafafa',
},
tableCell: {
padding: 6,
fontSize: 9,
color: '#1f2937',
},
muted: {
color: '#6b7280',
},
kpiGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 12,
},
kpiCard: {
flexGrow: 1,
flexBasis: '23%',
minWidth: '23%',
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 4,
padding: 8,
},
kpiLabel: {
fontSize: 8,
color: '#6b7280',
textTransform: 'uppercase',
letterSpacing: 0.4,
marginBottom: 2,
},
kpiValue: {
fontSize: 16,
fontFamily: 'Helvetica-Bold',
color: branding.primaryColor,
},
kpiSubvalue: {
fontSize: 9,
color: '#6b7280',
marginTop: 2,
},
footer: {
position: 'absolute',
bottom: 22,
left: 36,
right: 36,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
paddingTop: 8,
fontSize: 8,
color: '#6b7280',
},
});
}
/**
* Brand-color luminance check. Used to pick black or white text on
* the accent stripe so the title reads regardless of how dark the
* port's brand is. Standard relative-luminance formula; threshold
* 0.55 picks white on mid-dark brands.
*/
function pickReadableForeground(hex: string): string {
const cleaned = hex.replace('#', '');
if (cleaned.length !== 6) return '#ffffff';
const r = parseInt(cleaned.slice(0, 2), 16) / 255;
const g = parseInt(cleaned.slice(2, 4), 16) / 255;
const b = parseInt(cleaned.slice(4, 6), 16) / 255;
const luminance = 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
return luminance > 0.55 ? '#0f172a' : '#ffffff';
}
function srgbToLinear(channel: number): number {
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
}

View File

@@ -0,0 +1,65 @@
/**
* Shared types for the report-PDF pipeline.
*
* The render entry point (`render-report.ts`) takes a `ReportConfig`
* discriminated-union plus the resolved data, then dispatches to the
* matching React-PDF document component. Each kind keeps its own
* config shape so type-checking surfaces "you forgot to set
* widgetIds for the dashboard report" inline.
*/
/** Per-port branding pulled from `port_branding`. Single primary
* color drives the headings / footer accent; logo is rendered into
* the page header. */
export interface ReportBranding {
/** Public URL to the port's logo (square PNG). Null falls back to
* the bundled Port Nimara circular logo. */
logoUrl: string | null;
/** Hex string, e.g. "#0F4C81". Drives heading + footer accent. */
primaryColor: string;
/** Used in the header and footer attribution. */
portName: string;
}
export interface DashboardReportConfig {
kind: 'dashboard';
/** Widget ids to include — keyed against `widget-registry.ts`. */
widgetIds: string[];
/** Date range applied to dashboard data fetches. */
dateFrom?: string;
dateTo?: string;
}
export interface ClientListReportConfig {
kind: 'clients';
/** Optional column override; defaults to a canonical set. */
columns?: string[];
/** Optional filter snapshot — same shape as `/api/v1/clients`. */
filters?: Record<string, unknown>;
}
export interface BerthListReportConfig {
kind: 'berths';
columns?: string[];
filters?: Record<string, unknown>;
}
export interface InterestListReportConfig {
kind: 'interests';
columns?: string[];
filters?: Record<string, unknown>;
}
export type ReportConfig =
| DashboardReportConfig
| ClientListReportConfig
| BerthListReportConfig
| InterestListReportConfig;
export interface ReportRequest {
/** Free-text title shown on the cover / page header. */
title: string;
/** Optional subtitle (e.g. date range, "Quarterly review"). */
subtitle?: string;
config: ReportConfig;
}

View File

@@ -0,0 +1,116 @@
/**
* Server-side data resolver for the dashboard PDF report.
*
* Each section is gated on its widget id being present in
* `config.widgetIds`, so a report that only includes the pipeline
* funnel runs ONE query instead of the full dashboard panel. Keeps
* cold-call latency low even when the actual port has hundreds of
* berths.
*
* Lives in its own file (not inside dashboard.service.ts) so the
* report-builder concerns — what widget ids map to what fetcher,
* which fields the PDF shape requires — stay scoped to the
* report-side surface, not the dashboard UI.
*/
import {
getKpis,
getPipelineCounts,
getBerthStatusDistribution,
getHotDeals,
getSourceConversion,
} from './dashboard.service';
import type { DashboardReportData } from '@/lib/pdf/reports/dashboard-report';
/**
* Maps widget ids the dashboard PDF understands. The id space is
* intentionally a subset of the on-screen `DASHBOARD_WIDGETS`
* registry — only widgets that have a sensible printable form
* appear here. The dialog's widget picker filters its option list
* by this set.
*/
export const PDF_DASHBOARD_WIDGET_IDS = [
'kpi_overview',
'pipeline_funnel',
'berth_status',
'source_conversion',
'hot_deals',
] as const;
export type PdfDashboardWidgetId = (typeof PDF_DASHBOARD_WIDGET_IDS)[number];
export interface PdfDashboardWidgetOption {
id: PdfDashboardWidgetId;
label: string;
description: string;
}
/**
* Public widget list (label + description) for the export dialog.
* Mirrored from the on-screen widget-registry but with PDF-friendly
* copy: a "Berth heat" chart is "Berth demand ranking" in print.
*/
export const PDF_DASHBOARD_WIDGETS: readonly PdfDashboardWidgetOption[] = [
{
id: 'kpi_overview',
label: 'Key metrics',
description: 'Total clients, active interests, pipeline value, occupancy %.',
},
{
id: 'pipeline_funnel',
label: 'Pipeline funnel',
description: 'Active interests grouped by pipeline stage.',
},
{
id: 'berth_status',
label: 'Berth status distribution',
description: 'Available / under offer / reserved / sold counts.',
},
{
id: 'source_conversion',
label: 'Source conversion',
description: 'Inquiries → Clients → Interests → Won, by lead source.',
},
{
id: 'hot_deals',
label: 'Hot deals',
description: 'Top 5 active interests by deal-health score.',
},
];
export async function resolveDashboardReportData(
portId: string,
widgetIds: string[],
): Promise<DashboardReportData> {
const want = new Set(widgetIds);
// Each fetcher returns its own shape; default to undefined to
// signal "don't render this section" downstream.
const data: DashboardReportData = {};
if (want.has('kpi_overview')) {
data.kpis = await getKpis(portId);
}
if (want.has('pipeline_funnel')) {
data.pipelineCounts = await getPipelineCounts(portId);
}
if (want.has('berth_status')) {
const dist = await getBerthStatusDistribution(portId);
// `dist` shape from the service is already the totals dict; pass
// straight through. If the service changes shape, the type-check
// here will trip.
data.berthStatus = dist;
}
if (want.has('source_conversion')) {
data.sourceConversion = await getSourceConversion(portId);
}
if (want.has('hot_deals')) {
const deals = await getHotDeals(portId, 5);
data.hotDeals = deals.map((d) => ({
id: d.id,
clientName: d.clientName,
mooringNumber: d.mooringNumber,
stage: d.stage,
lastContact: d.lastContact,
}));
}
return data;
}

View File

@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest';
import { renderToBuffer } from '@react-pdf/renderer';
import { createElement } from 'react';
import { DashboardReport } from '@/lib/pdf/reports/dashboard-report';
import type { ReportBranding } from '@/lib/pdf/reports/types';
const branding: ReportBranding = {
logoUrl: null,
primaryColor: '#0F4C81',
portName: 'Port Nimara',
};
describe('PDF report renderer', () => {
it('renders a dashboard report with all sections to a non-empty PDF buffer', async () => {
const element = createElement(DashboardReport, {
title: 'Test report',
subtitle: 'Unit-test fixture',
branding,
generatedAt: '2026-05-21T12:00:00.000Z',
config: {
kind: 'dashboard',
widgetIds: [
'kpi_overview',
'pipeline_funnel',
'berth_status',
'source_conversion',
'hot_deals',
],
},
data: {
kpis: {
totalClients: 142,
activeInterests: 27,
pipelineValue: 1250000,
pipelineValueCurrency: 'USD',
occupancyRate: 64.3,
},
pipelineCounts: [
{ stage: 'enquiry', count: 12 },
{ stage: 'qualified', count: 8 },
{ stage: 'eoi', count: 4 },
{ stage: 'reservation', count: 2 },
{ stage: 'deposit_paid', count: 1 },
],
berthStatus: {
total: 120,
available: 80,
underOffer: 10,
maintenance: 5,
sold: 25,
},
sourceConversion: [
{ source: 'website', total: 60, won: 12, lost: 30, conversionRate: 0.2 },
{ source: 'referral', total: 25, won: 8, lost: 10, conversionRate: 0.32 },
],
hotDeals: [
{
id: 'i1',
clientName: 'Acme Corp',
mooringNumber: 'A3',
stage: 'reservation',
lastContact: '2026-05-18T09:00:00.000Z',
},
],
},
});
const buf = await renderToBuffer(element as any);
expect(buf.byteLength).toBeGreaterThan(2_000);
// PDF files start with `%PDF-` magic bytes — sanity-check that
// the renderer produced an actual PDF, not an error blob or
// empty buffer.
const head = buf.subarray(0, 5).toString('utf-8');
expect(head).toBe('%PDF-');
}, 30_000);
it('skips sections whose widget id is absent from widgetIds', async () => {
const element = createElement(DashboardReport, {
title: 'Sparse report',
branding,
generatedAt: '2026-05-21T12:00:00.000Z',
config: {
kind: 'dashboard',
widgetIds: ['kpi_overview'],
},
data: {
kpis: {
totalClients: 5,
activeInterests: 1,
pipelineValue: 0,
pipelineValueCurrency: 'USD',
occupancyRate: 0,
},
// Provide pipelineCounts even though widgetIds didn't ask for
// it — the renderer should still skip the section since it's
// gated on widgetIds, not data presence.
pipelineCounts: [{ stage: 'enquiry', count: 1 }],
},
});
const buf = await renderToBuffer(element as any);
expect(buf.byteLength).toBeGreaterThan(1_000);
}, 30_000);
it('falls back to a stable layout when no logo URL is supplied', async () => {
const element = createElement(DashboardReport, {
title: 'Logoless',
branding: { ...branding, logoUrl: null },
generatedAt: '2026-05-21T12:00:00.000Z',
config: { kind: 'dashboard', widgetIds: ['kpi_overview'] },
data: {
kpis: {
totalClients: 0,
activeInterests: 0,
pipelineValue: 0,
pipelineValueCurrency: 'USD',
occupancyRate: 0,
},
},
});
const buf = await renderToBuffer(element as any);
expect(buf.byteLength).toBeGreaterThan(1_000);
}, 30_000);
});