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