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,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;
}