feat(reports): PDF report exporter foundation + dashboard report (phase A)
Production-grade PDF reporting for the CRM. Phase A ships the
foundation (branded layout, render pipeline, API route) plus the
first report kind — the dashboard summary. Phases B, C, D add the
remaining report kinds, saved templates, and the preview modal.
Stack: @react-pdf/renderer (already in package.json). Single primary
font (Helvetica/Helvetica-Bold), per-port primary color + logo,
table-based section layout. Charts will become tables here on
purpose; reports are for printed reference and review, where
exact numbers beat at-a-glance shapes. We can revisit Recharts-as-
SVG embedding if a stakeholder asks for chart visuals.
New files:
- src/lib/pdf/reports/types.ts: discriminated-union ReportConfig
covering dashboard / clients / berths / interests kinds. Only
dashboard is wired in phase A; the others throw a clear
not-implemented error from pickDocument().
- src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off
branding.primaryColor. Computes a readable foreground color
(luminance check) for the accent stripe so dark-brand ports
still read at AA.
- src/lib/pdf/reports/branded-document.tsx: page wrapper with
fixed footer (port name, generated-at timestamp, page numbers
via react-pdf's render-prop pattern).
- src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget
SimpleTable sections. Each section gated on the widget id being
present in config.widgetIds AND data being supplied.
- src/lib/pdf/reports/render-report.ts: single entry point that
resolves branding (logoUrl + primaryColor + portName from
getPortBrandingConfig + ports.name), dispatches via
discriminated-union switch, returns Buffer via renderToBuffer.
Exhaustiveness check at the bottom catches unhandled variants
at compile time.
- src/lib/services/dashboard-report-data.service.ts: server-side
data resolver. PDF_DASHBOARD_WIDGETS is the public widget list
for the dialog picker; each id maps to a dashboard.service.ts
fetcher invoked only when the rep selected that widget.
- src/app/api/v1/reports/generate/route.ts: POST endpoint, zod
discriminated-union body schema, withAuth + withPermission
'reports.export' gating, audit-log write on success, RFC 5987
Content-Disposition for unicode-safe filenames.
- src/components/reports/export-dashboard-pdf-button.tsx: dialog
with section checkboxes + title input. Permission-gated client-
side (server re-checks). Raw fetch (not apiFetch) to pull the
binary blob with X-Port-Id header attached manually.
- tests/unit/pdf-report-renderer.test.ts: renders three fixture
cases — full set / sparse / no-logo — and asserts the buffer
starts with the `%PDF-` magic bytes and is non-trivial in size.
DashboardShell gains an Export PDF button between the date-range
picker and the Customize widgets menu (gated on reports.export).
Verified: tsc clean, vitest 1451/1451 (3 new render tests included).
The first end-to-end manual test (export a real dashboard) is in
Phase D after the preview modal lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
116
src/lib/services/dashboard-report-data.service.ts
Normal file
116
src/lib/services/dashboard-report-data.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Server-side data resolver for the dashboard PDF report.
|
||||
*
|
||||
* Each section is gated on its widget id being present in
|
||||
* `config.widgetIds`, so a report that only includes the pipeline
|
||||
* funnel runs ONE query instead of the full dashboard panel. Keeps
|
||||
* cold-call latency low even when the actual port has hundreds of
|
||||
* berths.
|
||||
*
|
||||
* Lives in its own file (not inside dashboard.service.ts) so the
|
||||
* report-builder concerns — what widget ids map to what fetcher,
|
||||
* which fields the PDF shape requires — stay scoped to the
|
||||
* report-side surface, not the dashboard UI.
|
||||
*/
|
||||
import {
|
||||
getKpis,
|
||||
getPipelineCounts,
|
||||
getBerthStatusDistribution,
|
||||
getHotDeals,
|
||||
getSourceConversion,
|
||||
} from './dashboard.service';
|
||||
import type { DashboardReportData } from '@/lib/pdf/reports/dashboard-report';
|
||||
|
||||
/**
|
||||
* Maps widget ids the dashboard PDF understands. The id space is
|
||||
* intentionally a subset of the on-screen `DASHBOARD_WIDGETS`
|
||||
* registry — only widgets that have a sensible printable form
|
||||
* appear here. The dialog's widget picker filters its option list
|
||||
* by this set.
|
||||
*/
|
||||
export const PDF_DASHBOARD_WIDGET_IDS = [
|
||||
'kpi_overview',
|
||||
'pipeline_funnel',
|
||||
'berth_status',
|
||||
'source_conversion',
|
||||
'hot_deals',
|
||||
] as const;
|
||||
|
||||
export type PdfDashboardWidgetId = (typeof PDF_DASHBOARD_WIDGET_IDS)[number];
|
||||
|
||||
export interface PdfDashboardWidgetOption {
|
||||
id: PdfDashboardWidgetId;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public widget list (label + description) for the export dialog.
|
||||
* Mirrored from the on-screen widget-registry but with PDF-friendly
|
||||
* copy: a "Berth heat" chart is "Berth demand ranking" in print.
|
||||
*/
|
||||
export const PDF_DASHBOARD_WIDGETS: readonly PdfDashboardWidgetOption[] = [
|
||||
{
|
||||
id: 'kpi_overview',
|
||||
label: 'Key metrics',
|
||||
description: 'Total clients, active interests, pipeline value, occupancy %.',
|
||||
},
|
||||
{
|
||||
id: 'pipeline_funnel',
|
||||
label: 'Pipeline funnel',
|
||||
description: 'Active interests grouped by pipeline stage.',
|
||||
},
|
||||
{
|
||||
id: 'berth_status',
|
||||
label: 'Berth status distribution',
|
||||
description: 'Available / under offer / reserved / sold counts.',
|
||||
},
|
||||
{
|
||||
id: 'source_conversion',
|
||||
label: 'Source conversion',
|
||||
description: 'Inquiries → Clients → Interests → Won, by lead source.',
|
||||
},
|
||||
{
|
||||
id: 'hot_deals',
|
||||
label: 'Hot deals',
|
||||
description: 'Top 5 active interests by deal-health score.',
|
||||
},
|
||||
];
|
||||
|
||||
export async function resolveDashboardReportData(
|
||||
portId: string,
|
||||
widgetIds: string[],
|
||||
): Promise<DashboardReportData> {
|
||||
const want = new Set(widgetIds);
|
||||
// Each fetcher returns its own shape; default to undefined to
|
||||
// signal "don't render this section" downstream.
|
||||
const data: DashboardReportData = {};
|
||||
|
||||
if (want.has('kpi_overview')) {
|
||||
data.kpis = await getKpis(portId);
|
||||
}
|
||||
if (want.has('pipeline_funnel')) {
|
||||
data.pipelineCounts = await getPipelineCounts(portId);
|
||||
}
|
||||
if (want.has('berth_status')) {
|
||||
const dist = await getBerthStatusDistribution(portId);
|
||||
// `dist` shape from the service is already the totals dict; pass
|
||||
// straight through. If the service changes shape, the type-check
|
||||
// here will trip.
|
||||
data.berthStatus = dist;
|
||||
}
|
||||
if (want.has('source_conversion')) {
|
||||
data.sourceConversion = await getSourceConversion(portId);
|
||||
}
|
||||
if (want.has('hot_deals')) {
|
||||
const deals = await getHotDeals(portId, 5);
|
||||
data.hotDeals = deals.map((d) => ({
|
||||
id: d.id,
|
||||
clientName: d.clientName,
|
||||
mooringNumber: d.mooringNumber,
|
||||
stage: d.stage,
|
||||
lastContact: d.lastContact,
|
||||
}));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
Reference in New Issue
Block a user