Files
pn-new-crm/src/lib/pdf/templates/client-summary.tsx
Matt 0e4a2d7396 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>
2026-05-12 20:59:05 +02:00

159 lines
4.6 KiB
TypeScript

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