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:
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