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>
159 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|