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:
140
src/app/api/v1/reports/generate/route.ts
Normal file
140
src/app/api/v1/reports/generate/route.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { renderReport, type ReportData } from '@/lib/pdf/reports/render-report';
|
||||
import { resolveDashboardReportData } from '@/lib/services/dashboard-report-data.service';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
|
||||
const dashboardConfigSchema = z.object({
|
||||
kind: z.literal('dashboard'),
|
||||
widgetIds: z.array(z.string()).min(1).max(20),
|
||||
dateFrom: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
dateTo: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const requestSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
subtitle: z.string().max(400).optional(),
|
||||
config: z.discriminatedUnion('kind', [
|
||||
dashboardConfigSchema,
|
||||
// Phase B will widen this union with clients / berths / interests.
|
||||
]),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/reports/generate
|
||||
*
|
||||
* Renders a port-branded PDF report and streams it back to the
|
||||
* caller. Permission gating sits at the entry: `reports.export` (a
|
||||
* new permission added in seed-data; defaults to true for super-
|
||||
* admins and sales-managers, false for sales-agents). The route also
|
||||
* defends in depth via per-section service calls, each of which
|
||||
* reapplies the active port filter — a rep with reports.export but
|
||||
* no clients.view still gets a clients table in the PDF, since the
|
||||
* export is a snapshot of state the rep already has dashboard access
|
||||
* to. Defer narrower per-section gating to a follow-up once the
|
||||
* usage pattern is clearer.
|
||||
*
|
||||
* Audit trail: every successful export writes a `document` create
|
||||
* audit row with the report kind + widget ids in metadata. Useful
|
||||
* for "who exported what" investigations downstream.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('reports', 'export', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, requestSchema);
|
||||
|
||||
const data: ReportData = {};
|
||||
switch (body.config.kind) {
|
||||
case 'dashboard':
|
||||
data.dashboard = await resolveDashboardReportData(ctx.portId, body.config.widgetIds);
|
||||
break;
|
||||
default:
|
||||
// Unreachable while only the dashboard kind is wired; kept
|
||||
// for the type-narrowing exhaustiveness check.
|
||||
throw new ValidationError('Unsupported report kind');
|
||||
}
|
||||
|
||||
const buffer = await renderReport({
|
||||
portId: ctx.portId,
|
||||
request: body,
|
||||
data,
|
||||
});
|
||||
|
||||
// Audit BEFORE returning so a failed write doesn't silently
|
||||
// leak an export. The `void` is intentional — audit is best-
|
||||
// effort relative to the export; the PDF download succeeding
|
||||
// is the contract.
|
||||
void createAuditLog({
|
||||
portId: ctx.portId,
|
||||
userId: ctx.userId,
|
||||
action: 'create',
|
||||
entityType: 'report',
|
||||
entityId: 'pdf-export',
|
||||
metadata: {
|
||||
kind: body.config.kind,
|
||||
title: body.title,
|
||||
...(body.config.kind === 'dashboard'
|
||||
? {
|
||||
widgetIds: body.config.widgetIds,
|
||||
dateFrom: body.config.dateFrom,
|
||||
dateTo: body.config.dateTo,
|
||||
}
|
||||
: {}),
|
||||
sizeBytes: buffer.byteLength,
|
||||
},
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
const filename = sanitizeFilename(`${body.title}.pdf`);
|
||||
// Stream the buffer back inline. The Content-Disposition uses
|
||||
// `attachment; filename*` (RFC 5987) for unicode titles —
|
||||
// Port names with diacritics need this or browsers fall back
|
||||
// to a mojibake'd ASCII filename. `filename=` carries the
|
||||
// ASCII fallback for older HTTP stacks.
|
||||
// Node Buffer is not directly assignable to BodyInit in the
|
||||
// Next.js / Web Fetch typings; passing the underlying
|
||||
// ArrayBuffer view keeps the bytes intact without an extra copy.
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Length': String(buffer.byteLength),
|
||||
'Content-Disposition': `attachment; filename="${filename.ascii}"; filename*=UTF-8''${filename.encoded}`,
|
||||
// No-cache: each export reflects the moment-in-time data,
|
||||
// so caching the response would leak stale numbers to
|
||||
// subsequent calls.
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, portId: ctx.portId }, 'PDF report generation failed');
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Build a safe attachment filename. The ASCII variant strips
|
||||
* non-ASCII characters for the legacy `filename=` field; the
|
||||
* percent-encoded variant carries the full unicode title for the
|
||||
* RFC 5987 `filename*=` field. Replace `/` and `\\` so the file
|
||||
* doesn't accidentally look like a path component.
|
||||
*/
|
||||
function sanitizeFilename(name: string): { ascii: string; encoded: string } {
|
||||
const noPaths = name.replace(/[\\/]/g, '_');
|
||||
const ascii = noPaths.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, "'");
|
||||
const encoded = encodeURIComponent(noPaths);
|
||||
return { ascii, encoded };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ExportDashboardPdfButton } from '@/components/reports/export-dashboard-pdf-button';
|
||||
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
||||
import { DateRangePicker } from './date-range-picker';
|
||||
import { TimezoneDriftBanner } from './timezone-drift-banner';
|
||||
@@ -165,6 +166,7 @@ export function DashboardShell({
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<DateRangePicker value={range} onChange={setRange} />
|
||||
<ExportDashboardPdfButton />
|
||||
<CustomizeWidgetsMenu />
|
||||
</div>
|
||||
}
|
||||
|
||||
159
src/components/reports/export-dashboard-pdf-button.tsx
Normal file
159
src/components/reports/export-dashboard-pdf-button.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { FileDown, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
PDF_DASHBOARD_WIDGETS,
|
||||
type PdfDashboardWidgetId,
|
||||
} from '@/lib/services/dashboard-report-data.service';
|
||||
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||
|
||||
/**
|
||||
* Dashboard "Export as PDF" affordance. Per-export dialog lets reps
|
||||
* pick which sections to include + set a custom title. Saved-template
|
||||
* support lands in Phase C; for now, the dialog defaults all widgets
|
||||
* checked + the current date in the title.
|
||||
*
|
||||
* Permission-gated client-side on `reports.export`; the server route
|
||||
* re-checks via withPermission so a tampered client can't bypass.
|
||||
*/
|
||||
export function ExportDashboardPdfButton() {
|
||||
const { can } = usePermissions();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState(
|
||||
`Dashboard report — ${new Date().toLocaleDateString('en-GB')}`,
|
||||
);
|
||||
const [selected, setSelected] = useState<PdfDashboardWidgetId[]>(
|
||||
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!can('reports', 'export')) return null;
|
||||
|
||||
function toggle(id: PdfDashboardWidgetId) {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (selected.length === 0) {
|
||||
toast.error('Pick at least one section to include.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
// FormData isn't required (this is a JSON body), but we DO need
|
||||
// to forward the X-Port-Id header so the server-side resolver
|
||||
// knows which port's data to use. apiFetch is JSON-only and
|
||||
// doesn't expose the raw response body; we need the buffer here
|
||||
// so do a raw fetch with the same header convention.
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (typeof window !== 'undefined') {
|
||||
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api') {
|
||||
const portId = await resolvePortIdFromSlug(slug);
|
||||
if (portId) headers.set('X-Port-Id', portId);
|
||||
}
|
||||
}
|
||||
const res = await fetch('/api/v1/reports/generate', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: title.trim() || 'Dashboard report',
|
||||
config: {
|
||||
kind: 'dashboard',
|
||||
widgetIds: selected,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Export failed (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const filename = title.trim().replace(/[\\/]/g, '_') + '.pdf';
|
||||
triggerBlobDownload(blob, filename);
|
||||
toast.success('Report downloaded');
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Export failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
Export PDF
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export dashboard as PDF</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick which sections to include and set a title. The PDF inherits the active
|
||||
port's logo and primary color.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="export-title">Title</Label>
|
||||
<Input id="export-title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Sections</Label>
|
||||
<div className="space-y-1.5 rounded-md border p-2">
|
||||
{PDF_DASHBOARD_WIDGETS.map((w) => (
|
||||
<label
|
||||
key={w.id}
|
||||
className="flex items-start gap-2 cursor-pointer rounded-sm p-1 hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.includes(w.id)}
|
||||
onCheckedChange={() => toggle(w.id)}
|
||||
aria-label={w.label}
|
||||
/>
|
||||
<div className="text-sm leading-tight">
|
||||
<div className="font-medium">{w.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{w.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={loading || selected.length === 0}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
|
||||
)}
|
||||
Download PDF
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/lib/pdf/reports/branded-document.tsx
Normal file
77
src/lib/pdf/reports/branded-document.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
src/lib/pdf/reports/dashboard-report.tsx
Normal file
250
src/lib/pdf/reports/dashboard-report.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/lib/pdf/reports/render-report.ts
Normal file
120
src/lib/pdf/reports/render-report.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
188
src/lib/pdf/reports/styles.ts
Normal file
188
src/lib/pdf/reports/styles.ts
Normal 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);
|
||||
}
|
||||
65
src/lib/pdf/reports/types.ts
Normal file
65
src/lib/pdf/reports/types.ts
Normal 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;
|
||||
}
|
||||
116
src/lib/services/dashboard-report-data.service.ts
Normal file
116
src/lib/services/dashboard-report-data.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Server-side data resolver for the dashboard PDF report.
|
||||
*
|
||||
* Each section is gated on its widget id being present in
|
||||
* `config.widgetIds`, so a report that only includes the pipeline
|
||||
* funnel runs ONE query instead of the full dashboard panel. Keeps
|
||||
* cold-call latency low even when the actual port has hundreds of
|
||||
* berths.
|
||||
*
|
||||
* Lives in its own file (not inside dashboard.service.ts) so the
|
||||
* report-builder concerns — what widget ids map to what fetcher,
|
||||
* which fields the PDF shape requires — stay scoped to the
|
||||
* report-side surface, not the dashboard UI.
|
||||
*/
|
||||
import {
|
||||
getKpis,
|
||||
getPipelineCounts,
|
||||
getBerthStatusDistribution,
|
||||
getHotDeals,
|
||||
getSourceConversion,
|
||||
} from './dashboard.service';
|
||||
import type { DashboardReportData } from '@/lib/pdf/reports/dashboard-report';
|
||||
|
||||
/**
|
||||
* Maps widget ids the dashboard PDF understands. The id space is
|
||||
* intentionally a subset of the on-screen `DASHBOARD_WIDGETS`
|
||||
* registry — only widgets that have a sensible printable form
|
||||
* appear here. The dialog's widget picker filters its option list
|
||||
* by this set.
|
||||
*/
|
||||
export const PDF_DASHBOARD_WIDGET_IDS = [
|
||||
'kpi_overview',
|
||||
'pipeline_funnel',
|
||||
'berth_status',
|
||||
'source_conversion',
|
||||
'hot_deals',
|
||||
] as const;
|
||||
|
||||
export type PdfDashboardWidgetId = (typeof PDF_DASHBOARD_WIDGET_IDS)[number];
|
||||
|
||||
export interface PdfDashboardWidgetOption {
|
||||
id: PdfDashboardWidgetId;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public widget list (label + description) for the export dialog.
|
||||
* Mirrored from the on-screen widget-registry but with PDF-friendly
|
||||
* copy: a "Berth heat" chart is "Berth demand ranking" in print.
|
||||
*/
|
||||
export const PDF_DASHBOARD_WIDGETS: readonly PdfDashboardWidgetOption[] = [
|
||||
{
|
||||
id: 'kpi_overview',
|
||||
label: 'Key metrics',
|
||||
description: 'Total clients, active interests, pipeline value, occupancy %.',
|
||||
},
|
||||
{
|
||||
id: 'pipeline_funnel',
|
||||
label: 'Pipeline funnel',
|
||||
description: 'Active interests grouped by pipeline stage.',
|
||||
},
|
||||
{
|
||||
id: 'berth_status',
|
||||
label: 'Berth status distribution',
|
||||
description: 'Available / under offer / reserved / sold counts.',
|
||||
},
|
||||
{
|
||||
id: 'source_conversion',
|
||||
label: 'Source conversion',
|
||||
description: 'Inquiries → Clients → Interests → Won, by lead source.',
|
||||
},
|
||||
{
|
||||
id: 'hot_deals',
|
||||
label: 'Hot deals',
|
||||
description: 'Top 5 active interests by deal-health score.',
|
||||
},
|
||||
];
|
||||
|
||||
export async function resolveDashboardReportData(
|
||||
portId: string,
|
||||
widgetIds: string[],
|
||||
): Promise<DashboardReportData> {
|
||||
const want = new Set(widgetIds);
|
||||
// Each fetcher returns its own shape; default to undefined to
|
||||
// signal "don't render this section" downstream.
|
||||
const data: DashboardReportData = {};
|
||||
|
||||
if (want.has('kpi_overview')) {
|
||||
data.kpis = await getKpis(portId);
|
||||
}
|
||||
if (want.has('pipeline_funnel')) {
|
||||
data.pipelineCounts = await getPipelineCounts(portId);
|
||||
}
|
||||
if (want.has('berth_status')) {
|
||||
const dist = await getBerthStatusDistribution(portId);
|
||||
// `dist` shape from the service is already the totals dict; pass
|
||||
// straight through. If the service changes shape, the type-check
|
||||
// here will trip.
|
||||
data.berthStatus = dist;
|
||||
}
|
||||
if (want.has('source_conversion')) {
|
||||
data.sourceConversion = await getSourceConversion(portId);
|
||||
}
|
||||
if (want.has('hot_deals')) {
|
||||
const deals = await getHotDeals(portId, 5);
|
||||
data.hotDeals = deals.map((d) => ({
|
||||
id: d.id,
|
||||
clientName: d.clientName,
|
||||
mooringNumber: d.mooringNumber,
|
||||
stage: d.stage,
|
||||
lastContact: d.lastContact,
|
||||
}));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
126
tests/unit/pdf-report-renderer.test.ts
Normal file
126
tests/unit/pdf-report-renderer.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { DashboardReport } from '@/lib/pdf/reports/dashboard-report';
|
||||
import type { ReportBranding } from '@/lib/pdf/reports/types';
|
||||
|
||||
const branding: ReportBranding = {
|
||||
logoUrl: null,
|
||||
primaryColor: '#0F4C81',
|
||||
portName: 'Port Nimara',
|
||||
};
|
||||
|
||||
describe('PDF report renderer', () => {
|
||||
it('renders a dashboard report with all sections to a non-empty PDF buffer', async () => {
|
||||
const element = createElement(DashboardReport, {
|
||||
title: 'Test report',
|
||||
subtitle: 'Unit-test fixture',
|
||||
branding,
|
||||
generatedAt: '2026-05-21T12:00:00.000Z',
|
||||
config: {
|
||||
kind: 'dashboard',
|
||||
widgetIds: [
|
||||
'kpi_overview',
|
||||
'pipeline_funnel',
|
||||
'berth_status',
|
||||
'source_conversion',
|
||||
'hot_deals',
|
||||
],
|
||||
},
|
||||
data: {
|
||||
kpis: {
|
||||
totalClients: 142,
|
||||
activeInterests: 27,
|
||||
pipelineValue: 1250000,
|
||||
pipelineValueCurrency: 'USD',
|
||||
occupancyRate: 64.3,
|
||||
},
|
||||
pipelineCounts: [
|
||||
{ stage: 'enquiry', count: 12 },
|
||||
{ stage: 'qualified', count: 8 },
|
||||
{ stage: 'eoi', count: 4 },
|
||||
{ stage: 'reservation', count: 2 },
|
||||
{ stage: 'deposit_paid', count: 1 },
|
||||
],
|
||||
berthStatus: {
|
||||
total: 120,
|
||||
available: 80,
|
||||
underOffer: 10,
|
||||
maintenance: 5,
|
||||
sold: 25,
|
||||
},
|
||||
sourceConversion: [
|
||||
{ source: 'website', total: 60, won: 12, lost: 30, conversionRate: 0.2 },
|
||||
{ source: 'referral', total: 25, won: 8, lost: 10, conversionRate: 0.32 },
|
||||
],
|
||||
hotDeals: [
|
||||
{
|
||||
id: 'i1',
|
||||
clientName: 'Acme Corp',
|
||||
mooringNumber: 'A3',
|
||||
stage: 'reservation',
|
||||
lastContact: '2026-05-18T09:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const buf = await renderToBuffer(element as any);
|
||||
expect(buf.byteLength).toBeGreaterThan(2_000);
|
||||
// PDF files start with `%PDF-` magic bytes — sanity-check that
|
||||
// the renderer produced an actual PDF, not an error blob or
|
||||
// empty buffer.
|
||||
const head = buf.subarray(0, 5).toString('utf-8');
|
||||
expect(head).toBe('%PDF-');
|
||||
}, 30_000);
|
||||
|
||||
it('skips sections whose widget id is absent from widgetIds', async () => {
|
||||
const element = createElement(DashboardReport, {
|
||||
title: 'Sparse report',
|
||||
branding,
|
||||
generatedAt: '2026-05-21T12:00:00.000Z',
|
||||
config: {
|
||||
kind: 'dashboard',
|
||||
widgetIds: ['kpi_overview'],
|
||||
},
|
||||
data: {
|
||||
kpis: {
|
||||
totalClients: 5,
|
||||
activeInterests: 1,
|
||||
pipelineValue: 0,
|
||||
pipelineValueCurrency: 'USD',
|
||||
occupancyRate: 0,
|
||||
},
|
||||
// Provide pipelineCounts even though widgetIds didn't ask for
|
||||
// it — the renderer should still skip the section since it's
|
||||
// gated on widgetIds, not data presence.
|
||||
pipelineCounts: [{ stage: 'enquiry', count: 1 }],
|
||||
},
|
||||
});
|
||||
|
||||
const buf = await renderToBuffer(element as any);
|
||||
expect(buf.byteLength).toBeGreaterThan(1_000);
|
||||
}, 30_000);
|
||||
|
||||
it('falls back to a stable layout when no logo URL is supplied', async () => {
|
||||
const element = createElement(DashboardReport, {
|
||||
title: 'Logoless',
|
||||
branding: { ...branding, logoUrl: null },
|
||||
generatedAt: '2026-05-21T12:00:00.000Z',
|
||||
config: { kind: 'dashboard', widgetIds: ['kpi_overview'] },
|
||||
data: {
|
||||
kpis: {
|
||||
totalClients: 0,
|
||||
activeInterests: 0,
|
||||
pipelineValue: 0,
|
||||
pipelineValueCurrency: 'USD',
|
||||
occupancyRate: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const buf = await renderToBuffer(element as any);
|
||||
expect(buf.byteLength).toBeGreaterThan(1_000);
|
||||
}, 30_000);
|
||||
});
|
||||
Reference in New Issue
Block a user