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,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();