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:
@@ -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'),
|
||||
},
|
||||
];
|
||||
}
|
||||
138
src/lib/pdf/templates/reports/activity-report.tsx
Normal file
138
src/lib/pdf/templates/reports/activity-report.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
];
|
||||
}
|
||||
89
src/lib/pdf/templates/reports/occupancy-report.tsx
Normal file
89
src/lib/pdf/templates/reports/occupancy-report.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
];
|
||||
}
|
||||
88
src/lib/pdf/templates/reports/pipeline-report.tsx
Normal file
88
src/lib/pdf/templates/reports/pipeline-report.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
101
src/lib/pdf/templates/reports/revenue-report.tsx
Normal file
101
src/lib/pdf/templates/reports/revenue-report.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
114
tests/unit/report-templates.test.tsx
Normal file
114
tests/unit/report-templates.test.tsx
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user