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>
143 lines
4.2 KiB
TypeScript
143 lines
4.2 KiB
TypeScript
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);
|
|
});
|