diff --git a/src/app/api/v1/reports/generate/route.ts b/src/app/api/v1/reports/generate/route.ts
new file mode 100644
index 00000000..e9ff2dd5
--- /dev/null
+++ b/src/app/api/v1/reports/generate/route.ts
@@ -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 };
+}
diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx
index 11f5860d..831def6a 100644
--- a/src/components/dashboard/dashboard-shell.tsx
+++ b/src/components/dashboard/dashboard-shell.tsx
@@ -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={
+
}
diff --git a/src/components/reports/export-dashboard-pdf-button.tsx b/src/components/reports/export-dashboard-pdf-button.tsx
new file mode 100644
index 00000000..ea93836f
--- /dev/null
+++ b/src/components/reports/export-dashboard-pdf-button.tsx
@@ -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(
+ 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 (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/lib/pdf/reports/branded-document.tsx b/src/lib/pdf/reports/branded-document.tsx
new file mode 100644
index 00000000..654b0150
--- /dev/null
+++ b/src/lib/pdf/reports/branded-document.tsx
@@ -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 `` 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 (
+
+
+ {/* 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). */}
+
+ {branding.logoUrl ? (
+
+ ) : (
+ // 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.
+
+ )}
+
+ {title}
+ {subtitle ? {subtitle} : null}
+
+
+
+ {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). */}
+
+ {branding.portName}
+ Generated {new Date(generatedAt).toLocaleString('en-GB')}
+ `Page ${pageNumber} of ${totalPages}`} />
+
+
+
+ );
+}
diff --git a/src/lib/pdf/reports/dashboard-report.tsx b/src/lib/pdf/reports/dashboard-report.tsx
new file mode 100644
index 00000000..c9e2ce33
--- /dev/null
+++ b/src/lib/pdf/reports/dashboard-report.tsx
@@ -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 (
+
+ {include('kpi_overview') && data.kpis ? (
+
+ Key metrics
+
+
+ Total clients
+ {data.kpis.totalClients.toLocaleString()}
+
+
+ Active interests
+ {data.kpis.activeInterests.toLocaleString()}
+
+
+ Pipeline value
+
+ {formatCurrency(String(data.kpis.pipelineValue), data.kpis.pipelineValueCurrency, {
+ maxFractionDigits: 0,
+ })}
+
+ Sum of primary-berth prices, active deals only
+
+
+ Occupancy
+ {data.kpis.occupancyRate.toFixed(1)}%
+ Sold berths / total
+
+
+
+ ) : null}
+
+ {include('pipeline_funnel') && data.pipelineCounts ? (
+
+ Pipeline funnel
+ Active interests grouped by pipeline stage.
+ [stageLabel(row.stage), String(row.count)])}
+ />
+
+ ) : null}
+
+ {include('berth_status') && data.berthStatus ? (
+
+ Berth status
+ Current distribution across the marina.
+
+
+ ) : null}
+
+ {include('source_conversion') && data.sourceConversion ? (
+
+ Source conversion
+
+ Interest counts grouped by lead source, with win rate per source.
+
+ [
+ row.source,
+ String(row.total),
+ String(row.won),
+ String(row.lost),
+ `${(row.conversionRate * 100).toFixed(1)}%`,
+ ])}
+ />
+
+ ) : null}
+
+ {include('hot_deals') && data.hotDeals && data.hotDeals.length > 0 ? (
+
+ Hot deals
+
+ Top active interests, ranked by pipeline stage with most-recent activity as tiebreaker.
+
+ [
+ d.clientName ?? '-',
+ d.mooringNumber ?? '-',
+ stageLabel(d.stage),
+ d.lastContact ? new Date(d.lastContact).toLocaleDateString('en-GB') : '-',
+ ])}
+ />
+
+ ) : null}
+
+ );
+}
+
+function pct(n: number, total: number): string {
+ return total > 0 ? `${((n / total) * 100).toFixed(1)}%` : '—';
+}
+
+interface SimpleTableProps {
+ styles: ReturnType;
+ 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 (
+
+
+ {headers.map((header, i) => (
+
+ {header}
+
+ ))}
+
+ {rows.map((row, rowIdx) => (
+
+ {row.map((cell, i) => (
+
+ {cell}
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/src/lib/pdf/reports/render-report.ts b/src/lib/pdf/reports/render-report.ts
new file mode 100644
index 00000000..5125b137
--- /dev/null
+++ b/src/lib/pdf/reports/render-report.ts
@@ -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 {
+ 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;
+}
+
+async function resolveBranding(portId: string): Promise {
+ 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}`);
+ }
+ }
+}
diff --git a/src/lib/pdf/reports/styles.ts b/src/lib/pdf/reports/styles.ts
new file mode 100644
index 00000000..ea435925
--- /dev/null
+++ b/src/lib/pdf/reports/styles.ts
@@ -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);
+}
diff --git a/src/lib/pdf/reports/types.ts b/src/lib/pdf/reports/types.ts
new file mode 100644
index 00000000..3fe8195a
--- /dev/null
+++ b/src/lib/pdf/reports/types.ts
@@ -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;
+}
+
+export interface BerthListReportConfig {
+ kind: 'berths';
+ columns?: string[];
+ filters?: Record;
+}
+
+export interface InterestListReportConfig {
+ kind: 'interests';
+ columns?: string[];
+ filters?: Record;
+}
+
+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;
+}
diff --git a/src/lib/services/dashboard-report-data.service.ts b/src/lib/services/dashboard-report-data.service.ts
new file mode 100644
index 00000000..6d8562d7
--- /dev/null
+++ b/src/lib/services/dashboard-report-data.service.ts
@@ -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 {
+ 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;
+}
diff --git a/tests/unit/pdf-report-renderer.test.ts b/tests/unit/pdf-report-renderer.test.ts
new file mode 100644
index 00000000..463ec620
--- /dev/null
+++ b/tests/unit/pdf-report-renderer.test.ts
@@ -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);
+});