feat(record-export): migrate client/berth/interest summaries to react-pdf
Phase 1 / commits 7-9 of 14 — bundled because all three record exports
share the same conversion pattern and call sites.
Templates:
client-summary.tsx header + KV grid for client, contacts table
with primary badge, yacht table, interests
table with stage/category, recent activity
table
berth-spec.tsx header + status badge, overview KV grid,
dimensions KV grid (with min markers), pricing
& tenure KV grid, infrastructure KV grid,
waiting list table with priority badges,
maintenance log table
interest-summary.tsx header + stage badge, status KV grid, client
KV, optional yacht/berth sections, milestones
KV grid, recent timeline table
record-export.tsx (renamed .ts -> .tsx for JSX):
- swap generatePdf(...) calls for renderPdf(<…Pdf … />) calls
- inject port logo via resolvePortLogo()
- shape data into typed template props (Drizzle returns are passed
through deliberately so the template controls its own type surface)
Drops two latent bugs the old templates carried:
- client.nationality was read as a property but the schema field is
nationalityIso — old PDFs always showed "—" for nationality
- interest.notes was read but the interests table doesn't have a
notes column (interest_berths does) — old PDFs always showed "No
notes"
Both fields are now sourced correctly (or omitted) in the new templates.
Old pdfme files deleted (3 templates). API routes that import
exportClientPdf/exportBerthPdf/exportInterestPdf unchanged.
Tests:
tests/unit/record-export-templates.test.tsx (4 tests): each template
renders to valid PDF bytes with representative data, plus a minimal-
input path for the berth spec.
1317/1317 vitest green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
158
src/lib/pdf/templates/client-summary.tsx
Normal file
158
src/lib/pdf/templates/client-summary.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Badge, DataTable, DocumentShell, KeyValueGrid, Section } from '@/lib/pdf/brand-kit';
|
||||
|
||||
export interface ClientContact {
|
||||
channel: string;
|
||||
value: string;
|
||||
label?: string | null;
|
||||
isPrimary: boolean | null;
|
||||
}
|
||||
|
||||
export interface YachtRow {
|
||||
name: string;
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
draftFt: string | null;
|
||||
lengthM: string | null;
|
||||
widthM: string | null;
|
||||
draftM: string | null;
|
||||
}
|
||||
|
||||
export interface InterestRow {
|
||||
id: string;
|
||||
pipelineStage: string | null;
|
||||
leadCategory: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
createdAt: Date | string;
|
||||
}
|
||||
|
||||
export interface ActivityRow {
|
||||
action: string;
|
||||
entityType: string;
|
||||
fieldChanged?: string | null;
|
||||
createdAt: Date | string;
|
||||
}
|
||||
|
||||
export interface ClientSummaryPdfProps {
|
||||
portName: string;
|
||||
logoBuffer: Buffer | null;
|
||||
client: {
|
||||
fullName: string | null;
|
||||
nationality?: string | null;
|
||||
source?: string | null;
|
||||
createdAt: Date | string;
|
||||
};
|
||||
contacts: ClientContact[];
|
||||
yachts: YachtRow[];
|
||||
interests: InterestRow[];
|
||||
activity: ActivityRow[];
|
||||
}
|
||||
|
||||
function fmtDate(d: Date | string | null | undefined): string {
|
||||
if (!d) return '—';
|
||||
return new Date(d).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function yachtDims(y: YachtRow): string {
|
||||
const parts = [
|
||||
y.lengthFt ? `${y.lengthFt}ft` : y.lengthM ? `${y.lengthM}m` : null,
|
||||
y.widthFt ? `${y.widthFt}ft beam` : null,
|
||||
y.draftFt ? `${y.draftFt}ft draft` : null,
|
||||
].filter(Boolean);
|
||||
return parts.length ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
export function ClientSummaryPdf({
|
||||
portName,
|
||||
logoBuffer,
|
||||
client,
|
||||
contacts,
|
||||
yachts,
|
||||
interests,
|
||||
activity,
|
||||
}: ClientSummaryPdfProps) {
|
||||
const docMeta = `${client.fullName ?? 'Client'} · ${contacts.length} contact${
|
||||
contacts.length === 1 ? '' : 's'
|
||||
} · ${yachts.length} yacht${yachts.length === 1 ? '' : 's'} · ${interests.length} interest${
|
||||
interests.length === 1 ? '' : 's'
|
||||
}`;
|
||||
return (
|
||||
<DocumentShell
|
||||
portName={portName}
|
||||
docTitle="Client Summary"
|
||||
docMeta={docMeta}
|
||||
logoBuffer={logoBuffer}
|
||||
>
|
||||
<Section title="Client">
|
||||
<KeyValueGrid
|
||||
rows={[
|
||||
{ label: 'Full name', value: client.fullName ?? '—' },
|
||||
{ label: 'Nationality', value: client.nationality ?? '—' },
|
||||
{ label: 'Source', value: client.source ?? '—' },
|
||||
{ label: 'Added', value: fmtDate(client.createdAt) },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Contacts"
|
||||
subtitle={`${contacts.length} channel${contacts.length === 1 ? '' : 's'} on file.`}
|
||||
>
|
||||
<DataTable<ClientContact>
|
||||
columns={[
|
||||
{ header: 'Channel', flex: 1, render: (c) => c.channel },
|
||||
{
|
||||
header: 'Value',
|
||||
flex: 3,
|
||||
render: (c) => `${c.value}${c.label ? ` (${c.label})` : ''}`,
|
||||
},
|
||||
{
|
||||
header: 'Primary',
|
||||
flex: 1,
|
||||
align: 'center',
|
||||
render: (c) => (c.isPrimary ? <Badge text="Yes" tone="success" /> : '—'),
|
||||
},
|
||||
]}
|
||||
rows={contacts}
|
||||
emptyMessage="No contacts on file."
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Linked yachts">
|
||||
<DataTable<YachtRow>
|
||||
columns={[
|
||||
{ header: 'Name', flex: 3, render: (y) => y.name },
|
||||
{ header: 'Dimensions', flex: 4, render: (y) => yachtDims(y) },
|
||||
]}
|
||||
rows={yachts}
|
||||
emptyMessage="No yachts linked to this client."
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Pipeline interests">
|
||||
<DataTable<InterestRow>
|
||||
columns={[
|
||||
{ header: 'Stage', flex: 2, render: (i) => i.pipelineStage ?? 'open' },
|
||||
{ header: 'Berth', flex: 1, render: (i) => i.berthMooringNumber ?? '—' },
|
||||
{ header: 'Category', flex: 2, render: (i) => i.leadCategory ?? '—' },
|
||||
{ header: 'Created', flex: 1.5, render: (i) => fmtDate(i.createdAt) },
|
||||
]}
|
||||
rows={interests}
|
||||
emptyMessage="No pipeline interests on file."
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Recent activity" subtitle={`Last ${activity.length} events.`}>
|
||||
<DataTable<ActivityRow>
|
||||
columns={[
|
||||
{ header: 'When', flex: 1.5, render: (a) => fmtDate(a.createdAt) },
|
||||
{ header: 'Action', flex: 1.5, render: (a) => a.action },
|
||||
{ header: 'Entity', flex: 1.5, render: (a) => a.entityType },
|
||||
{ header: 'Field', flex: 1.5, render: (a) => a.fieldChanged ?? '—' },
|
||||
]}
|
||||
rows={activity}
|
||||
emptyMessage="No recent activity."
|
||||
/>
|
||||
</Section>
|
||||
</DocumentShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user