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>
220 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|