Files
pn-new-crm/src/lib/pdf/templates/berth-spec.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

220 lines
6.8 KiB
TypeScript

import {
Badge,
DataTable,
DocumentShell,
KeyValueGrid,
Section,
type BadgeTone,
} from '@/lib/pdf/brand-kit';
interface BerthRow {
mooringNumber: string | null;
area?: string | null;
status?: string | null;
nominalBoatSize?: string | null;
bowFacing?: string | null;
lengthFt?: string | null;
lengthM?: string | null;
widthFt?: string | null;
widthM?: string | null;
widthIsMinimum?: boolean | null;
draftFt?: string | null;
draftM?: string | null;
waterDepth?: string | null;
waterDepthM?: string | null;
waterDepthIsMinimum?: boolean | null;
price?: string | number | null;
priceCurrency?: string | null;
tenureType?: string | null;
tenureYears?: string | number | null;
tenureStartDate?: string | null;
tenureEndDate?: string | null;
mooringType?: string | null;
powerCapacity?: string | number | null;
voltage?: string | number | null;
cleatType?: string | null;
cleatCapacity?: string | null;
bollardType?: string | null;
bollardCapacity?: string | null;
sidePontoon?: string | null;
access?: string | null;
}
export interface BerthSpecWaitingRow {
position: number | null;
priority: string | null;
clientName: string;
notes?: string | null;
}
export interface BerthSpecMaintenanceRow {
performedDate: string | null;
category: string | null;
description: string | null;
cost: string | number | null;
costCurrency: string | null;
}
export interface BerthSpecPdfProps {
portName: string;
logoBuffer: Buffer | null;
berth: BerthRow;
waitingList: BerthSpecWaitingRow[];
maintenance: BerthSpecMaintenanceRow[];
}
const STATUS_TONE: Record<string, BadgeTone> = {
available: 'success',
under_offer: 'warning',
sold: 'accent',
reserved: 'neutral',
maintenance: 'danger',
};
function dim(ft?: string | null, m?: string | null, minimum?: boolean | null): string {
if (!ft && !m) return '—';
const parts = [ft ? `${ft}ft` : null, m ? `${m}m` : null].filter(Boolean);
return `${parts.join(' / ')}${minimum ? ' (min)' : ''}`;
}
function fmtPrice(
v: string | number | null | undefined,
currency: string | null | undefined,
): string {
if (v === null || v === undefined) return 'TBD';
const n = Number(v);
if (!Number.isFinite(n)) return String(v);
return `${currency ?? 'USD'} ${n.toLocaleString()}`;
}
export function BerthSpecPdf({
portName,
logoBuffer,
berth,
waitingList,
maintenance,
}: BerthSpecPdfProps) {
const status = (berth.status ?? 'available').toLowerCase();
const docMeta = `Mooring ${berth.mooringNumber ?? '—'}${berth.area ? ` · ${berth.area}` : ''}`;
return (
<DocumentShell
portName={portName}
docTitle={`Berth Spec — ${berth.mooringNumber ?? '—'}`}
docMeta={docMeta}
logoBuffer={logoBuffer}
>
<Section title="Overview">
<KeyValueGrid
rows={[
{ label: 'Mooring', value: berth.mooringNumber ?? '—' },
{ label: 'Area', value: berth.area ?? '—' },
{ label: 'Status', value: status.replace('_', ' ') },
{ label: 'Nominal boat size', value: berth.nominalBoatSize ?? '—' },
{ label: 'Bow facing', value: berth.bowFacing ?? '—' },
{ label: 'Side pontoon', value: berth.sidePontoon ?? '—' },
]}
/>
<Badge
text={status.replace('_', ' ').toUpperCase()}
tone={STATUS_TONE[status] ?? 'neutral'}
/>
</Section>
<Section title="Dimensions">
<KeyValueGrid
rows={[
{ label: 'Length', value: dim(berth.lengthFt, berth.lengthM) },
{
label: 'Beam',
value: dim(berth.widthFt, berth.widthM, berth.widthIsMinimum),
},
{ label: 'Draft', value: dim(berth.draftFt, berth.draftM) },
{
label: 'Water depth',
value: dim(berth.waterDepth, berth.waterDepthM, berth.waterDepthIsMinimum),
},
]}
/>
</Section>
<Section title="Pricing & tenure">
<KeyValueGrid
rows={[
{ label: 'Price', value: fmtPrice(berth.price, berth.priceCurrency) },
{ label: 'Tenure type', value: berth.tenureType ?? 'permanent' },
{ label: 'Tenure years', value: berth.tenureYears ?? '—' },
{ label: 'Tenure start', value: berth.tenureStartDate ?? '—' },
{ label: 'Tenure end', value: berth.tenureEndDate ?? '—' },
]}
/>
</Section>
<Section title="Infrastructure">
<KeyValueGrid
rows={[
{ label: 'Mooring type', value: berth.mooringType ?? '—' },
{
label: 'Power',
value: berth.powerCapacity
? `${berth.powerCapacity}${berth.voltage ? ` / ${berth.voltage}V` : ''}`
: '—',
},
{
label: 'Cleat',
value: berth.cleatType
? `${berth.cleatType}${berth.cleatCapacity ? ` (${berth.cleatCapacity})` : ''}`
: '—',
},
{
label: 'Bollard',
value: berth.bollardType
? `${berth.bollardType}${berth.bollardCapacity ? ` (${berth.bollardCapacity})` : ''}`
: '—',
},
{ label: 'Access', value: berth.access ?? '—' },
]}
/>
</Section>
<Section
title={`Waiting list (${waitingList.length})`}
subtitle="Clients queued for this berth."
>
<DataTable<BerthSpecWaitingRow>
columns={[
{ header: '#', flex: 0.5, render: (w) => String(w.position ?? '—') },
{ header: 'Client', flex: 3, render: (w) => w.clientName },
{
header: 'Priority',
flex: 1,
render: (w) =>
w.priority === 'high' ? <Badge text="High" tone="warning" /> : (w.priority ?? '—'),
},
{ header: 'Notes', flex: 3, render: (w) => w.notes ?? '—' },
]}
rows={waitingList}
emptyMessage="No clients on waiting list."
/>
</Section>
<Section title={`Maintenance log (${maintenance.length})`}>
<DataTable<BerthSpecMaintenanceRow>
columns={[
{ header: 'Date', flex: 1.5, render: (m) => m.performedDate ?? '—' },
{ header: 'Category', flex: 1.5, render: (m) => m.category ?? '—' },
{ header: 'Description', flex: 4, render: (m) => m.description ?? '—' },
{
header: 'Cost',
flex: 1.5,
align: 'right',
render: (m) => fmtPrice(m.cost, m.costCurrency),
},
]}
rows={maintenance}
emptyMessage="No maintenance records."
/>
</Section>
</DocumentShell>
);
}