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:
@@ -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')}`,
|
||||
};
|
||||
}
|
||||
219
src/lib/pdf/templates/berth-spec.tsx
Normal file
219
src/lib/pdf/templates/berth-spec.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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')}`,
|
||||
};
|
||||
}
|
||||
158
src/lib/pdf/templates/client-summary.tsx
Normal file
158
src/lib/pdf/templates/client-summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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')}`,
|
||||
};
|
||||
}
|
||||
178
src/lib/pdf/templates/interest-summary.tsx
Normal file
178
src/lib/pdf/templates/interest-summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
142
tests/unit/record-export-templates.test.tsx
Normal file
142
tests/unit/record-export-templates.test.tsx
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user