Files
pn-new-crm/src/lib/services/dashboard-report-data.service.ts

72 lines
2.3 KiB
TypeScript
Raw Normal View History

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>
2026-05-21 20:35:53 +02:00
/**
* 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';
// Pure data/types now live in `dashboard-report-widgets.ts` so the
// client-side export button can import them without dragging this
// file's DB-touching imports into the browser bundle. Re-exported
// here so existing consumers keep working.
export {
PDF_DASHBOARD_WIDGET_IDS,
PDF_DASHBOARD_WIDGETS,
type PdfDashboardWidgetId,
type PdfDashboardWidgetOption,
} from './dashboard-report-widgets';
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>
2026-05-21 20:35:53 +02:00
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;
}