diff --git a/src/lib/pdf/templates/reports/activity-report.ts b/src/lib/pdf/templates/reports/activity-report.ts deleted file mode 100644 index 203e0f9d..00000000 --- a/src/lib/pdf/templates/reports/activity-report.ts +++ /dev/null @@ -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[] { - 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'), - }, - ]; -} diff --git a/src/lib/pdf/templates/reports/activity-report.tsx b/src/lib/pdf/templates/reports/activity-report.tsx new file mode 100644 index 00000000..cb8be624 --- /dev/null +++ b/src/lib/pdf/templates/reports/activity-report.tsx @@ -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(); + 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, + 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(); + 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 ( + +
+ +
+ +
+ + columns={[ + { header: 'Action', flex: 3, render: (r) => r.key }, + { header: 'Count', flex: 1, align: 'right', render: (r) => r.value.toLocaleString() }, + ]} + rows={topActions} + /> +
+ +
+ +
+ +
+ + 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." + /> +
+
+ ); +} diff --git a/src/lib/pdf/templates/reports/occupancy-report.ts b/src/lib/pdf/templates/reports/occupancy-report.ts deleted file mode 100644 index 4a7fe01a..00000000 --- a/src/lib/pdf/templates/reports/occupancy-report.ts +++ /dev/null @@ -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[] { - const statusLabels: Record = { - 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'), - }, - ]; -} diff --git a/src/lib/pdf/templates/reports/occupancy-report.tsx b/src/lib/pdf/templates/reports/occupancy-report.tsx new file mode 100644 index 00000000..32d29230 --- /dev/null +++ b/src/lib/pdf/templates/reports/occupancy-report.tsx @@ -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 = { + available: 'Available', + under_offer: 'Under offer', + sold: 'Sold', + reserved: 'Reserved', + maintenance: 'Maintenance', +}; + +const STATUS_COLORS: Record = { + 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 ( + +
+ +
+ +
+ +
+ +
+ + 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%']} + /> +
+
+ ); +} diff --git a/src/lib/pdf/templates/reports/pipeline-report.ts b/src/lib/pdf/templates/reports/pipeline-report.ts deleted file mode 100644 index ca92f376..00000000 --- a/src/lib/pdf/templates/reports/pipeline-report.ts +++ /dev/null @@ -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[] { - 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'), - }, - ]; -} diff --git a/src/lib/pdf/templates/reports/pipeline-report.tsx b/src/lib/pdf/templates/reports/pipeline-report.tsx new file mode 100644 index 00000000..f9ae5c4a --- /dev/null +++ b/src/lib/pdf/templates/reports/pipeline-report.tsx @@ -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 ( + +
+ +
+ +
+ +
+ +
+ + 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." + /> +
+
+ ); +} diff --git a/src/lib/pdf/templates/reports/revenue-report.ts b/src/lib/pdf/templates/reports/revenue-report.ts deleted file mode 100644 index 8dc6c496..00000000 --- a/src/lib/pdf/templates/reports/revenue-report.ts +++ /dev/null @@ -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[] { - 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}`, - }, - ]; -} diff --git a/src/lib/pdf/templates/reports/revenue-report.tsx b/src/lib/pdf/templates/reports/revenue-report.tsx new file mode 100644 index 00000000..6017b41a --- /dev/null +++ b/src/lib/pdf/templates/reports/revenue-report.tsx @@ -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 ( + +
+ +
+ +
+ +
+ +
+ + 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." + /> +
+
+ ); +} diff --git a/src/lib/services/reports.service.ts b/src/lib/services/reports.service.tsx similarity index 78% rename from src/lib/services/reports.service.ts rename to src/lib/services/reports.service.tsx index 2cf7d212..87891da1 100644 --- a/src/lib/services/reports.service.ts +++ b/src/lib/services/reports.service.tsx @@ -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) => ( + + ), }, revenue: { fetchData: fetchRevenueData, - template: revenueReportTemplate, - buildInputs: buildRevenueInputs, + render: (data: RevenueData, ctx: ReportContext) => ( + + ), }, activity: { fetchData: fetchActivityData, - template: activityReportTemplate, - buildInputs: buildActivityInputs, + render: (data: ActivityData, ctx: ReportContext) => ( + + ), }, occupancy: { fetchData: fetchOccupancyData, - template: occupancyReportTemplate, - buildInputs: buildOccupancyInputs, + render: (data: OccupancyData, ctx: ReportContext) => ( + + ), }, } as const; @@ -206,20 +226,31 @@ export async function generateReport(reportJobId: string): Promise { // 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[] - )(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; + const pdfBytes = await renderPdf(renderFn(data, ctx)); // 8. Build storage path const fileId = crypto.randomUUID(); diff --git a/tests/unit/report-templates.test.tsx b/tests/unit/report-templates.test.tsx new file mode 100644 index 00000000..fde63e6a --- /dev/null +++ b/tests/unit/report-templates.test.tsx @@ -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( + ({ + 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( + , + ); + expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + }, 30_000); + + it('pipeline report renders funnel + top interests', async () => { + const bytes = await renderPdf( + ({ + 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( + , + ); + expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + }, 30_000); + + it('all reports gracefully handle empty data', async () => { + const empty = await renderPdf( + , + ); + expect(empty.length).toBeGreaterThan(500); + }, 30_000); +});