Files
pn-new-crm/tests/unit/record-export-templates.test.tsx
Matt 0e4a2d7396 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>
2026-05-12 20:59:05 +02:00

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);
});