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:
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}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user