Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

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:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

View 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')}`,
};
}

View 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')}`,
};
}

View 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.",
};
}

View 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')}`,
};
}

View 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}` : '',
};
}

View 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'),
},
];
}

View 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'),
},
];
}

View 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'),
},
];
}

View 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}`,
},
];
}