Files
pn-new-crm/src/lib/pdf/templates/client-summary.tsx

159 lines
4.6 KiB
TypeScript
Raw Normal View History

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