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 ( + <> + + + + + Export dashboard as PDF + + Pick which sections to include and set a title. The PDF inherits the active + port's logo and primary color. + + +
+
+ + setTitle(e.target.value)} /> +
+
+ +
+ {PDF_DASHBOARD_WIDGETS.map((w) => ( + + ))} +
+
+
+ + + + +
+
+ + ); +} 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); +});