Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
24
src/lib/pdf/generate.ts
Normal file
24
src/lib/pdf/generate.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { generate } from '@pdfme/generator';
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* Generates a PDF from a @pdfme template and input data.
|
||||
*
|
||||
* @param template The @pdfme template definition (basePdf + schemas)
|
||||
* @param inputs Array of input objects matching the template schema fields
|
||||
* @returns Raw PDF bytes
|
||||
*/
|
||||
export async function generatePdf(
|
||||
template: Template,
|
||||
inputs: Record<string, string>[],
|
||||
): Promise<Uint8Array> {
|
||||
try {
|
||||
const pdf = await generate({ template, inputs });
|
||||
return pdf;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'PDF generation failed');
|
||||
throw new Error('Failed to generate PDF');
|
||||
}
|
||||
}
|
||||
113
src/lib/pdf/templates/berth-spec-template.ts
Normal file
113
src/lib/pdf/templates/berth-spec-template.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
export const berthSpecTemplate: Template = {
|
||||
basePdf: 'BLANK_PDF' as any,
|
||||
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: any,
|
||||
waitingList: any[],
|
||||
maintenance: any[],
|
||||
linkedInterests: any[],
|
||||
port: any,
|
||||
): 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 ?? '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')}`,
|
||||
};
|
||||
}
|
||||
92
src/lib/pdf/templates/client-summary-template.ts
Normal file
92
src/lib/pdf/templates/client-summary-template.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
export const clientSummaryTemplate: Template = {
|
||||
basePdf: 'BLANK_PDF' as any,
|
||||
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: 'vesselInfo', type: 'text', position: { x: 20, y: 90 }, width: 170, height: 20, fontSize: 9 },
|
||||
{ name: 'interests', type: 'text', position: { x: 20, y: 115 }, width: 170, height: 80, fontSize: 8 },
|
||||
{ name: 'recentActivity', type: 'text', position: { x: 20, y: 200 }, width: 170, height: 60, fontSize: 8 },
|
||||
{ name: 'generatedAt', type: 'text', position: { x: 20, y: 275 }, width: 170, height: 6, fontSize: 7 },
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export function buildClientSummaryInputs(
|
||||
client: any,
|
||||
contacts: any[],
|
||||
interestList: any[],
|
||||
activity: any[],
|
||||
port: any,
|
||||
): Record<string, string> {
|
||||
const clientInfo = [
|
||||
`Name: ${client.fullName ?? 'N/A'}`,
|
||||
client.companyName ? `Company: ${client.companyName}` : null,
|
||||
client.nationality ? `Nationality: ${client.nationality}` : null,
|
||||
client.source ? `Source: ${client.source}` : null,
|
||||
client.isProxy ? `Proxy: Yes${client.proxyType ? ` (${client.proxyType})` : ''}` : null,
|
||||
`Added: ${new Date(client.createdAt).toLocaleDateString('en-GB')}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const contactsText = contacts.length > 0
|
||||
? contacts
|
||||
.map(
|
||||
(c) =>
|
||||
`${c.channel.charAt(0).toUpperCase() + c.channel.slice(1)}${c.isPrimary ? ' (primary)' : ''}: ${c.value}${c.label ? ` [${c.label}]` : ''}`,
|
||||
)
|
||||
.join('\n')
|
||||
: 'No contacts on file';
|
||||
|
||||
const vesselInfo = [
|
||||
client.yachtName ? `Yacht: ${client.yachtName}` : null,
|
||||
client.yachtLengthFt
|
||||
? `Length: ${client.yachtLengthFt}ft${client.yachtLengthM ? ` / ${client.yachtLengthM}m` : ''}`
|
||||
: null,
|
||||
client.yachtWidthFt
|
||||
? `Beam: ${client.yachtWidthFt}ft${client.yachtWidthM ? ` / ${client.yachtWidthM}m` : ''}`
|
||||
: null,
|
||||
client.yachtDraftFt
|
||||
? `Draft: ${client.yachtDraftFt}ft${client.yachtDraftM ? ` / ${client.yachtDraftM}m` : ''}`
|
||||
: null,
|
||||
client.berthSizeDesired ? `Desired berth size: ${client.berthSizeDesired}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ') || 'No vessel information on file';
|
||||
|
||||
const interestsText =
|
||||
interestList.length > 0
|
||||
? interestList
|
||||
.map(
|
||||
(i) =>
|
||||
`• ${i.pipelineStage ?? 'open'}${i.berthMooringNumber ? ` — Berth ${i.berthMooringNumber}` : ''}${i.leadCategory ? ` [${i.leadCategory}]` : ''} (${new Date(i.createdAt).toLocaleDateString('en-GB')})`,
|
||||
)
|
||||
.join('\n')
|
||||
: 'No pipeline interests on file';
|
||||
|
||||
const activityText =
|
||||
activity.length > 0
|
||||
? activity
|
||||
.map(
|
||||
(a) =>
|
||||
`${new Date(a.createdAt).toLocaleDateString('en-GB')} ${a.action} ${a.entityType}${a.fieldChanged ? ` (${a.fieldChanged})` : ''}`,
|
||||
)
|
||||
.join('\n')
|
||||
: 'No recent activity';
|
||||
|
||||
return {
|
||||
portName: port?.name ?? 'Port Nimara',
|
||||
title: `Client Summary — ${client.fullName ?? ''}`,
|
||||
clientInfo,
|
||||
contacts: contactsText,
|
||||
vesselInfo,
|
||||
interests: `Pipeline Interests:\n${interestsText}`,
|
||||
recentActivity: `Recent Activity:\n${activityText}`,
|
||||
generatedAt: `Generated: ${new Date().toLocaleString('en-GB')}`,
|
||||
};
|
||||
}
|
||||
45
src/lib/pdf/templates/eoi-template.ts
Normal file
45
src/lib/pdf/templates/eoi-template.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
export const eoiTemplate: Template = {
|
||||
basePdf: 'BLANK_PDF' as any,
|
||||
schemas: [
|
||||
[
|
||||
{ name: 'portName', type: 'text', position: { x: 20, y: 20 }, width: 170, height: 10, fontSize: 18 },
|
||||
{ name: 'title', type: 'text', position: { x: 20, y: 40 }, width: 170, height: 8, fontSize: 14 },
|
||||
{ name: 'clientName', type: 'text', position: { x: 20, y: 60 }, width: 80, height: 6 },
|
||||
{ name: 'clientEmail', type: 'text', position: { x: 20, y: 68 }, width: 80, height: 6 },
|
||||
{ name: 'yachtName', type: 'text', position: { x: 20, y: 80 }, width: 80, height: 6 },
|
||||
{ name: 'yachtDimensions', type: 'text', position: { x: 20, y: 88 }, width: 80, height: 6 },
|
||||
{ name: 'berthNumber', type: 'text', position: { x: 110, y: 60 }, width: 80, height: 6 },
|
||||
{ name: 'berthDimensions', type: 'text', position: { x: 110, y: 68 }, width: 80, height: 6 },
|
||||
{ name: 'berthPrice', type: 'text', position: { x: 110, y: 76 }, width: 80, height: 6 },
|
||||
{ name: 'date', type: 'text', position: { x: 20, y: 110 }, width: 80, height: 6 },
|
||||
{ name: 'terms', type: 'text', position: { x: 20, y: 130 }, width: 170, height: 100, fontSize: 9 },
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export function buildEoiInputs(
|
||||
interest: Record<string, unknown>,
|
||||
client: Record<string, unknown>,
|
||||
berth: Record<string, unknown>,
|
||||
port: Record<string, unknown>,
|
||||
): Record<string, string> {
|
||||
const contacts = (client.contacts as Array<{ channel: string; value: string }> | undefined) ?? [];
|
||||
const emailContact = contacts.find((c) => c.channel === 'email');
|
||||
|
||||
return {
|
||||
portName: (port.name as string) ?? 'Port Nimara',
|
||||
title: 'Expression of Interest',
|
||||
clientName: `Client: ${client.fullName as string}`,
|
||||
clientEmail: `Email: ${emailContact?.value ?? 'N/A'}`,
|
||||
yachtName: `Yacht: ${(client.yachtName as string) ?? 'N/A'}`,
|
||||
yachtDimensions: `LOA: ${(client.yachtLengthFt as string) ?? '?'}ft × Beam: ${(client.yachtWidthFt as string) ?? '?'}ft × Draft: ${(client.yachtDraftFt as string) ?? '?'}ft`,
|
||||
berthNumber: `Berth: ${berth.mooringNumber as string}`,
|
||||
berthDimensions: `${(berth.lengthFt as string) ?? '?'}ft × ${(berth.widthFt as string) ?? '?'}ft`,
|
||||
berthPrice: `Price: ${(berth.priceCurrency as string) ?? 'USD'} ${(berth.price as string) ?? 'TBD'}`,
|
||||
date: `Date: ${new Date().toLocaleDateString('en-GB')}`,
|
||||
terms:
|
||||
"This Expression of Interest confirms the above-named client's interest in the specified berth. This document is non-binding until signed by all parties. Upon signing, the client agrees to proceed with the berth acquisition process as outlined in the full terms and conditions provided separately.",
|
||||
};
|
||||
}
|
||||
101
src/lib/pdf/templates/interest-summary-template.ts
Normal file
101
src/lib/pdf/templates/interest-summary-template.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
export const interestSummaryTemplate: Template = {
|
||||
basePdf: 'BLANK_PDF' as any,
|
||||
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: any,
|
||||
client: any,
|
||||
berth: any,
|
||||
timeline: any[],
|
||||
port: any,
|
||||
): Record<string, string> {
|
||||
const clientInfo = [
|
||||
`Name: ${client?.fullName ?? 'N/A'}`,
|
||||
client?.companyName ? `Company: ${client.companyName}` : null,
|
||||
client?.yachtName ? `Yacht: ${client.yachtName}` : null,
|
||||
client?.yachtLengthFt
|
||||
? `Length: ${client.yachtLengthFt}ft${client.yachtLengthM ? ` / ${client.yachtLengthM}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)}`,
|
||||
`Last contact: ${formatDate(interest.dateLastContact)}`,
|
||||
`EOI sent: ${formatDate(interest.dateEoiSent)}`,
|
||||
`EOI signed: ${formatDate(interest.dateEoiSigned)}`,
|
||||
`Contract sent: ${formatDate(interest.dateContractSent)}`,
|
||||
`Contract signed: ${formatDate(interest.dateContractSigned)}`,
|
||||
`Deposit received: ${formatDate(interest.dateDepositReceived)}`,
|
||||
].join('\n');
|
||||
|
||||
const notesText = interest.notes
|
||||
? `Notes:\n${interest.notes}`
|
||||
: 'No notes';
|
||||
|
||||
const timelineText =
|
||||
timeline.length > 0
|
||||
? timeline
|
||||
.map(
|
||||
(e) =>
|
||||
`${formatDate(e.createdAt)} ${e.action ?? e.eventType ?? 'event'} ${e.entityType ?? e.type ?? ''}${e.fieldChanged ? ` [${e.fieldChanged}]` : ''}`,
|
||||
)
|
||||
.join('\n')
|
||||
: 'No timeline events';
|
||||
|
||||
return {
|
||||
portName: port?.name ?? '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')}`,
|
||||
};
|
||||
}
|
||||
120
src/lib/pdf/templates/invoice-template.ts
Normal file
120
src/lib/pdf/templates/invoice-template.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
export const invoiceTemplate: Template = {
|
||||
basePdf: 'BLANK_PDF' as any,
|
||||
schemas: [
|
||||
[
|
||||
// Header fields
|
||||
{
|
||||
name: 'portName',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 15 },
|
||||
width: 100,
|
||||
height: 10,
|
||||
fontSize: 16,
|
||||
},
|
||||
{
|
||||
name: 'invoiceTitle',
|
||||
type: 'text',
|
||||
position: { x: 140, y: 15 },
|
||||
width: 50,
|
||||
height: 10,
|
||||
fontSize: 16,
|
||||
},
|
||||
{
|
||||
name: 'invoiceNumber',
|
||||
type: 'text',
|
||||
position: { x: 140, y: 27 },
|
||||
width: 50,
|
||||
height: 6,
|
||||
fontSize: 10,
|
||||
},
|
||||
{
|
||||
name: 'invoiceDate',
|
||||
type: 'text',
|
||||
position: { x: 140, y: 35 },
|
||||
width: 50,
|
||||
height: 6,
|
||||
fontSize: 10,
|
||||
},
|
||||
{
|
||||
name: 'dueDate',
|
||||
type: 'text',
|
||||
position: { x: 140, y: 43 },
|
||||
width: 50,
|
||||
height: 6,
|
||||
fontSize: 10,
|
||||
},
|
||||
// Client info
|
||||
{
|
||||
name: 'clientInfo',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 55 },
|
||||
width: 100,
|
||||
height: 20,
|
||||
fontSize: 10,
|
||||
},
|
||||
// Line items as text block
|
||||
{
|
||||
name: 'lineItems',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 85 },
|
||||
width: 170,
|
||||
height: 120,
|
||||
fontSize: 9,
|
||||
},
|
||||
// Totals
|
||||
{
|
||||
name: 'totals',
|
||||
type: 'text',
|
||||
position: { x: 110, y: 215 },
|
||||
width: 80,
|
||||
height: 30,
|
||||
fontSize: 10,
|
||||
},
|
||||
// Notes
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 250 },
|
||||
width: 170,
|
||||
height: 20,
|
||||
fontSize: 8,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export function buildInvoiceInputs(
|
||||
invoice: any,
|
||||
lineItems: any[],
|
||||
port: any,
|
||||
): Record<string, string> {
|
||||
const itemLines = lineItems
|
||||
.map(
|
||||
(li, i) =>
|
||||
`${i + 1}. ${li.description} | Qty: ${li.quantity} | Unit: ${invoice.currency} ${Number(li.unitPrice).toFixed(2)} | Total: ${invoice.currency} ${Number(li.total).toFixed(2)}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
let totalsText = `Subtotal: ${invoice.currency} ${Number(invoice.subtotal).toFixed(2)}`;
|
||||
if (Number(invoice.discountAmount) > 0) {
|
||||
totalsText += `\nDiscount (${invoice.discountPct}%): -${invoice.currency} ${Number(invoice.discountAmount).toFixed(2)}`;
|
||||
}
|
||||
if (Number(invoice.feeAmount) > 0) {
|
||||
totalsText += `\nFee (${invoice.feePct}%): +${invoice.currency} ${Number(invoice.feeAmount).toFixed(2)}`;
|
||||
}
|
||||
totalsText += `\n─────────────\nTOTAL: ${invoice.currency} ${Number(invoice.total).toFixed(2)}`;
|
||||
|
||||
return {
|
||||
portName: port?.name ?? 'Port Nimara',
|
||||
invoiceTitle: 'INVOICE',
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoiceDate: `Date: ${new Date(invoice.createdAt).toLocaleDateString('en-GB')}`,
|
||||
dueDate: `Due: ${invoice.dueDate}`,
|
||||
clientInfo: `${invoice.clientName}\n${invoice.billingEmail ?? ''}\n${invoice.billingAddress ?? ''}`.trim(),
|
||||
lineItems: itemLines || 'No line items',
|
||||
totals: totalsText,
|
||||
notes: invoice.notes ? `Notes: ${invoice.notes}` : '',
|
||||
};
|
||||
}
|
||||
93
src/lib/pdf/templates/reports/activity-report.ts
Normal file
93
src/lib/pdf/templates/reports/activity-report.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
import type { ActivityData } from '@/lib/services/report-generators';
|
||||
|
||||
export const activityReportTemplate: Template = {
|
||||
basePdf: 'BLANK_PDF' as any,
|
||||
schemas: [
|
||||
[
|
||||
{
|
||||
name: 'reportTitle',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 15 },
|
||||
width: 170,
|
||||
height: 12,
|
||||
fontSize: 20,
|
||||
},
|
||||
{
|
||||
name: 'portName',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 30 },
|
||||
width: 130,
|
||||
height: 8,
|
||||
fontSize: 11,
|
||||
},
|
||||
{
|
||||
name: 'generatedAt',
|
||||
type: 'text',
|
||||
position: { x: 140, y: 30 },
|
||||
width: 50,
|
||||
height: 8,
|
||||
fontSize: 9,
|
||||
},
|
||||
{
|
||||
name: 'activitySummary',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 50 },
|
||||
width: 170,
|
||||
height: 80,
|
||||
fontSize: 10,
|
||||
},
|
||||
{
|
||||
name: 'activityDetails',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 140 },
|
||||
width: 170,
|
||||
height: 120,
|
||||
fontSize: 9,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export function buildActivityInputs(
|
||||
data: ActivityData,
|
||||
portName?: string,
|
||||
): Record<string, string>[] {
|
||||
const summaryLines = [
|
||||
`Activity Summary (${data.logs.length} events)`,
|
||||
'─────────────────────',
|
||||
];
|
||||
|
||||
const sortedSummary = Object.entries(data.summary).sort((a, b) => b[1] - a[1]);
|
||||
if (sortedSummary.length === 0) {
|
||||
summaryLines.push('No activity recorded in the selected period.');
|
||||
} else {
|
||||
for (const [key, cnt] of sortedSummary.slice(0, 15)) {
|
||||
summaryLines.push(`${key}: ${cnt}`);
|
||||
}
|
||||
}
|
||||
|
||||
const detailLines = ['Recent Activity Log', '─────────────────────'];
|
||||
const recentLogs = data.logs.slice(0, 30);
|
||||
if (recentLogs.length === 0) {
|
||||
detailLines.push('No activity logs found.');
|
||||
} else {
|
||||
for (const log of recentLogs) {
|
||||
const date = new Date(log.createdAt).toLocaleDateString('en-GB');
|
||||
detailLines.push(
|
||||
`${date} ${log.action} ${log.entityType}${log.entityId ? ` (${log.entityId.slice(0, 8)}...)` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
reportTitle: 'Activity Report',
|
||||
portName: portName ?? 'Port Nimara',
|
||||
generatedAt: `Generated: ${new Date(data.generatedAt).toLocaleString('en-GB')}`,
|
||||
activitySummary: summaryLines.join('\n'),
|
||||
activityDetails: detailLines.join('\n'),
|
||||
},
|
||||
];
|
||||
}
|
||||
87
src/lib/pdf/templates/reports/occupancy-report.ts
Normal file
87
src/lib/pdf/templates/reports/occupancy-report.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
import type { OccupancyData } from '@/lib/services/report-generators';
|
||||
|
||||
export const occupancyReportTemplate: Template = {
|
||||
basePdf: 'BLANK_PDF' as any,
|
||||
schemas: [
|
||||
[
|
||||
{
|
||||
name: 'reportTitle',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 15 },
|
||||
width: 170,
|
||||
height: 12,
|
||||
fontSize: 20,
|
||||
},
|
||||
{
|
||||
name: 'portName',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 30 },
|
||||
width: 130,
|
||||
height: 8,
|
||||
fontSize: 11,
|
||||
},
|
||||
{
|
||||
name: 'generatedAt',
|
||||
type: 'text',
|
||||
position: { x: 140, y: 30 },
|
||||
width: 50,
|
||||
height: 8,
|
||||
fontSize: 9,
|
||||
},
|
||||
{
|
||||
name: 'occupancyRate',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 50 },
|
||||
width: 170,
|
||||
height: 20,
|
||||
fontSize: 16,
|
||||
},
|
||||
{
|
||||
name: 'statusBreakdown',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 80 },
|
||||
width: 170,
|
||||
height: 80,
|
||||
fontSize: 10,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export function buildOccupancyInputs(
|
||||
data: OccupancyData,
|
||||
portName?: string,
|
||||
): Record<string, string>[] {
|
||||
const statusLabels: Record<string, string> = {
|
||||
available: 'Available',
|
||||
under_offer: 'Under Offer',
|
||||
sold: 'Sold / Occupied',
|
||||
};
|
||||
|
||||
const breakdownLines = ['Berth Status Breakdown', '─────────────────────'];
|
||||
const allStatuses = ['available', 'under_offer', 'sold'];
|
||||
const unknownStatuses = Object.keys(data.statusCounts).filter(
|
||||
(s) => !allStatuses.includes(s),
|
||||
);
|
||||
|
||||
for (const status of [...allStatuses, ...unknownStatuses]) {
|
||||
const cnt = data.statusCounts[status] ?? 0;
|
||||
const label = statusLabels[status] ?? status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const pct = data.totalBerths > 0 ? ((cnt / data.totalBerths) * 100).toFixed(1) : '0.0';
|
||||
breakdownLines.push(`${label}: ${cnt} berth(s) (${pct}%)`);
|
||||
}
|
||||
breakdownLines.push('─────────────────────');
|
||||
breakdownLines.push(`Total Berths: ${data.totalBerths}`);
|
||||
|
||||
return [
|
||||
{
|
||||
reportTitle: 'Berth Occupancy Report',
|
||||
portName: portName ?? 'Port Nimara',
|
||||
generatedAt: `Generated: ${new Date(data.generatedAt).toLocaleString('en-GB')}`,
|
||||
occupancyRate: `Occupancy Rate: ${data.occupancyRate}%`,
|
||||
statusBreakdown: breakdownLines.join('\n'),
|
||||
},
|
||||
];
|
||||
}
|
||||
112
src/lib/pdf/templates/reports/pipeline-report.ts
Normal file
112
src/lib/pdf/templates/reports/pipeline-report.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
import type { PipelineData } from '@/lib/services/report-generators';
|
||||
|
||||
export const pipelineReportTemplate: Template = {
|
||||
basePdf: 'BLANK_PDF' as any,
|
||||
schemas: [
|
||||
[
|
||||
{
|
||||
name: 'reportTitle',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 15 },
|
||||
width: 170,
|
||||
height: 12,
|
||||
fontSize: 20,
|
||||
},
|
||||
{
|
||||
name: 'portName',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 30 },
|
||||
width: 130,
|
||||
height: 8,
|
||||
fontSize: 11,
|
||||
},
|
||||
{
|
||||
name: 'generatedAt',
|
||||
type: 'text',
|
||||
position: { x: 140, y: 30 },
|
||||
width: 50,
|
||||
height: 8,
|
||||
fontSize: 9,
|
||||
},
|
||||
{
|
||||
name: 'summaryText',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 50 },
|
||||
width: 170,
|
||||
height: 100,
|
||||
fontSize: 10,
|
||||
},
|
||||
{
|
||||
name: 'detailsText',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 160 },
|
||||
width: 170,
|
||||
height: 100,
|
||||
fontSize: 9,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export function buildPipelineInputs(
|
||||
data: PipelineData,
|
||||
portName?: string,
|
||||
): Record<string, string>[] {
|
||||
const stageOrder = [
|
||||
'open',
|
||||
'details_sent',
|
||||
'in_communication',
|
||||
'visited',
|
||||
'signed_eoi_nda',
|
||||
'deposit_10pct',
|
||||
'contract',
|
||||
'completed',
|
||||
];
|
||||
|
||||
const summaryLines = stageOrder
|
||||
.filter((stage) => (data.stageCounts[stage] ?? 0) > 0)
|
||||
.map((stage) => {
|
||||
const label = stage.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return `${label}: ${data.stageCounts[stage] ?? 0} interest(s)`;
|
||||
});
|
||||
|
||||
// Include stages not in standard order
|
||||
const unknownStages = Object.keys(data.stageCounts).filter(
|
||||
(s) => !stageOrder.includes(s),
|
||||
);
|
||||
for (const stage of unknownStages) {
|
||||
summaryLines.push(`${stage}: ${data.stageCounts[stage]} interest(s)`);
|
||||
}
|
||||
|
||||
const totalInterests = Object.values(data.stageCounts).reduce((a, b) => a + b, 0);
|
||||
summaryLines.unshift(`Total Active Interests: ${totalInterests}`);
|
||||
summaryLines.unshift('Pipeline Stage Breakdown');
|
||||
summaryLines.unshift('─────────────────────');
|
||||
|
||||
const detailLines = ['Top Interests by Value', '─────────────────────'];
|
||||
if (data.topInterests.length === 0) {
|
||||
detailLines.push('No interests with linked berths found.');
|
||||
} else {
|
||||
data.topInterests.forEach((interest, i) => {
|
||||
const price = interest.berthPrice
|
||||
? `Berth Price: ${Number(interest.berthPrice).toLocaleString()}`
|
||||
: 'No berth linked';
|
||||
const stage = interest.pipelineStage
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
detailLines.push(`${i + 1}. Stage: ${stage} | ${price}`);
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
reportTitle: 'Pipeline Summary Report',
|
||||
portName: portName ?? 'Port Nimara',
|
||||
generatedAt: `Generated: ${new Date(data.generatedAt).toLocaleString('en-GB')}`,
|
||||
summaryText: summaryLines.join('\n'),
|
||||
detailsText: detailLines.join('\n'),
|
||||
},
|
||||
];
|
||||
}
|
||||
102
src/lib/pdf/templates/reports/revenue-report.ts
Normal file
102
src/lib/pdf/templates/reports/revenue-report.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
import type { RevenueData } from '@/lib/services/report-generators';
|
||||
|
||||
export const revenueReportTemplate: Template = {
|
||||
basePdf: 'BLANK_PDF' as any,
|
||||
schemas: [
|
||||
[
|
||||
{
|
||||
name: 'reportTitle',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 15 },
|
||||
width: 170,
|
||||
height: 12,
|
||||
fontSize: 20,
|
||||
},
|
||||
{
|
||||
name: 'portName',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 30 },
|
||||
width: 130,
|
||||
height: 8,
|
||||
fontSize: 11,
|
||||
},
|
||||
{
|
||||
name: 'generatedAt',
|
||||
type: 'text',
|
||||
position: { x: 140, y: 30 },
|
||||
width: 50,
|
||||
height: 8,
|
||||
fontSize: 9,
|
||||
},
|
||||
{
|
||||
name: 'revenueBreakdown',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 50 },
|
||||
width: 170,
|
||||
height: 120,
|
||||
fontSize: 10,
|
||||
},
|
||||
{
|
||||
name: 'totalText',
|
||||
type: 'text',
|
||||
position: { x: 20, y: 180 },
|
||||
width: 170,
|
||||
height: 20,
|
||||
fontSize: 12,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export function buildRevenueInputs(
|
||||
data: RevenueData,
|
||||
portName?: string,
|
||||
): Record<string, string>[] {
|
||||
const stageOrder = [
|
||||
'open',
|
||||
'details_sent',
|
||||
'in_communication',
|
||||
'visited',
|
||||
'signed_eoi_nda',
|
||||
'deposit_10pct',
|
||||
'contract',
|
||||
'completed',
|
||||
];
|
||||
|
||||
const breakdownLines = ['Revenue by Pipeline Stage', '─────────────────────'];
|
||||
|
||||
const orderedStages = [
|
||||
...stageOrder.filter((s) => data.stageRevenue[s] !== undefined),
|
||||
...Object.keys(data.stageRevenue).filter((s) => !stageOrder.includes(s)),
|
||||
];
|
||||
|
||||
if (orderedStages.length === 0) {
|
||||
breakdownLines.push('No revenue data available.');
|
||||
} else {
|
||||
for (const stage of orderedStages) {
|
||||
const label = stage.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const amount = Number(data.stageRevenue[stage] ?? 0).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
breakdownLines.push(`${label}: ${amount}`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalCompleted = Number(data.totalCompleted).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
reportTitle: 'Revenue Report',
|
||||
portName: portName ?? 'Port Nimara',
|
||||
generatedAt: `Generated: ${new Date(data.generatedAt).toLocaleString('en-GB')}`,
|
||||
revenueBreakdown: breakdownLines.join('\n'),
|
||||
totalText: `TOTAL COMPLETED REVENUE: ${totalCompleted}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
581
src/lib/pdf/tiptap-to-pdfme.ts
Normal file
581
src/lib/pdf/tiptap-to-pdfme.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* TipTap JSON → @pdfme Template Serializer
|
||||
*
|
||||
* Converts a TipTap document JSON into a @pdfme Template suitable for PDF generation.
|
||||
* Supports a constrained formatting subset; unsupported nodes are rejected at validation time.
|
||||
*
|
||||
* Supported nodes:
|
||||
* paragraph, heading (h1-h3), bulletList, orderedList, listItem,
|
||||
* table, tableRow, tableCell, tableHeader, image, hardBreak,
|
||||
* text (with marks: bold, italic, underline)
|
||||
*
|
||||
* Unsupported (rejected at save time):
|
||||
* blockquote, codeBlock, horizontalRule, taskList
|
||||
*/
|
||||
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TipTapMark {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TipTapNode {
|
||||
type: string;
|
||||
content?: TipTapNode[];
|
||||
text?: string;
|
||||
marks?: TipTapMark[];
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// @pdfme schema field shape (matches pdfme text plugin schema)
|
||||
// We use an index signature to satisfy @pdfme/common's Schema type requirement
|
||||
interface SchemaField {
|
||||
name: string;
|
||||
type: 'text';
|
||||
position: { x: number; y: number };
|
||||
width: number;
|
||||
height: number;
|
||||
fontSize: number;
|
||||
fontName?: string;
|
||||
fontColor?: string;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
lineHeight?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_WIDTH_MM = 170; // A4 content width (210 - 20mm margins each side)
|
||||
const PAGE_BREAK_THRESHOLD = 250; // y position (mm from page top) to start new logical page
|
||||
const MARGIN_X_MM = 20; // Left margin
|
||||
const MARGIN_TOP_MM = 20; // Top margin
|
||||
|
||||
const UNSUPPORTED_NODES = new Set([
|
||||
'blockquote',
|
||||
'codeBlock',
|
||||
'horizontalRule',
|
||||
'taskList',
|
||||
'taskItem',
|
||||
]);
|
||||
|
||||
// Line heights per node type (mm)
|
||||
const PARAGRAPH_HEIGHT = 6;
|
||||
const H1_HEIGHT = 12;
|
||||
const H2_HEIGHT = 9;
|
||||
const H3_HEIGHT = 7;
|
||||
const LIST_ITEM_HEIGHT = 6;
|
||||
const TABLE_ROW_HEIGHT = 8;
|
||||
|
||||
// Font sizes
|
||||
const PARAGRAPH_FONT_SIZE = 10;
|
||||
const H1_FONT_SIZE = 20;
|
||||
const H2_FONT_SIZE = 16;
|
||||
const H3_FONT_SIZE = 14;
|
||||
const LIST_FONT_SIZE = 10;
|
||||
const TABLE_FONT_SIZE = 9;
|
||||
|
||||
// ─── Template Variables ───────────────────────────────────────────────────────
|
||||
|
||||
export const TEMPLATE_VARIABLES: Array<{ key: string; label: string; example: string }> = [
|
||||
{ key: 'client.name', label: 'Client Full Name', example: 'John Smith' },
|
||||
{ key: 'client.company', label: 'Company Name', example: 'Smith Holdings' },
|
||||
{ key: 'client.email', label: 'Client Email', example: 'john@smithholdings.com' },
|
||||
{ key: 'client.phone', label: 'Client Phone', example: '+61 400 000 000' },
|
||||
{ key: 'interest.stage', label: 'Pipeline Stage', example: 'Signed EOI/NDA' },
|
||||
{ key: 'interest.berthNumber', label: 'Berth Number (from interest)', example: 'A-23' },
|
||||
{ key: 'berth.mooring_number', label: 'Berth Number', example: 'A-23' },
|
||||
{ key: 'berth.price', label: 'Berth Price', example: '$45,000' },
|
||||
{ key: 'berth.tenure_type', label: 'Tenure Type', example: 'Freehold' },
|
||||
{ key: 'port.name', label: 'Port Name', example: 'Port Nimara' },
|
||||
{ key: 'port.currency', label: 'Port Currency', example: 'AUD' },
|
||||
{ key: 'date.today', label: "Today's Date", example: '2026-03-15' },
|
||||
{ key: 'date.year', label: 'Current Year', example: '2026' },
|
||||
];
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recursively walks a TipTap node tree and collects any unsupported node types.
|
||||
* Returns an array of unsupported type names found, or empty array if valid.
|
||||
*/
|
||||
export function validateTipTapDocument(doc: TipTapNode): string[] {
|
||||
const found = new Set<string>();
|
||||
|
||||
function walk(node: TipTapNode): void {
|
||||
if (UNSUPPORTED_NODES.has(node.type)) {
|
||||
found.add(node.type);
|
||||
}
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(doc);
|
||||
return Array.from(found);
|
||||
}
|
||||
|
||||
// ─── Text extraction helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extracts plain text from a TipTap node and its children.
|
||||
* Preserves hardBreak as newline.
|
||||
*/
|
||||
function extractText(node: TipTapNode): string {
|
||||
if (node.type === 'text') {
|
||||
return node.text ?? '';
|
||||
}
|
||||
if (node.type === 'hardBreak') {
|
||||
return '\n';
|
||||
}
|
||||
if (!node.content || node.content.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return node.content.map(extractText).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* For a paragraph's inline content, returns text and a flag indicating bold.
|
||||
* (pdfme text schema doesn't support inline mixed formatting; we honour the
|
||||
* dominant mark on the paragraph level for simplicity.)
|
||||
*/
|
||||
function extractParagraphContent(node: TipTapNode): { text: string; bold: boolean } {
|
||||
let text = '';
|
||||
let hasBold = false;
|
||||
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
if (child.type === 'text') {
|
||||
text += child.text ?? '';
|
||||
if ((child.marks ?? []).some((m) => m.type === 'bold')) hasBold = true;
|
||||
} else if (child.type === 'hardBreak') {
|
||||
text += '\n';
|
||||
} else {
|
||||
text += extractText(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { text, bold: hasBold };
|
||||
}
|
||||
|
||||
// ─── Field name generation ────────────────────────────────────────────────────
|
||||
|
||||
let fieldCounter = 0;
|
||||
|
||||
function nextFieldName(prefix: string): string {
|
||||
return `${prefix}_${fieldCounter++}`;
|
||||
}
|
||||
|
||||
// ─── Serializer State ─────────────────────────────────────────────────────────
|
||||
|
||||
interface SerializerState {
|
||||
fields: SchemaField[];
|
||||
y: number;
|
||||
pageIndex: number;
|
||||
}
|
||||
|
||||
function ensurePageSpace(state: SerializerState, needed: number): void {
|
||||
if (state.y + needed > PAGE_BREAK_THRESHOLD) {
|
||||
state.pageIndex++;
|
||||
state.y = MARGIN_TOP_MM;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Node Processors ──────────────────────────────────────────────────────────
|
||||
|
||||
function processParagraph(node: TipTapNode, state: SerializerState): void {
|
||||
const { text, bold } = extractParagraphContent(node);
|
||||
const lineCount = Math.max(1, (text.match(/\n/g) ?? []).length + 1);
|
||||
const height = PARAGRAPH_HEIGHT * lineCount;
|
||||
|
||||
ensurePageSpace(state, height);
|
||||
|
||||
const field: SchemaField = {
|
||||
name: nextFieldName('para'),
|
||||
type: 'text',
|
||||
position: { x: MARGIN_X_MM, y: state.y },
|
||||
width: PAGE_WIDTH_MM,
|
||||
height,
|
||||
fontSize: PARAGRAPH_FONT_SIZE,
|
||||
fontName: bold ? 'Helvetica-Bold' : 'Helvetica',
|
||||
};
|
||||
|
||||
state.fields.push(field);
|
||||
state.y += height + 1;
|
||||
}
|
||||
|
||||
function processHeading(node: TipTapNode, state: SerializerState): void {
|
||||
const level = (node.attrs?.level as number) ?? 1;
|
||||
|
||||
let height: number;
|
||||
let fontSize: number;
|
||||
if (level === 1) {
|
||||
height = H1_HEIGHT;
|
||||
fontSize = H1_FONT_SIZE;
|
||||
} else if (level === 2) {
|
||||
height = H2_HEIGHT;
|
||||
fontSize = H2_FONT_SIZE;
|
||||
} else {
|
||||
height = H3_HEIGHT;
|
||||
fontSize = H3_FONT_SIZE;
|
||||
}
|
||||
|
||||
ensurePageSpace(state, height);
|
||||
|
||||
const field: SchemaField = {
|
||||
name: nextFieldName(`h${level}`),
|
||||
type: 'text',
|
||||
position: { x: MARGIN_X_MM, y: state.y },
|
||||
width: PAGE_WIDTH_MM,
|
||||
height,
|
||||
fontSize,
|
||||
fontName: 'Helvetica-Bold',
|
||||
};
|
||||
|
||||
state.fields.push(field);
|
||||
state.y += height + 2;
|
||||
}
|
||||
|
||||
function processBulletList(node: TipTapNode, state: SerializerState): void {
|
||||
if (!node.content) return;
|
||||
|
||||
for (const item of node.content) {
|
||||
if (item.type !== 'listItem') continue;
|
||||
const height = LIST_ITEM_HEIGHT;
|
||||
|
||||
ensurePageSpace(state, height);
|
||||
|
||||
state.fields.push({
|
||||
name: nextFieldName('bullet'),
|
||||
type: 'text',
|
||||
position: { x: MARGIN_X_MM + 5, y: state.y },
|
||||
width: PAGE_WIDTH_MM - 5,
|
||||
height,
|
||||
fontSize: LIST_FONT_SIZE,
|
||||
fontName: 'Helvetica',
|
||||
});
|
||||
|
||||
state.y += height + 0.5;
|
||||
}
|
||||
state.y += 2;
|
||||
}
|
||||
|
||||
function processOrderedList(node: TipTapNode, state: SerializerState): void {
|
||||
if (!node.content) return;
|
||||
|
||||
// Use a counter that increments to build ordered list prefixes
|
||||
let listIndex = (node.attrs?.start as number) ?? 1;
|
||||
|
||||
for (const item of node.content) {
|
||||
if (item.type !== 'listItem') continue;
|
||||
const height = LIST_ITEM_HEIGHT;
|
||||
|
||||
ensurePageSpace(state, height);
|
||||
|
||||
// listIndex is used via the field name prefix to distinguish items
|
||||
state.fields.push({
|
||||
name: nextFieldName(`ol_${listIndex}`),
|
||||
type: 'text',
|
||||
position: { x: MARGIN_X_MM + 5, y: state.y },
|
||||
width: PAGE_WIDTH_MM - 5,
|
||||
height,
|
||||
fontSize: LIST_FONT_SIZE,
|
||||
fontName: 'Helvetica',
|
||||
});
|
||||
|
||||
state.y += height + 0.5;
|
||||
listIndex++;
|
||||
}
|
||||
state.y += 2;
|
||||
}
|
||||
|
||||
function processTable(node: TipTapNode, state: SerializerState): void {
|
||||
if (!node.content) return;
|
||||
|
||||
const rows = node.content.filter((r) => r.type === 'tableRow');
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const firstRow = rows[0];
|
||||
const colCount = firstRow?.content?.length ?? 1;
|
||||
const colWidth = PAGE_WIDTH_MM / colCount;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.content) continue;
|
||||
const rowHeight = TABLE_ROW_HEIGHT;
|
||||
|
||||
ensurePageSpace(state, rowHeight);
|
||||
|
||||
row.content.forEach((cell, colIdx) => {
|
||||
const isHeader = cell.type === 'tableHeader';
|
||||
|
||||
state.fields.push({
|
||||
name: nextFieldName(isHeader ? 'th' : 'td'),
|
||||
type: 'text',
|
||||
position: {
|
||||
x: MARGIN_X_MM + colIdx * colWidth,
|
||||
y: state.y,
|
||||
},
|
||||
width: colWidth - 0.5,
|
||||
height: rowHeight,
|
||||
fontSize: TABLE_FONT_SIZE,
|
||||
fontName: isHeader ? 'Helvetica-Bold' : 'Helvetica',
|
||||
});
|
||||
});
|
||||
|
||||
state.y += rowHeight + 0.5;
|
||||
}
|
||||
state.y += 3;
|
||||
}
|
||||
|
||||
// ─── Top-level Node Dispatch ──────────────────────────────────────────────────
|
||||
|
||||
function processNode(node: TipTapNode, state: SerializerState): void {
|
||||
switch (node.type) {
|
||||
case 'paragraph':
|
||||
processParagraph(node, state);
|
||||
break;
|
||||
case 'heading':
|
||||
processHeading(node, state);
|
||||
break;
|
||||
case 'bulletList':
|
||||
processBulletList(node, state);
|
||||
break;
|
||||
case 'orderedList':
|
||||
processOrderedList(node, state);
|
||||
break;
|
||||
case 'table':
|
||||
processTable(node, state);
|
||||
break;
|
||||
case 'image':
|
||||
state.y += 20;
|
||||
break;
|
||||
case 'hardBreak':
|
||||
state.y += 3;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main Serializer ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts a TipTap JSON document to a @pdfme Template.
|
||||
* Variables like {{client.name}} are left as-is in text content.
|
||||
* Call buildContentInputsFromDoc to get inputs with actual values.
|
||||
*/
|
||||
export function tipTapToPdfmeTemplate(doc: TipTapNode): Template {
|
||||
fieldCounter = 0;
|
||||
|
||||
const state: SerializerState = {
|
||||
fields: [],
|
||||
y: MARGIN_TOP_MM,
|
||||
pageIndex: 0,
|
||||
};
|
||||
|
||||
const children = doc.type === 'doc' ? (doc.content ?? []) : [doc];
|
||||
for (const node of children) {
|
||||
processNode(node, state);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const basePdf = 'BLANK_PDF' as any;
|
||||
// pdfme's Schema type has a string index signature we satisfy via the
|
||||
// [key: string]: unknown on SchemaField
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const schemas = [state.fields] as any;
|
||||
|
||||
return { basePdf, schemas } as Template;
|
||||
}
|
||||
|
||||
// ─── Input Builder ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Given a @pdfme Template and a flat key→value data record,
|
||||
* builds empty input records keyed by field name.
|
||||
* Use buildContentInputsFromDoc for content-populated inputs.
|
||||
*/
|
||||
export function buildTemplateInputs(
|
||||
template: Template,
|
||||
data: Record<string, string>,
|
||||
): Record<string, string>[] {
|
||||
return template.schemas.map((pageSchema) => {
|
||||
const record: Record<string, string> = {};
|
||||
for (const field of pageSchema as SchemaField[]) {
|
||||
record[field.name] = data[field.name] ?? '';
|
||||
}
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces all {{variable.key}} tokens in a string with values from the data map.
|
||||
* Unmatched tokens are left as-is.
|
||||
*/
|
||||
export function substituteVariables(
|
||||
text: string,
|
||||
data: Record<string, string>,
|
||||
): string {
|
||||
return text.replace(/\{\{([^}]+)\}\}/g, (_match, key: string) => {
|
||||
return data[key.trim()] ?? _match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Public alias for internal buildContentInputs.
|
||||
* Builds pdfme input records by extracting text content from the TipTap doc
|
||||
* and mapping it to generated field names (in schema order).
|
||||
* Use after calling tipTapToPdfmeTemplate on the same (already-substituted) doc.
|
||||
*/
|
||||
export function buildContentInputsFromDoc(
|
||||
doc: TipTapNode,
|
||||
template: Template,
|
||||
): Record<string, string>[] {
|
||||
return buildContentInputs(doc, template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full pipeline: validate → substitute variables → convert to pdfme template + inputs.
|
||||
* Returns { template, inputs, errors }.
|
||||
*/
|
||||
export function tiptapDocumentToTemplateWithData(
|
||||
doc: TipTapNode,
|
||||
data: Record<string, string> = {},
|
||||
): {
|
||||
template: Template | null;
|
||||
inputs: Record<string, string>[];
|
||||
errors: string[];
|
||||
} {
|
||||
const errors = validateTipTapDocument(doc);
|
||||
if (errors.length > 0) {
|
||||
return { template: null, inputs: [], errors };
|
||||
}
|
||||
|
||||
const substitutedDoc = substituteInDoc(doc, data);
|
||||
const template = tipTapToPdfmeTemplate(substitutedDoc);
|
||||
const inputs = buildContentInputs(substitutedDoc, template);
|
||||
|
||||
return { template, inputs, errors: [] };
|
||||
}
|
||||
|
||||
// ─── Internals ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deeply substitutes variables in all text nodes of a TipTap document.
|
||||
*/
|
||||
function substituteInDoc(
|
||||
node: TipTapNode,
|
||||
data: Record<string, string>,
|
||||
): TipTapNode {
|
||||
if (node.type === 'text' && node.text) {
|
||||
return { ...node, text: substituteVariables(node.text, data) };
|
||||
}
|
||||
if (node.content) {
|
||||
return {
|
||||
...node,
|
||||
content: node.content.map((child) => substituteInDoc(child, data)),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds pdfme input records by extracting text content from the TipTap doc
|
||||
* and mapping it to generated field names (in schema order).
|
||||
*/
|
||||
function buildContentInputs(
|
||||
doc: TipTapNode,
|
||||
template: Template,
|
||||
): Record<string, string>[] {
|
||||
const textContents = extractAllTextContents(doc);
|
||||
const schemas = template.schemas;
|
||||
|
||||
return schemas.map((pageSchema, pageIdx) => {
|
||||
const record: Record<string, string> = {};
|
||||
const fields = pageSchema as SchemaField[];
|
||||
|
||||
fields.forEach((field, fieldIdx) => {
|
||||
const globalIdx =
|
||||
schemas
|
||||
.slice(0, pageIdx)
|
||||
.reduce((acc, ps) => acc + (ps as SchemaField[]).length, 0) + fieldIdx;
|
||||
record[field.name] = textContents[globalIdx] ?? '';
|
||||
});
|
||||
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content for each schema field in document order.
|
||||
* Order must mirror the order that processNode() creates fields.
|
||||
*/
|
||||
function extractAllTextContents(doc: TipTapNode): string[] {
|
||||
const contents: string[] = [];
|
||||
const children = doc.type === 'doc' ? (doc.content ?? []) : [doc];
|
||||
|
||||
for (const node of children) {
|
||||
extractNodeContent(node, contents);
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
function extractNodeContent(node: TipTapNode, out: string[]): void {
|
||||
switch (node.type) {
|
||||
case 'paragraph': {
|
||||
const { text } = extractParagraphContent(node);
|
||||
out.push(text);
|
||||
break;
|
||||
}
|
||||
case 'heading': {
|
||||
out.push(extractText(node));
|
||||
break;
|
||||
}
|
||||
case 'bulletList': {
|
||||
if (node.content) {
|
||||
for (const item of node.content) {
|
||||
if (item.type === 'listItem') {
|
||||
out.push('• ' + extractText(item).replace(/\n/g, ' '));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'orderedList': {
|
||||
if (node.content) {
|
||||
let idx = (node.attrs?.start as number) ?? 1;
|
||||
for (const item of node.content) {
|
||||
if (item.type === 'listItem') {
|
||||
out.push(`${idx}. ` + extractText(item).replace(/\n/g, ' '));
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'table': {
|
||||
if (node.content) {
|
||||
for (const row of node.content) {
|
||||
if (row.type !== 'tableRow' || !row.content) continue;
|
||||
for (const cell of row.content) {
|
||||
out.push(extractText(cell));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'image':
|
||||
out.push('');
|
||||
break;
|
||||
case 'hardBreak':
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user