feat(reports): migrate 4 reports from pdfme to react-pdf

Phase 1 / commits 3-6 of 14 — bundled because every report follows the
same conversion pattern (coordinate-stuffed pdfme template -> JSX brand
kit). Each report now has a real header (logo + port name), structured
KeyValueGrid for summary stats, a chart (BarChart / FunnelChart / PieChart
/ LineChart-ready), and a DataTable for detail rows.

Templates:
  activity-report.tsx   bar chart of events-per-day, summary KPIs, top
                        actions table, recent-events table (50 rows)
  revenue-report.tsx    bar chart of revenue per stage, breakdown table
                        with totals row, currency-aware formatting
  pipeline-report.tsx   funnel chart of interests per stage, top interests
                        table, win rate / cycle KPIs
  occupancy-report.tsx  donut pie of berth status mix, status breakdown
                        table with percentages, occupancy rate KPI

reports.service.tsx (renamed .ts -> .tsx for JSX):
  - swap REPORT_TYPE_MAP `template`/`buildInputs` for a single `render`
    function returning a typed react-pdf element
  - inject port logo via resolvePortLogo() and pass through to every
    template through a ReportContext object
  - keep the existing job queue / storage / file-row / socket-emit
    flow intact — only the inner PDF-bytes generation changed

Old pdfme files deleted (4 templates). buildStoragePath / files-table
insert / notifications / status updates all unchanged.

Tests:
  tests/unit/report-templates.test.tsx (5 tests): each report renders
  to valid PDF bytes given a representative seed-style fixture; empty
  data path doesn't throw.

1313/1313 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:55:07 +02:00
parent 6517e014a6
commit 90fbb66709
10 changed files with 594 additions and 420 deletions

View File

@@ -1,90 +0,0 @@
import type { Template } from '@pdfme/common';
import type { ActivityData } from '@/lib/services/report-generators';
export const activityReportTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
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,138 @@
import {
BarChart,
DataTable,
DocumentShell,
KeyValueGrid,
Section,
type BarDatum,
} from '@/lib/pdf/brand-kit';
import type { ActivityData } from '@/lib/services/report-generators';
export interface ActivityReportPdfProps {
portName: string;
logoBuffer: Buffer | null;
data: ActivityData;
/** Optional ISO range for the meta line. Falls back to data.generatedAt. */
dateFrom?: string;
dateTo?: string;
}
interface RowShape {
id: string;
action: string;
entityType: string;
entityId: string | null;
userId: string | null;
createdAt: Date;
}
function bucketByDay(logs: RowShape[]): BarDatum[] {
const buckets = new Map<string, number>();
for (const log of logs) {
const day = new Date(log.createdAt).toISOString().slice(0, 10);
buckets.set(day, (buckets.get(day) ?? 0) + 1);
}
return [...buckets.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.slice(-14)
.map(([day, value]) => ({
label: day.slice(5),
value,
}));
}
function topEntries(
record: Record<string, number>,
n: number,
): Array<{ key: string; value: number }> {
return Object.entries(record)
.sort((a, b) => b[1] - a[1])
.slice(0, n)
.map(([key, value]) => ({ key, value }));
}
function busiestDay(logs: RowShape[]): string {
const buckets = new Map<string, number>();
for (const log of logs) {
const day = new Date(log.createdAt).toISOString().slice(0, 10);
buckets.set(day, (buckets.get(day) ?? 0) + 1);
}
let best = '';
let bestCount = 0;
for (const [day, count] of buckets) {
if (count > bestCount) {
best = day;
bestCount = count;
}
}
return best ? `${best} (${bestCount})` : '—';
}
export function ActivityReportPdf({
portName,
logoBuffer,
data,
dateFrom,
dateTo,
}: ActivityReportPdfProps) {
const topActions = topEntries(data.summary, 5);
const topAction = topActions[0]?.key ?? '—';
const meta =
dateFrom || dateTo
? `Range: ${dateFrom ?? '—'}${dateTo ?? 'today'} · ${data.logs.length} events`
: `Last 30 days · ${data.logs.length} events`;
const chartData = bucketByDay(data.logs);
const tableRows = data.logs.slice(0, 50);
return (
<DocumentShell
portName={portName}
docTitle="Activity Report"
docMeta={meta}
logoBuffer={logoBuffer}
>
<Section title="Summary">
<KeyValueGrid
rows={[
{ label: 'Total events', value: data.logs.length.toLocaleString() },
{ label: 'Top action', value: topAction },
{ label: 'Top users', value: 'see breakdown below' },
{ label: 'Busiest day', value: busiestDay(data.logs) },
]}
/>
</Section>
<Section title="Top actions" subtitle="Top 5 audit-log action types by count.">
<DataTable<{ key: string; value: number }>
columns={[
{ header: 'Action', flex: 3, render: (r) => r.key },
{ header: 'Count', flex: 1, align: 'right', render: (r) => r.value.toLocaleString() },
]}
rows={topActions}
/>
</Section>
<Section title="Daily volume" subtitle="Events per day (most recent 14 days).">
<BarChart data={chartData} height={180} />
</Section>
<Section title="Recent events" subtitle="Most recent 50 audit-log entries.">
<DataTable<RowShape>
columns={[
{
header: 'When',
flex: 1.5,
render: (r) => new Date(r.createdAt).toISOString().replace('T', ' ').slice(0, 16),
},
{ header: 'Action', flex: 1.5, render: (r) => r.action },
{ header: 'Entity', flex: 1.5, render: (r) => r.entityType },
{ header: 'User', flex: 1.5, render: (r) => r.userId ?? '—' },
]}
rows={tableRows}
emptyMessage="No activity in the selected period."
/>
</Section>
</DocumentShell>
);
}

View File

@@ -1,86 +0,0 @@
import type { Template } from '@pdfme/common';
import type { OccupancyData } from '@/lib/services/report-generators';
export const occupancyReportTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
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,89 @@
import {
DataTable,
DocumentShell,
KeyValueGrid,
PieChart,
Section,
type PieDatum,
} from '@/lib/pdf/brand-kit';
import { PDF_TOKENS } from '@/lib/pdf/brand-kit';
import type { OccupancyData } from '@/lib/services/report-generators';
export interface OccupancyReportPdfProps {
portName: string;
logoBuffer: Buffer | null;
data: OccupancyData;
}
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under offer',
sold: 'Sold',
reserved: 'Reserved',
maintenance: 'Maintenance',
};
const STATUS_COLORS: Record<string, string> = {
available: PDF_TOKENS.colors.success,
under_offer: PDF_TOKENS.colors.warning,
sold: PDF_TOKENS.colors.accentBlue,
reserved: PDF_TOKENS.colors.accentSlate,
maintenance: PDF_TOKENS.colors.danger,
};
export function OccupancyReportPdf({ portName, logoBuffer, data }: OccupancyReportPdfProps) {
const entries = Object.entries(data.statusCounts);
const pieData: PieDatum[] = entries.map(([status, count]) => ({
label: STATUS_LABELS[status] ?? status,
value: count,
color: STATUS_COLORS[status],
}));
const sold = data.statusCounts.sold ?? 0;
const underOffer = data.statusCounts.under_offer ?? 0;
const available = data.statusCounts.available ?? 0;
const occupiedRate = `${Math.round(data.occupancyRate * 100)}%`;
return (
<DocumentShell portName={portName} docTitle="Occupancy Report" logoBuffer={logoBuffer}>
<Section title="Summary">
<KeyValueGrid
rows={[
{ label: 'Total berths', value: data.totalBerths.toLocaleString() },
{ label: 'Occupancy rate', value: occupiedRate },
{ label: 'Sold', value: sold.toLocaleString() },
{ label: 'Under offer', value: underOffer.toLocaleString() },
{ label: 'Available', value: available.toLocaleString() },
]}
/>
</Section>
<Section title="Status mix" subtitle="Berth count by current status.">
<PieChart data={pieData} innerRadiusRatio={0.5} />
</Section>
<Section title="Status breakdown">
<DataTable<{ status: string; count: number }>
columns={[
{ header: 'Status', flex: 2, render: (r) => STATUS_LABELS[r.status] ?? r.status },
{
header: 'Count',
flex: 1,
align: 'right',
render: (r) => r.count.toLocaleString(),
},
{
header: '% of total',
flex: 1,
align: 'right',
render: (r) =>
data.totalBerths > 0 ? `${((r.count / data.totalBerths) * 100).toFixed(1)}%` : '—',
},
]}
rows={entries.map(([status, count]) => ({ status, count }))}
totals={['Total', String(data.totalBerths), '100%']}
/>
</Section>
</DocumentShell>
);
}

View File

@@ -1,111 +0,0 @@
import type { Template } from '@pdfme/common';
import type { PipelineData } from '@/lib/services/report-generators';
import { stageLabel } from '@/lib/constants';
export const pipelineReportTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
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',
'eoi_sent',
'eoi_signed',
'deposit_10pct',
'contract_sent',
'contract_signed',
'completed',
];
const summaryLines = stageOrder
.filter((stage) => (data.stageCounts[stage] ?? 0) > 0)
.map((stage) => {
return `${stageLabel(stage)}: ${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,88 @@
import {
DataTable,
DocumentShell,
FunnelChart,
KeyValueGrid,
Section,
type FunnelDatum,
} from '@/lib/pdf/brand-kit';
import { stageLabel } from '@/lib/constants';
import type { PipelineData } from '@/lib/services/report-generators';
export interface PipelineReportPdfProps {
portName: string;
logoBuffer: Buffer | null;
data: PipelineData;
}
const FUNNEL_STAGES = [
'open',
'details_sent',
'in_communication',
'eoi_sent',
'eoi_signed',
'deposit_10pct',
'contract_sent',
'contract_signed',
'completed',
];
interface TopInterestRow {
id: string;
clientId: string;
pipelineStage: string;
berthPrice: string | null;
}
export function PipelineReportPdf({ portName, logoBuffer, data }: PipelineReportPdfProps) {
const funnel: FunnelDatum[] = FUNNEL_STAGES.map((stage) => ({
label: stageLabel(stage),
value: data.stageCounts[stage] ?? 0,
})).filter((d) => d.value > 0);
const totalInterests = Object.values(data.stageCounts).reduce((sum, n) => sum + n, 0);
const completed = data.stageCounts.completed ?? 0;
const cancelled = data.stageCounts.cancelled ?? 0;
const winRate = totalInterests > 0 ? Math.round((completed / totalInterests) * 100) : 0;
const topStage = Object.entries(data.stageCounts).sort((a, b) => b[1] - a[1])[0];
return (
<DocumentShell portName={portName} docTitle="Pipeline Report" logoBuffer={logoBuffer}>
<Section title="Summary">
<KeyValueGrid
rows={[
{ label: 'Total interests', value: totalInterests.toLocaleString() },
{ label: 'Win rate', value: `${winRate}%` },
{ label: 'Completed', value: completed.toLocaleString() },
{
label: 'Top stage',
value: topStage ? `${stageLabel(topStage[0])} (${topStage[1]})` : '—',
},
{ label: 'Cancelled / Lost', value: cancelled.toLocaleString() },
]}
/>
</Section>
<Section title="Pipeline funnel" subtitle="Interests at each stage of the pipeline.">
<FunnelChart data={funnel} height={280} />
</Section>
<Section title="Top interests" subtitle="Highest-value open interests, by berth price.">
<DataTable<TopInterestRow>
columns={[
{ header: 'Stage', flex: 2, render: (r) => stageLabel(r.pipelineStage) },
{ header: 'Client', flex: 2, render: (r) => r.clientId.slice(0, 8) + '…' },
{
header: 'Berth price',
flex: 2,
align: 'right',
render: (r) => (r.berthPrice ? Number(r.berthPrice).toLocaleString() : '—'),
},
]}
rows={data.topInterests}
emptyMessage="No open interests."
/>
</Section>
</DocumentShell>
);
}

View File

@@ -1,100 +0,0 @@
import type { Template } from '@pdfme/common';
import type { RevenueData } from '@/lib/services/report-generators';
import { stageLabel } from '@/lib/constants';
export const revenueReportTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
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',
'eoi_sent',
'eoi_signed',
'deposit_10pct',
'contract_sent',
'contract_signed',
'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 amount = Number(data.stageRevenue[stage] ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
breakdownLines.push(`${stageLabel(stage)}: ${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}`,
},
];
}

View File

@@ -0,0 +1,101 @@
import {
BarChart,
DataTable,
DocumentShell,
KeyValueGrid,
Section,
type BarDatum,
} from '@/lib/pdf/brand-kit';
import { stageLabel } from '@/lib/constants';
import type { RevenueData } from '@/lib/services/report-generators';
export interface RevenueReportPdfProps {
portName: string;
logoBuffer: Buffer | null;
data: RevenueData;
dateFrom?: string;
dateTo?: string;
currency?: string;
}
const STAGE_ORDER = [
'open',
'details_sent',
'in_communication',
'eoi_sent',
'eoi_signed',
'deposit_10pct',
'contract_sent',
'contract_signed',
'completed',
];
function fmtAmount(n: number, currency: string): string {
return `${currency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function RevenueReportPdf({
portName,
logoBuffer,
data,
dateFrom,
dateTo,
currency = 'USD',
}: RevenueReportPdfProps) {
const stages = [
...STAGE_ORDER.filter((s) => data.stageRevenue[s] !== undefined),
...Object.keys(data.stageRevenue).filter((s) => !STAGE_ORDER.includes(s)),
];
const rows = stages.map((stage) => ({
stage,
amount: Number(data.stageRevenue[stage] ?? 0),
}));
const chartData: BarDatum[] = rows.map((r) => ({ label: stageLabel(r.stage), value: r.amount }));
const total = Number(data.totalCompleted);
const subtotal = rows.reduce((s, r) => s + r.amount, 0);
const meta = dateFrom || dateTo ? `Range: ${dateFrom ?? '—'}${dateTo ?? 'today'}` : 'All time';
return (
<DocumentShell
portName={portName}
docTitle="Revenue Report"
docMeta={meta}
logoBuffer={logoBuffer}
>
<Section title="Summary">
<KeyValueGrid
rows={[
{ label: 'Total completed', value: fmtAmount(total, currency) },
{ label: 'Pipeline value (open)', value: fmtAmount(subtotal - total, currency) },
{ label: 'Total stages', value: rows.length },
{ label: 'Currency', value: currency },
]}
/>
</Section>
<Section
title="Revenue by stage"
subtitle="Sum of expected berth value at each pipeline stage."
>
<BarChart data={chartData} height={220} showValues />
</Section>
<Section title="Breakdown">
<DataTable<{ stage: string; amount: number }>
columns={[
{ header: 'Stage', flex: 3, render: (r) => stageLabel(r.stage) },
{
header: 'Amount',
flex: 2,
align: 'right',
render: (r) => fmtAmount(r.amount, currency),
},
]}
rows={rows}
totals={['Total', fmtAmount(subtotal, currency)]}
emptyMessage="No revenue data."
/>
</Section>
</DocumentShell>
);
}

View File

@@ -1,11 +1,14 @@
import { and, desc, eq } from 'drizzle-orm';
import type { ReactElement } from 'react';
import type { DocumentProps } from '@react-pdf/renderer';
import { db } from '@/lib/db';
import { generatedReports } from '@/lib/db/schema/operations';
import { notifications } from '@/lib/db/schema/operations';
import { files } from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports';
import { generatePdf } from '@/lib/pdf/generate';
import { renderPdf } from '@/lib/pdf/render';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { buildStoragePath } from '@/lib/minio/index';
import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
import { emitToRoom } from '@/lib/socket/server';
@@ -19,48 +22,65 @@ import {
fetchRevenueData,
fetchActivityData,
fetchOccupancyData,
type ActivityData,
type OccupancyData,
type PipelineData,
type RevenueData,
} from '@/lib/services/report-generators';
import {
pipelineReportTemplate,
buildPipelineInputs,
} from '@/lib/pdf/templates/reports/pipeline-report';
import {
revenueReportTemplate,
buildRevenueInputs,
} from '@/lib/pdf/templates/reports/revenue-report';
import {
activityReportTemplate,
buildActivityInputs,
} from '@/lib/pdf/templates/reports/activity-report';
import {
occupancyReportTemplate,
buildOccupancyInputs,
} from '@/lib/pdf/templates/reports/occupancy-report';
import { ActivityReportPdf } from '@/lib/pdf/templates/reports/activity-report';
import { OccupancyReportPdf } from '@/lib/pdf/templates/reports/occupancy-report';
import { PipelineReportPdf } from '@/lib/pdf/templates/reports/pipeline-report';
import { RevenueReportPdf } from '@/lib/pdf/templates/reports/revenue-report';
import type { RequestReportInput, ListReportsInput } from '@/lib/validators/reports';
// ─── Report Type Map ──────────────────────────────────────────────────────────
interface ReportContext {
portName: string;
logoBuffer: Buffer | null;
dateFrom?: string;
dateTo?: string;
currency?: string;
}
const REPORT_TYPE_MAP = {
pipeline: {
fetchData: fetchPipelineData,
template: pipelineReportTemplate,
buildInputs: buildPipelineInputs,
render: (data: PipelineData, ctx: ReportContext) => (
<PipelineReportPdf portName={ctx.portName} logoBuffer={ctx.logoBuffer} data={data} />
),
},
revenue: {
fetchData: fetchRevenueData,
template: revenueReportTemplate,
buildInputs: buildRevenueInputs,
render: (data: RevenueData, ctx: ReportContext) => (
<RevenueReportPdf
portName={ctx.portName}
logoBuffer={ctx.logoBuffer}
data={data}
dateFrom={ctx.dateFrom}
dateTo={ctx.dateTo}
currency={ctx.currency}
/>
),
},
activity: {
fetchData: fetchActivityData,
template: activityReportTemplate,
buildInputs: buildActivityInputs,
render: (data: ActivityData, ctx: ReportContext) => (
<ActivityReportPdf
portName={ctx.portName}
logoBuffer={ctx.logoBuffer}
data={data}
dateFrom={ctx.dateFrom}
dateTo={ctx.dateTo}
/>
),
},
occupancy: {
fetchData: fetchOccupancyData,
template: occupancyReportTemplate,
buildInputs: buildOccupancyInputs,
render: (data: OccupancyData, ctx: ReportContext) => (
<OccupancyReportPdf portName={ctx.portName} logoBuffer={ctx.logoBuffer} data={data} />
),
},
} as const;
@@ -206,20 +226,31 @@ export async function generateReport(reportJobId: string): Promise<void> {
// 4. Fetch data
const data = await config.fetchData(portId, params);
// 5. Get port info for name in PDF
// 5. Get port info + brand assets for the PDF header
const port = await db.query.ports.findFirst({
where: eq(ports.id, portId),
});
const portName = port?.name ?? 'Port Nimara';
const portSlug = port?.slug ?? 'port';
const logo = await resolvePortLogo(portId);
// 6. Build inputs (pass portName)
const inputs = (
config.buildInputs as (data: unknown, portName: string) => Record<string, string>[]
)(data, portName);
// 7. Generate PDF
const pdfBytes = await generatePdf(config.template, inputs);
// 6. Render PDF via react-pdf brand kit
const ctx: ReportContext = {
portName,
logoBuffer: logo.buffer,
dateFrom: params.dateFrom as string | undefined,
dateTo: params.dateTo as string | undefined,
currency: params.currency as string | undefined,
};
// The render fn is typed per report; we widen here because typeKey
// narrowing across the union loses the link between data shape and
// render signature. The fetchData / render pair is guaranteed in lock-
// step by the map definition above.
const renderFn = config.render as (
data: unknown,
ctx: ReportContext,
) => ReactElement<DocumentProps>;
const pdfBytes = await renderPdf(renderFn(data, ctx));
// 8. Build storage path
const fileId = crypto.randomUUID();

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import { renderPdf } from '@/lib/pdf/render';
import { ActivityReportPdf } from '@/lib/pdf/templates/reports/activity-report';
import { OccupancyReportPdf } from '@/lib/pdf/templates/reports/occupancy-report';
import { PipelineReportPdf } from '@/lib/pdf/templates/reports/pipeline-report';
import { RevenueReportPdf } from '@/lib/pdf/templates/reports/revenue-report';
const PORT_NAME = 'Port Test';
describe('report templates render', () => {
it('activity report renders with logs + summary', async () => {
const bytes = await renderPdf(
<ActivityReportPdf
portName={PORT_NAME}
logoBuffer={null}
data={{
logs: Array.from({ length: 30 }, (_, i) => ({
id: `id-${i}`,
action: i % 3 === 0 ? 'create' : i % 3 === 1 ? 'update' : 'delete',
entityType: i % 2 === 0 ? 'client' : 'berth',
entityId: `e-${i}`,
userId: `user-${i % 3}`,
createdAt: new Date(2026, 4, (i % 28) + 1),
})),
summary: { create: 10, update: 10, delete: 10 },
generatedAt: new Date().toISOString(),
}}
/>,
);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
expect(bytes.length).toBeGreaterThan(2000);
}, 30_000);
it('revenue report renders with multi-stage breakdown', async () => {
const bytes = await renderPdf(
<RevenueReportPdf
portName={PORT_NAME}
logoBuffer={null}
data={{
stageRevenue: {
open: '12345.67',
eoi_sent: '54321.00',
contract_signed: '98765.43',
completed: '111000.00',
},
totalCompleted: '111000.00',
generatedAt: new Date().toISOString(),
}}
currency="USD"
/>,
);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
it('pipeline report renders funnel + top interests', async () => {
const bytes = await renderPdf(
<PipelineReportPdf
portName={PORT_NAME}
logoBuffer={null}
data={{
stageCounts: {
open: 50,
details_sent: 30,
eoi_sent: 20,
eoi_signed: 10,
completed: 5,
},
topInterests: Array.from({ length: 8 }, (_, i) => ({
id: `i-${i}`,
clientId: `client-${i.toString().padStart(8, '0')}`,
pipelineStage: 'eoi_sent',
berthPrice: String(50000 + i * 5000),
})),
generatedAt: new Date().toISOString(),
}}
/>,
);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
it('occupancy report renders pie + status table', async () => {
const bytes = await renderPdf(
<OccupancyReportPdf
portName={PORT_NAME}
logoBuffer={null}
data={{
statusCounts: {
available: 42,
under_offer: 12,
sold: 38,
reserved: 3,
maintenance: 2,
},
occupancyRate: 0.42,
totalBerths: 97,
generatedAt: new Date().toISOString(),
}}
/>,
);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
it('all reports gracefully handle empty data', async () => {
const empty = await renderPdf(
<ActivityReportPdf
portName={PORT_NAME}
logoBuffer={null}
data={{ logs: [], summary: {}, generatedAt: new Date().toISOString() }}
/>,
);
expect(empty.length).toBeGreaterThan(500);
}, 30_000);
});