diff --git a/src/lib/pdf/templates/berth-spec-template.ts b/src/lib/pdf/templates/berth-spec-template.ts deleted file mode 100644 index fcc55753..00000000 --- a/src/lib/pdf/templates/berth-spec-template.ts +++ /dev/null @@ -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, - waitingList: Record[], - maintenance: Record[], - linkedInterests: Record[], - port: Record, -): Record { - 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')}`, - }; -} diff --git a/src/lib/pdf/templates/berth-spec.tsx b/src/lib/pdf/templates/berth-spec.tsx new file mode 100644 index 00000000..75a3f0ad --- /dev/null +++ b/src/lib/pdf/templates/berth-spec.tsx @@ -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 = { + 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 ( + +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + 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' ? : (w.priority ?? '—'), + }, + { header: 'Notes', flex: 3, render: (w) => w.notes ?? '—' }, + ]} + rows={waitingList} + emptyMessage="No clients on waiting list." + /> +
+ +
+ + 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." + /> +
+
+ ); +} diff --git a/src/lib/pdf/templates/client-summary-template.ts b/src/lib/pdf/templates/client-summary-template.ts deleted file mode 100644 index b2304253..00000000 --- a/src/lib/pdf/templates/client-summary-template.ts +++ /dev/null @@ -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, - contacts: Record[], - yachtList: YachtSummary[], - interestList: Record[], - activity: Record[], - port: Record, -): Record { - 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')}`, - }; -} diff --git a/src/lib/pdf/templates/client-summary.tsx b/src/lib/pdf/templates/client-summary.tsx new file mode 100644 index 00000000..4a0af4f6 --- /dev/null +++ b/src/lib/pdf/templates/client-summary.tsx @@ -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 ( + +
+ +
+ +
+ + 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 ? : '—'), + }, + ]} + rows={contacts} + emptyMessage="No contacts on file." + /> +
+ +
+ + 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." + /> +
+ +
+ + 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." + /> +
+ +
+ + 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." + /> +
+
+ ); +} diff --git a/src/lib/pdf/templates/interest-summary-template.ts b/src/lib/pdf/templates/interest-summary-template.ts deleted file mode 100644 index 786ea84b..00000000 --- a/src/lib/pdf/templates/interest-summary-template.ts +++ /dev/null @@ -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, - client: Record, - yacht: Record | null, - berth: Record | null, - timeline: Record[], - port: Record, -): Record { - 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')}`, - }; -} diff --git a/src/lib/pdf/templates/interest-summary.tsx b/src/lib/pdf/templates/interest-summary.tsx new file mode 100644 index 00000000..9adfe2f2 --- /dev/null +++ b/src/lib/pdf/templates/interest-summary.tsx @@ -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 = { + 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 ( + +
+ + +
+ +
+ +
+ + {yacht ? ( +
+ +
+ ) : null} + + {berth ? ( +
+ +
+ ) : 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." + /> +
+
+ ); +} diff --git a/src/lib/services/record-export.ts b/src/lib/services/record-export.tsx similarity index 59% rename from src/lib/services/record-export.ts rename to src/lib/services/record-export.tsx index a8d93cf2..a30f202d 100644 --- a/src/lib/services/record-export.ts +++ b/src/lib/services/record-export.tsx @@ -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( + ({ + 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 ({ + 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( + ({ + createdAt: t.createdAt, + action: t.action, + entityType: t.entityType, + fieldChanged: t.fieldChanged, + }))} + />, + ); } diff --git a/tests/unit/record-export-templates.test.tsx b/tests/unit/record-export-templates.test.tsx new file mode 100644 index 00000000..77e1db6f --- /dev/null +++ b/tests/unit/record-export-templates.test.tsx @@ -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( + , + ); + expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + }, 30_000); + + it('berth spec renders with waiting list + maintenance', async () => { + const bytes = await renderPdf( + , + ); + expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + }, 30_000); + + it('interest summary renders with all sections', async () => { + const bytes = await renderPdf( + , + ); + expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + }, 30_000); + + it('berth spec handles minimal/empty inputs', async () => { + const bytes = await renderPdf( + , + ); + expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + }, 30_000); +});