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>
);
}