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>
This commit is contained in:
2026-05-12 20:59:05 +02:00
parent 90fbb66709
commit 0e4a2d7396
8 changed files with 845 additions and 547 deletions

View File

@@ -1,189 +0,0 @@
import type { Template } from '@pdfme/common';
export const berthSpecTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
schemas: [
[
{
name: 'portName',
type: 'text',
position: { x: 20, y: 15 },
width: 100,
height: 10,
fontSize: 16,
},
{
name: 'title',
type: 'text',
position: { x: 20, y: 30 },
width: 170,
height: 8,
fontSize: 14,
},
{
name: 'berthInfo',
type: 'text',
position: { x: 20, y: 45 },
width: 80,
height: 25,
fontSize: 9,
},
{
name: 'dimensions',
type: 'text',
position: { x: 110, y: 45 },
width: 80,
height: 25,
fontSize: 9,
},
{
name: 'pricing',
type: 'text',
position: { x: 20, y: 75 },
width: 80,
height: 20,
fontSize: 9,
},
{
name: 'tenure',
type: 'text',
position: { x: 110, y: 75 },
width: 80,
height: 20,
fontSize: 9,
},
{
name: 'infrastructure',
type: 'text',
position: { x: 20, y: 100 },
width: 170,
height: 25,
fontSize: 9,
},
{
name: 'waitingList',
type: 'text',
position: { x: 20, y: 130 },
width: 170,
height: 50,
fontSize: 8,
},
{
name: 'maintenanceLog',
type: 'text',
position: { x: 20, y: 185 },
width: 170,
height: 75,
fontSize: 8,
},
{
name: 'generatedAt',
type: 'text',
position: { x: 20, y: 275 },
width: 170,
height: 6,
fontSize: 7,
},
],
],
};
export function buildBerthSpecInputs(
berth: Record<string, unknown>,
waitingList: Record<string, unknown>[],
maintenance: Record<string, unknown>[],
linkedInterests: Record<string, unknown>[],
port: Record<string, unknown>,
): Record<string, string> {
const berthInfo = [
`Mooring: ${berth.mooringNumber}`,
berth.area ? `Area: ${berth.area}` : null,
`Status: ${berth.status ?? 'available'}`,
berth.nominalBoatSize ? `Nominal boat size: ${berth.nominalBoatSize}` : null,
berth.bowFacing ? `Bow facing: ${berth.bowFacing}` : null,
]
.filter(Boolean)
.join('\n');
const dimensions =
[
berth.lengthFt
? `Length: ${berth.lengthFt}ft${berth.lengthM ? ` / ${berth.lengthM}m` : ''}`
: null,
berth.widthFt
? `Beam: ${berth.widthFt}ft${berth.widthM ? ` / ${berth.widthM}m` : ''}${berth.widthIsMinimum ? ' (min)' : ''}`
: null,
berth.draftFt
? `Draft: ${berth.draftFt}ft${berth.draftM ? ` / ${berth.draftM}m` : ''}`
: null,
berth.waterDepth
? `Water depth: ${berth.waterDepth}ft${berth.waterDepthM ? ` / ${berth.waterDepthM}m` : ''}${berth.waterDepthIsMinimum ? ' (min)' : ''}`
: null,
]
.filter(Boolean)
.join('\n') || 'Dimensions not specified';
const pricing = berth.price
? `Price: ${berth.priceCurrency ?? 'USD'} ${Number(berth.price).toLocaleString()}`
: 'Price: TBD';
const tenure = [
`Tenure type: ${berth.tenureType ?? 'permanent'}`,
berth.tenureYears ? `Tenure years: ${berth.tenureYears}` : null,
berth.tenureStartDate ? `Start: ${berth.tenureStartDate}` : null,
berth.tenureEndDate ? `End: ${berth.tenureEndDate}` : null,
]
.filter(Boolean)
.join('\n');
const infrastructure =
[
berth.mooringType ? `Mooring type: ${berth.mooringType}` : null,
berth.powerCapacity
? `Power: ${berth.powerCapacity}${berth.voltage ? ` / ${berth.voltage}V` : ''}`
: null,
berth.cleatType
? `Cleat: ${berth.cleatType}${berth.cleatCapacity ? ` (${berth.cleatCapacity})` : ''}`
: null,
berth.bollardType
? `Bollard: ${berth.bollardType}${berth.bollardCapacity ? ` (${berth.bollardCapacity})` : ''}`
: null,
berth.sidePontoon ? `Side pontoon: ${berth.sidePontoon}` : null,
berth.access ? `Access: ${berth.access}` : null,
]
.filter(Boolean)
.join(' | ') || 'Infrastructure details not specified';
const waitingListText =
waitingList.length > 0
? waitingList
.map(
(w) =>
`${w.position}. ${w.clientName ?? 'Unknown'}${w.priority === 'high' ? ' [HIGH]' : ''}${w.notes ? ` - ${w.notes}` : ''}`,
)
.join('\n')
: 'No clients on waiting list';
const maintenanceText =
maintenance.length > 0
? maintenance
.map(
(m) =>
`${m.performedDate} [${m.category}] ${m.description}${m.cost ? ` Cost: ${m.costCurrency ?? 'USD'} ${Number(m.cost).toLocaleString()}` : ''}`,
)
.join('\n')
: 'No maintenance records';
return {
portName: (port?.name as string) ?? 'Port Nimara',
title: `Berth Specification - Mooring ${berth.mooringNumber}`,
berthInfo,
dimensions,
pricing,
tenure,
infrastructure,
waitingList: `Waiting List (${waitingList.length}):\n${waitingListText}`,
maintenanceLog: `Maintenance Log (last ${maintenance.length}):\n${maintenanceText}`,
generatedAt: `Generated: ${new Date().toLocaleString('en-GB')}`,
};
}

View File

@@ -0,0 +1,219 @@
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>
);
}

View File

@@ -1,158 +0,0 @@
import type { Template } from '@pdfme/common';
export const clientSummaryTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
schemas: [
[
{
name: 'portName',
type: 'text',
position: { x: 20, y: 15 },
width: 100,
height: 10,
fontSize: 16,
},
{
name: 'title',
type: 'text',
position: { x: 20, y: 30 },
width: 170,
height: 8,
fontSize: 14,
},
{
name: 'clientInfo',
type: 'text',
position: { x: 20, y: 45 },
width: 80,
height: 40,
fontSize: 9,
},
{
name: 'contacts',
type: 'text',
position: { x: 110, y: 45 },
width: 80,
height: 40,
fontSize: 9,
},
{
name: 'yachts',
type: 'text',
position: { x: 20, y: 90 },
width: 170,
height: 25,
fontSize: 9,
},
{
name: 'interests',
type: 'text',
position: { x: 20, y: 120 },
width: 170,
height: 80,
fontSize: 8,
},
{
name: 'recentActivity',
type: 'text',
position: { x: 20, y: 205 },
width: 170,
height: 60,
fontSize: 8,
},
{
name: 'generatedAt',
type: 'text',
position: { x: 20, y: 275 },
width: 170,
height: 6,
fontSize: 7,
},
],
],
};
export interface YachtSummary {
name: string;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
draftM: string | null;
}
export function buildClientSummaryInputs(
client: Record<string, unknown>,
contacts: Record<string, unknown>[],
yachtList: YachtSummary[],
interestList: Record<string, unknown>[],
activity: Record<string, unknown>[],
port: Record<string, unknown>,
): Record<string, string> {
const clientInfo = [
`Name: ${client.fullName ?? 'N/A'}`,
client.nationality ? `Nationality: ${client.nationality}` : null,
client.source ? `Source: ${client.source}` : null,
`Added: ${new Date(client.createdAt as string | Date).toLocaleDateString('en-GB')}`,
]
.filter(Boolean)
.join('\n');
const contactsText =
contacts.length > 0
? contacts
.map(
(c) =>
`${(c.channel as string).charAt(0).toUpperCase() + (c.channel as string).slice(1)}${c.isPrimary ? ' (primary)' : ''}: ${c.value}${c.label ? ` [${c.label}]` : ''}`,
)
.join('\n')
: 'No contacts on file';
const yachtsText =
yachtList.length > 0
? `Owned/Linked Yachts:\n${yachtList
.map((y) => {
const dims = [
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)
.join(' · ');
return `${y.name}${dims ? ` (${dims})` : ''}`;
})
.join('\n')}`
: 'No yachts linked to this client';
const interestsText =
interestList.length > 0
? interestList
.map(
(i) =>
`${i.pipelineStage ?? 'open'}${i.berthMooringNumber ? ` - Berth ${i.berthMooringNumber}` : ''}${i.leadCategory ? ` [${i.leadCategory}]` : ''} (${new Date(i.createdAt as string | Date).toLocaleDateString('en-GB')})`,
)
.join('\n')
: 'No pipeline interests on file';
const activityText =
activity.length > 0
? activity
.map(
(a) =>
`${new Date(a.createdAt as string | Date).toLocaleDateString('en-GB')} ${a.action} ${a.entityType}${a.fieldChanged ? ` (${a.fieldChanged})` : ''}`,
)
.join('\n')
: 'No recent activity';
return {
portName: (port?.name as string) ?? 'Port Nimara',
title: `Client Summary - ${client.fullName ?? ''}`,
clientInfo,
contacts: contactsText,
yachts: yachtsText,
interests: `Pipeline Interests:\n${interestsText}`,
recentActivity: `Recent Activity:\n${activityText}`,
generatedAt: `Generated: ${new Date().toLocaleString('en-GB')}`,
};
}

View File

@@ -0,0 +1,158 @@
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>
);
}

View File

@@ -1,164 +0,0 @@
import type { Template } from '@pdfme/common';
export const interestSummaryTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
schemas: [
[
{
name: 'portName',
type: 'text',
position: { x: 20, y: 15 },
width: 100,
height: 10,
fontSize: 16,
},
{
name: 'title',
type: 'text',
position: { x: 20, y: 30 },
width: 170,
height: 8,
fontSize: 14,
},
{
name: 'clientInfo',
type: 'text',
position: { x: 20, y: 45 },
width: 80,
height: 30,
fontSize: 9,
},
{
name: 'berthInfo',
type: 'text',
position: { x: 110, y: 45 },
width: 80,
height: 30,
fontSize: 9,
},
{
name: 'stageAndCategory',
type: 'text',
position: { x: 20, y: 80 },
width: 170,
height: 15,
fontSize: 9,
},
{
name: 'milestones',
type: 'text',
position: { x: 20, y: 100 },
width: 170,
height: 40,
fontSize: 8,
},
{
name: 'notes',
type: 'text',
position: { x: 20, y: 145 },
width: 170,
height: 30,
fontSize: 9,
},
{
name: 'recentTimeline',
type: 'text',
position: { x: 20, y: 180 },
width: 170,
height: 85,
fontSize: 8,
},
{
name: 'generatedAt',
type: 'text',
position: { x: 20, y: 275 },
width: 170,
height: 6,
fontSize: 7,
},
],
],
};
function formatDate(d: Date | string | null | undefined): string {
if (!d) return 'N/A';
return new Date(d).toLocaleDateString('en-GB');
}
export function buildInterestSummaryInputs(
interest: Record<string, unknown>,
client: Record<string, unknown>,
yacht: Record<string, unknown> | null,
berth: Record<string, unknown> | null,
timeline: Record<string, unknown>[],
port: Record<string, unknown>,
): Record<string, string> {
const clientInfo = [
`Name: ${client?.fullName ?? 'N/A'}`,
yacht?.name ? `Yacht: ${yacht.name}` : null,
yacht?.lengthFt
? `Length: ${yacht.lengthFt}ft${yacht.lengthM ? ` / ${yacht.lengthM}m` : ''}`
: null,
]
.filter(Boolean)
.join('\n');
const berthInfo = berth
? [
`Mooring: ${berth.mooringNumber}`,
berth.area ? `Area: ${berth.area}` : null,
berth.lengthFt ? `Length: ${berth.lengthFt}ft` : null,
berth.price
? `Price: ${berth.priceCurrency ?? 'USD'} ${Number(berth.price).toLocaleString()}`
: null,
`Status: ${berth.status ?? 'available'}`,
]
.filter(Boolean)
.join('\n')
: 'No berth linked';
const stageAndCategory = [
`Stage: ${interest.pipelineStage ?? 'open'}`,
interest.leadCategory ? `Category: ${interest.leadCategory}` : null,
interest.source ? `Source: ${interest.source}` : null,
interest.eoiStatus ? `EOI status: ${interest.eoiStatus}` : null,
interest.contractStatus ? `Contract: ${interest.contractStatus}` : null,
interest.depositStatus ? `Deposit: ${interest.depositStatus}` : null,
]
.filter(Boolean)
.join(' | ');
const milestones = [
`First contact: ${formatDate(interest.dateFirstContact as Date | string | null | undefined)}`,
`Last contact: ${formatDate(interest.dateLastContact as Date | string | null | undefined)}`,
`EOI sent: ${formatDate(interest.dateEoiSent as Date | string | null | undefined)}`,
`EOI signed: ${formatDate(interest.dateEoiSigned as Date | string | null | undefined)}`,
`Contract sent: ${formatDate(interest.dateContractSent as Date | string | null | undefined)}`,
`Contract signed: ${formatDate(interest.dateContractSigned as Date | string | null | undefined)}`,
`Deposit received: ${formatDate(interest.dateDepositReceived as Date | string | null | undefined)}`,
].join('\n');
const notesText = interest.notes ? `Notes:\n${interest.notes}` : 'No notes';
const timelineText =
timeline.length > 0
? timeline
.map(
(e) =>
`${formatDate(e.createdAt as Date | string | null | undefined)} ${e.action ?? e.eventType ?? 'event'} ${e.entityType ?? e.type ?? ''}${e.fieldChanged ? ` [${e.fieldChanged}]` : ''}`,
)
.join('\n')
: 'No timeline events';
return {
portName: (port?.name as string) ?? 'Port Nimara',
title: `Interest Summary - ${client?.fullName ?? 'Unknown Client'}`,
clientInfo,
berthInfo,
stageAndCategory,
milestones: `Milestones:\n${milestones}`,
notes: notesText,
recentTimeline: `Recent Timeline (last ${timeline.length}):\n${timelineText}`,
generatedAt: `Generated: ${new Date().toLocaleString('en-GB')}`,
};
}

View File

@@ -0,0 +1,178 @@
import {
Badge,
DataTable,
DocumentShell,
KeyValueGrid,
Section,
type BadgeTone,
} from '@/lib/pdf/brand-kit';
export interface InterestSummaryPdfProps {
portName: string;
logoBuffer: Buffer | null;
interest: {
id: string;
pipelineStage?: string | null;
leadCategory?: string | null;
source?: string | null;
eoiStatus?: string | null;
contractStatus?: string | null;
depositStatus?: string | null;
dateFirstContact?: Date | string | null;
dateLastContact?: Date | string | null;
dateEoiSent?: Date | string | null;
dateEoiSigned?: Date | string | null;
dateContractSent?: Date | string | null;
dateContractSigned?: Date | string | null;
dateDepositReceived?: Date | string | null;
};
client: { fullName?: string | null };
yacht: {
name?: string | null;
lengthFt?: string | null;
lengthM?: string | null;
widthFt?: string | null;
draftFt?: string | null;
} | null;
berth: {
mooringNumber?: string | null;
area?: string | null;
lengthFt?: string | null;
price?: string | number | null;
priceCurrency?: string | null;
status?: string | null;
} | null;
timeline: Array<{
createdAt: Date | string;
action?: string | null;
entityType?: string | null;
fieldChanged?: string | null;
}>;
}
const STAGE_TONE: Record<string, BadgeTone> = {
open: 'neutral',
details_sent: 'neutral',
in_communication: 'neutral',
eoi_sent: 'accent',
eoi_signed: 'accent',
deposit_10pct: 'warning',
contract_sent: 'warning',
contract_signed: 'success',
completed: 'success',
cancelled: 'danger',
};
function fmt(d: Date | string | null | undefined): string {
if (!d) return '—';
return new Date(d).toISOString().slice(0, 10);
}
export function InterestSummaryPdf({
portName,
logoBuffer,
interest,
client,
yacht,
berth,
timeline,
}: InterestSummaryPdfProps) {
const stage = (interest.pipelineStage ?? 'open').toLowerCase();
const docMeta = `${client.fullName ?? 'Unknown client'} · stage: ${stage.replace('_', ' ')}`;
return (
<DocumentShell
portName={portName}
docTitle="Interest Summary"
docMeta={docMeta}
logoBuffer={logoBuffer}
>
<Section title="Status">
<KeyValueGrid
rows={[
{ label: 'Pipeline stage', value: stage.replace('_', ' ') },
{ label: 'Lead category', value: interest.leadCategory ?? '—' },
{ label: 'Source', value: interest.source ?? '—' },
{ label: 'EOI status', value: interest.eoiStatus ?? '—' },
{ label: 'Contract status', value: interest.contractStatus ?? '—' },
{ label: 'Deposit status', value: interest.depositStatus ?? '—' },
]}
/>
<Badge text={stage.replace('_', ' ').toUpperCase()} tone={STAGE_TONE[stage] ?? 'neutral'} />
</Section>
<Section title="Client">
<KeyValueGrid rows={[{ label: 'Name', value: client.fullName ?? '—' }]} />
</Section>
{yacht ? (
<Section title="Yacht">
<KeyValueGrid
rows={[
{ label: 'Name', value: yacht.name ?? '—' },
{
label: 'Length',
value: yacht.lengthFt
? `${yacht.lengthFt}ft${yacht.lengthM ? ` / ${yacht.lengthM}m` : ''}`
: '—',
},
{ label: 'Beam', value: yacht.widthFt ? `${yacht.widthFt}ft` : '—' },
{ label: 'Draft', value: yacht.draftFt ? `${yacht.draftFt}ft` : '—' },
]}
/>
</Section>
) : null}
{berth ? (
<Section title="Primary berth">
<KeyValueGrid
rows={[
{ label: 'Mooring', value: berth.mooringNumber ?? '—' },
{ label: 'Area', value: berth.area ?? '—' },
{ label: 'Length', value: berth.lengthFt ? `${berth.lengthFt}ft` : '—' },
{
label: 'Price',
value: berth.price
? `${berth.priceCurrency ?? 'USD'} ${Number(berth.price).toLocaleString()}`
: '—',
},
{ label: 'Status', value: berth.status ?? '—' },
]}
/>
</Section>
) : null}
<Section title="Milestones">
<KeyValueGrid
rows={[
{ label: 'First contact', value: fmt(interest.dateFirstContact) },
{ label: 'Last contact', value: fmt(interest.dateLastContact) },
{ label: 'EOI sent', value: fmt(interest.dateEoiSent) },
{ label: 'EOI signed', value: fmt(interest.dateEoiSigned) },
{ label: 'Contract sent', value: fmt(interest.dateContractSent) },
{ label: 'Contract signed', value: fmt(interest.dateContractSigned) },
{ label: 'Deposit received', value: fmt(interest.dateDepositReceived) },
]}
/>
</Section>
<Section title={`Recent timeline (${timeline.length})`}>
<DataTable<{
createdAt: Date | string;
action?: string | null;
entityType?: string | null;
fieldChanged?: string | null;
}>
columns={[
{ header: 'When', flex: 1.5, render: (e) => fmt(e.createdAt) },
{ header: 'Action', flex: 2, render: (e) => e.action ?? '—' },
{ header: 'Entity', flex: 1.5, render: (e) => e.entityType ?? '—' },
{ header: 'Field', flex: 1.5, render: (e) => e.fieldChanged ?? '—' },
]}
rows={timeline}
emptyMessage="No timeline events."
/>
</Section>
</DocumentShell>
);
}

View File

@@ -13,16 +13,11 @@ import { companyMemberships } from '@/lib/db/schema/companies';
import { auditLogs } from '@/lib/db/schema/system';
import { ports } from '@/lib/db/schema/ports';
import { NotFoundError } from '@/lib/errors';
import { generatePdf } from '@/lib/pdf/generate';
import {
clientSummaryTemplate,
buildClientSummaryInputs,
} from '@/lib/pdf/templates/client-summary-template';
import { berthSpecTemplate, buildBerthSpecInputs } from '@/lib/pdf/templates/berth-spec-template';
import {
interestSummaryTemplate,
buildInterestSummaryInputs,
} from '@/lib/pdf/templates/interest-summary-template';
import { renderPdf } from '@/lib/pdf/render';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { BerthSpecPdf } from '@/lib/pdf/templates/berth-spec';
import { ClientSummaryPdf } from '@/lib/pdf/templates/client-summary';
import { InterestSummaryPdf } from '@/lib/pdf/templates/interest-summary';
// ─── Export Client PDF ────────────────────────────────────────────────────────
@@ -108,16 +103,40 @@ export async function exportClientPdf(clientId: string, portId: string): Promise
.from(yachts)
.where(and(eq(yachts.portId, portId), isNull(yachts.archivedAt), or(...ownerConditions)));
const inputs = buildClientSummaryInputs(
client,
contactList,
ownedYachts,
enrichedInterests,
activity,
port ?? {},
);
const logo = await resolvePortLogo(portId);
return generatePdf(clientSummaryTemplate, [inputs]);
return renderPdf(
<ClientSummaryPdf
portName={port?.name ?? 'Port Nimara'}
logoBuffer={logo.buffer}
client={{
fullName: client.fullName,
nationality: client.nationalityIso,
source: client.source,
createdAt: client.createdAt,
}}
contacts={contactList.map((c) => ({
channel: c.channel,
value: c.value,
label: c.label,
isPrimary: c.isPrimary,
}))}
yachts={ownedYachts}
interests={enrichedInterests.map((i) => ({
id: i.id,
pipelineStage: i.pipelineStage,
leadCategory: i.leadCategory,
berthMooringNumber: i.berthMooringNumber,
createdAt: i.createdAt,
}))}
activity={activity.map((a) => ({
action: a.action,
entityType: a.entityType,
fieldChanged: a.fieldChanged,
createdAt: a.createdAt,
}))}
/>,
);
}
// ─── Export Berth PDF ─────────────────────────────────────────────────────────
@@ -190,15 +209,64 @@ export async function exportBerthPdf(berthId: string, portId: string): Promise<U
.orderBy(desc(interests.updatedAt))
.limit(20);
const inputs = buildBerthSpecInputs(
berth,
enrichedWaitingList,
maintenance,
linkedInterests,
port ?? {},
);
// linkedInterests not currently surfaced in the PDF (the old template
// also omitted them); kept the query to preserve the existing data
// contract until a future revision wants to surface them.
void linkedInterests;
return generatePdf(berthSpecTemplate, [inputs]);
const logo = await resolvePortLogo(portId);
return renderPdf(
<BerthSpecPdf
portName={port?.name ?? 'Port Nimara'}
logoBuffer={logo.buffer}
berth={{
mooringNumber: berth.mooringNumber,
area: berth.area,
status: berth.status,
nominalBoatSize: berth.nominalBoatSize,
bowFacing: berth.bowFacing,
lengthFt: berth.lengthFt,
lengthM: berth.lengthM,
widthFt: berth.widthFt,
widthM: berth.widthM,
widthIsMinimum: berth.widthIsMinimum,
draftFt: berth.draftFt,
draftM: berth.draftM,
waterDepth: berth.waterDepth,
waterDepthM: berth.waterDepthM,
waterDepthIsMinimum: berth.waterDepthIsMinimum,
price: berth.price,
priceCurrency: berth.priceCurrency,
tenureType: berth.tenureType,
tenureYears: berth.tenureYears,
tenureStartDate: berth.tenureStartDate,
tenureEndDate: berth.tenureEndDate,
mooringType: berth.mooringType,
powerCapacity: berth.powerCapacity,
voltage: berth.voltage,
cleatType: berth.cleatType,
cleatCapacity: berth.cleatCapacity,
bollardType: berth.bollardType,
bollardCapacity: berth.bollardCapacity,
sidePontoon: berth.sidePontoon,
access: berth.access,
}}
waitingList={enrichedWaitingList.map((w) => ({
position: w.position,
priority: w.priority,
clientName: w.clientName,
notes: w.notes,
}))}
maintenance={maintenance.map((m) => ({
performedDate: m.performedDate,
category: m.category,
description: m.description,
cost: m.cost,
costCurrency: m.costCurrency,
}))}
/>,
);
}
// ─── Export Interest PDF ──────────────────────────────────────────────────────
@@ -243,14 +311,58 @@ export async function exportInterestPdf(interestId: string, portId: string): Pro
.orderBy(desc(auditLogs.createdAt))
.limit(20);
const inputs = buildInterestSummaryInputs(
interest,
client ?? {},
yacht ?? null,
berth ?? null,
timeline,
port ?? {},
);
const logo = await resolvePortLogo(portId);
return generatePdf(interestSummaryTemplate, [inputs]);
return renderPdf(
<InterestSummaryPdf
portName={port?.name ?? 'Port Nimara'}
logoBuffer={logo.buffer}
interest={{
id: interest.id,
pipelineStage: interest.pipelineStage,
leadCategory: interest.leadCategory,
source: interest.source,
eoiStatus: interest.eoiStatus,
contractStatus: interest.contractStatus,
depositStatus: interest.depositStatus,
dateFirstContact: interest.dateFirstContact,
dateLastContact: interest.dateLastContact,
dateEoiSent: interest.dateEoiSent,
dateEoiSigned: interest.dateEoiSigned,
dateContractSent: interest.dateContractSent,
dateContractSigned: interest.dateContractSigned,
dateDepositReceived: interest.dateDepositReceived,
}}
client={client ? { fullName: client.fullName } : { fullName: null }}
yacht={
yacht
? {
name: yacht.name,
lengthFt: yacht.lengthFt,
lengthM: yacht.lengthM,
widthFt: yacht.widthFt,
draftFt: yacht.draftFt,
}
: null
}
berth={
berth
? {
mooringNumber: berth.mooringNumber,
area: berth.area,
lengthFt: berth.lengthFt,
price: berth.price,
priceCurrency: berth.priceCurrency,
status: berth.status,
}
: null
}
timeline={timeline.map((t) => ({
createdAt: t.createdAt,
action: t.action,
entityType: t.entityType,
fieldChanged: t.fieldChanged,
}))}
/>,
);
}

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest';
import { renderPdf } from '@/lib/pdf/render';
import { BerthSpecPdf } from '@/lib/pdf/templates/berth-spec';
import { ClientSummaryPdf } from '@/lib/pdf/templates/client-summary';
import { InterestSummaryPdf } from '@/lib/pdf/templates/interest-summary';
describe('record export templates render', () => {
it('client summary renders with contacts, yachts, interests', async () => {
const bytes = await renderPdf(
<ClientSummaryPdf
portName="Port Test"
logoBuffer={null}
client={{
fullName: 'Alice Example',
nationality: 'US',
source: 'referral',
createdAt: new Date('2025-11-04'),
}}
contacts={[
{ channel: 'email', value: 'alice@example.com', label: 'work', isPrimary: true },
{ channel: 'phone', value: '+1-555-0100', label: null, isPrimary: false },
]}
yachts={[
{
name: 'Sea Spray',
lengthFt: '40',
widthFt: '14',
draftFt: '5',
lengthM: '12.2',
widthM: '4.3',
draftM: '1.5',
},
]}
interests={[
{
id: 'i1',
pipelineStage: 'eoi_sent',
leadCategory: 'a',
berthMooringNumber: 'A12',
createdAt: new Date('2026-01-15'),
},
]}
activity={[
{ action: 'create', entityType: 'client', fieldChanged: null, createdAt: new Date() },
]}
/>,
);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
it('berth spec renders with waiting list + maintenance', async () => {
const bytes = await renderPdf(
<BerthSpecPdf
portName="Port Test"
logoBuffer={null}
berth={{
mooringNumber: 'A12',
area: 'North',
status: 'available',
lengthFt: '50',
widthFt: '16',
draftFt: '6',
price: 75000,
priceCurrency: 'USD',
tenureType: 'permanent',
}}
waitingList={[
{ position: 1, priority: 'high', clientName: 'Alice Example', notes: 'Wants this' },
{ position: 2, priority: 'low', clientName: 'Bob Example', notes: null },
]}
maintenance={[
{
performedDate: '2026-03-01',
category: 'paint',
description: 'Pontoon repaint',
cost: 1200,
costCurrency: 'USD',
},
]}
/>,
);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
it('interest summary renders with all sections', async () => {
const bytes = await renderPdf(
<InterestSummaryPdf
portName="Port Test"
logoBuffer={null}
interest={{
id: 'i1',
pipelineStage: 'eoi_sent',
leadCategory: 'hot',
source: 'referral',
eoiStatus: 'sent',
contractStatus: null,
depositStatus: null,
dateFirstContact: '2025-12-01',
dateLastContact: '2026-01-10',
dateEoiSent: '2026-01-15',
dateEoiSigned: null,
dateContractSent: null,
dateContractSigned: null,
dateDepositReceived: null,
}}
client={{ fullName: 'Alice Example' }}
yacht={{ name: 'Sea Spray', lengthFt: '40', lengthM: '12.2', widthFt: '14', draftFt: '5' }}
berth={{
mooringNumber: 'A12',
area: 'North',
lengthFt: '50',
price: 75000,
priceCurrency: 'USD',
status: 'under_offer',
}}
timeline={[
{
createdAt: new Date('2026-01-15'),
action: 'update',
entityType: 'interest',
fieldChanged: 'pipelineStage',
},
]}
/>,
);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
it('berth spec handles minimal/empty inputs', async () => {
const bytes = await renderPdf(
<BerthSpecPdf
portName="Port Empty"
logoBuffer={null}
berth={{ mooringNumber: 'X1' }}
waitingList={[]}
maintenance={[]}
/>,
);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
});