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
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
|
import { renderToBuffer } from '@react-pdf/renderer';
|
|
|
|
|
import { createElement } from 'react';
|
|
|
|
|
|
|
|
|
|
import { DashboardReport } from '@/lib/pdf/reports/dashboard-report';
|
feat(reports): client / berth / interest list-export PDF reports (phase B)
Extends the report exporter with three list-style report kinds —
clients, berths, interests. Each shares the BrandedReportDocument
layout + the new ReportTable primitive (zebra-striped rows,
proportional widths, no-break rows to keep records together across
page boundaries).
Data fetchers in `src/lib/services/list-report-data.service.ts`:
- resolveClientReportData: clients table joined to per-client
primary email + phone via DISTINCT-style subqueries (matches the
canonical listClients ordering: is_primary DESC, created_at DESC
per channel).
- resolveBerthReportData: berths table, default sort by mooring
number for printed familiarity.
- resolveInterestReportData: interests left-joined to clients +
primary berth, sort by updatedAt desc.
All three cap at 1 000 rows per export with a clear "Showing top N
of <total>" notice rendered when the cap is hit. Above that, the PDF
becomes unreadable (hundreds of pages); reps wanting larger exports
use CSV.
Route schema widened to a 4-arm discriminated union; the dispatch
switch in render-report.ts uses `satisfies` for compile-time variant
narrowing and a `_exhaustive: never` check at the bottom.
UI: each list page (BerthList, ClientList, InterestList) gains an
ExportListPdfButton next to the existing ColumnPicker. Permission-
gated client-side on reports.export; server route re-enforces.
Tests: 3 new render fixtures (1 per kind), all hit the same
%PDF-magic + byte-length assertions. Total render tests now 6/6;
full vitest sweep 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
|
|
|
import { ClientListReport } from '@/lib/pdf/reports/client-list-report';
|
|
|
|
|
import { BerthListReport } from '@/lib/pdf/reports/berth-list-report';
|
|
|
|
|
import { InterestListReport } from '@/lib/pdf/reports/interest-list-report';
|
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
|
|
|
import type { ReportBranding } from '@/lib/pdf/reports/types';
|
|
|
|
|
|
|
|
|
|
const branding: ReportBranding = {
|
|
|
|
|
logoUrl: null,
|
|
|
|
|
primaryColor: '#0F4C81',
|
|
|
|
|
portName: 'Port Nimara',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('PDF report renderer', () => {
|
|
|
|
|
it('renders a dashboard report with all sections to a non-empty PDF buffer', async () => {
|
|
|
|
|
const element = createElement(DashboardReport, {
|
|
|
|
|
title: 'Test report',
|
|
|
|
|
subtitle: 'Unit-test fixture',
|
|
|
|
|
branding,
|
|
|
|
|
generatedAt: '2026-05-21T12:00:00.000Z',
|
|
|
|
|
config: {
|
|
|
|
|
kind: 'dashboard',
|
|
|
|
|
widgetIds: [
|
|
|
|
|
'kpi_overview',
|
|
|
|
|
'pipeline_funnel',
|
|
|
|
|
'berth_status',
|
|
|
|
|
'source_conversion',
|
|
|
|
|
'hot_deals',
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
data: {
|
|
|
|
|
kpis: {
|
|
|
|
|
totalClients: 142,
|
|
|
|
|
activeInterests: 27,
|
|
|
|
|
pipelineValue: 1250000,
|
|
|
|
|
pipelineValueCurrency: 'USD',
|
|
|
|
|
occupancyRate: 64.3,
|
|
|
|
|
},
|
|
|
|
|
pipelineCounts: [
|
|
|
|
|
{ stage: 'enquiry', count: 12 },
|
|
|
|
|
{ stage: 'qualified', count: 8 },
|
|
|
|
|
{ stage: 'eoi', count: 4 },
|
|
|
|
|
{ stage: 'reservation', count: 2 },
|
|
|
|
|
{ stage: 'deposit_paid', count: 1 },
|
|
|
|
|
],
|
|
|
|
|
berthStatus: {
|
|
|
|
|
total: 120,
|
|
|
|
|
available: 80,
|
|
|
|
|
underOffer: 10,
|
|
|
|
|
maintenance: 5,
|
|
|
|
|
sold: 25,
|
|
|
|
|
},
|
|
|
|
|
sourceConversion: [
|
|
|
|
|
{ source: 'website', total: 60, won: 12, lost: 30, conversionRate: 0.2 },
|
|
|
|
|
{ source: 'referral', total: 25, won: 8, lost: 10, conversionRate: 0.32 },
|
|
|
|
|
],
|
|
|
|
|
hotDeals: [
|
|
|
|
|
{
|
|
|
|
|
id: 'i1',
|
|
|
|
|
clientName: 'Acme Corp',
|
|
|
|
|
mooringNumber: 'A3',
|
|
|
|
|
stage: 'reservation',
|
|
|
|
|
lastContact: '2026-05-18T09:00:00.000Z',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const buf = await renderToBuffer(element as any);
|
|
|
|
|
expect(buf.byteLength).toBeGreaterThan(2_000);
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
// PDF files start with `%PDF-` magic bytes - sanity-check that
|
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
|
|
|
// the renderer produced an actual PDF, not an error blob or
|
|
|
|
|
// empty buffer.
|
|
|
|
|
const head = buf.subarray(0, 5).toString('utf-8');
|
|
|
|
|
expect(head).toBe('%PDF-');
|
|
|
|
|
}, 30_000);
|
|
|
|
|
|
|
|
|
|
it('skips sections whose widget id is absent from widgetIds', async () => {
|
|
|
|
|
const element = createElement(DashboardReport, {
|
|
|
|
|
title: 'Sparse report',
|
|
|
|
|
branding,
|
|
|
|
|
generatedAt: '2026-05-21T12:00:00.000Z',
|
|
|
|
|
config: {
|
|
|
|
|
kind: 'dashboard',
|
|
|
|
|
widgetIds: ['kpi_overview'],
|
|
|
|
|
},
|
|
|
|
|
data: {
|
|
|
|
|
kpis: {
|
|
|
|
|
totalClients: 5,
|
|
|
|
|
activeInterests: 1,
|
|
|
|
|
pipelineValue: 0,
|
|
|
|
|
pipelineValueCurrency: 'USD',
|
|
|
|
|
occupancyRate: 0,
|
|
|
|
|
},
|
|
|
|
|
// Provide pipelineCounts even though widgetIds didn't ask for
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
// it - the renderer should still skip the section since it's
|
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
|
|
|
// gated on widgetIds, not data presence.
|
|
|
|
|
pipelineCounts: [{ stage: 'enquiry', count: 1 }],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const buf = await renderToBuffer(element as any);
|
|
|
|
|
expect(buf.byteLength).toBeGreaterThan(1_000);
|
|
|
|
|
}, 30_000);
|
|
|
|
|
|
feat(reports): client / berth / interest list-export PDF reports (phase B)
Extends the report exporter with three list-style report kinds —
clients, berths, interests. Each shares the BrandedReportDocument
layout + the new ReportTable primitive (zebra-striped rows,
proportional widths, no-break rows to keep records together across
page boundaries).
Data fetchers in `src/lib/services/list-report-data.service.ts`:
- resolveClientReportData: clients table joined to per-client
primary email + phone via DISTINCT-style subqueries (matches the
canonical listClients ordering: is_primary DESC, created_at DESC
per channel).
- resolveBerthReportData: berths table, default sort by mooring
number for printed familiarity.
- resolveInterestReportData: interests left-joined to clients +
primary berth, sort by updatedAt desc.
All three cap at 1 000 rows per export with a clear "Showing top N
of <total>" notice rendered when the cap is hit. Above that, the PDF
becomes unreadable (hundreds of pages); reps wanting larger exports
use CSV.
Route schema widened to a 4-arm discriminated union; the dispatch
switch in render-report.ts uses `satisfies` for compile-time variant
narrowing and a `_exhaustive: never` check at the bottom.
UI: each list page (BerthList, ClientList, InterestList) gains an
ExportListPdfButton next to the existing ColumnPicker. Permission-
gated client-side on reports.export; server route re-enforces.
Tests: 3 new render fixtures (1 per kind), all hit the same
%PDF-magic + byte-length assertions. Total render tests now 6/6;
full vitest sweep 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
|
|
|
it('renders a client list report to a non-empty PDF buffer', async () => {
|
|
|
|
|
const element = createElement(ClientListReport, {
|
|
|
|
|
title: 'Clients',
|
|
|
|
|
branding,
|
|
|
|
|
generatedAt: '2026-05-21T12:00:00.000Z',
|
|
|
|
|
config: { kind: 'clients' },
|
|
|
|
|
data: {
|
|
|
|
|
rows: [
|
|
|
|
|
{
|
|
|
|
|
id: 'c1',
|
|
|
|
|
fullName: 'Acme Corp',
|
|
|
|
|
source: 'website',
|
|
|
|
|
nationality: 'GB',
|
|
|
|
|
primaryEmail: 'ops@acme.example',
|
|
|
|
|
primaryPhone: '+44 20 7946 0000',
|
|
|
|
|
createdAt: '2026-04-15T10:00:00Z',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'c2',
|
|
|
|
|
fullName: 'Beta Industries',
|
|
|
|
|
source: 'referral',
|
|
|
|
|
nationality: null,
|
|
|
|
|
primaryEmail: null,
|
|
|
|
|
primaryPhone: null,
|
|
|
|
|
createdAt: '2026-05-01T10:00:00Z',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
total: 2,
|
|
|
|
|
capHit: false,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const buf = await renderToBuffer(element as any);
|
|
|
|
|
expect(buf.byteLength).toBeGreaterThan(1_500);
|
|
|
|
|
expect(buf.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
|
|
|
|
}, 30_000);
|
|
|
|
|
|
|
|
|
|
it('renders a berth list report', async () => {
|
|
|
|
|
const element = createElement(BerthListReport, {
|
|
|
|
|
title: 'Berths',
|
|
|
|
|
branding,
|
|
|
|
|
generatedAt: '2026-05-21T12:00:00.000Z',
|
|
|
|
|
config: { kind: 'berths' },
|
|
|
|
|
data: {
|
|
|
|
|
rows: [
|
|
|
|
|
{
|
|
|
|
|
id: 'b1',
|
|
|
|
|
mooringNumber: 'A1',
|
|
|
|
|
area: 'A',
|
|
|
|
|
status: 'available',
|
|
|
|
|
lengthFt: '40',
|
|
|
|
|
widthFt: '14',
|
|
|
|
|
draftFt: '6',
|
|
|
|
|
price: '120000',
|
|
|
|
|
priceCurrency: 'USD',
|
|
|
|
|
tenureType: 'permanent',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
total: 1,
|
|
|
|
|
capHit: false,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const buf = await renderToBuffer(element as any);
|
|
|
|
|
expect(buf.byteLength).toBeGreaterThan(1_500);
|
|
|
|
|
expect(buf.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
|
|
|
|
}, 30_000);
|
|
|
|
|
|
|
|
|
|
it('renders an interest pipeline report', async () => {
|
|
|
|
|
const element = createElement(InterestListReport, {
|
|
|
|
|
title: 'Pipeline',
|
|
|
|
|
branding,
|
|
|
|
|
generatedAt: '2026-05-21T12:00:00.000Z',
|
|
|
|
|
config: { kind: 'interests' },
|
|
|
|
|
data: {
|
|
|
|
|
rows: [
|
|
|
|
|
{
|
|
|
|
|
id: 'i1',
|
|
|
|
|
clientName: 'Acme Corp',
|
|
|
|
|
primaryMooring: 'A1',
|
|
|
|
|
pipelineStage: 'reservation',
|
|
|
|
|
source: 'website',
|
|
|
|
|
outcome: null,
|
|
|
|
|
createdAt: '2026-04-20T10:00:00Z',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
total: 1,
|
|
|
|
|
capHit: false,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const buf = await renderToBuffer(element as any);
|
|
|
|
|
expect(buf.byteLength).toBeGreaterThan(1_500);
|
|
|
|
|
expect(buf.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
|
|
|
|
|
}, 30_000);
|
|
|
|
|
|
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
|
|
|
it('falls back to a stable layout when no logo URL is supplied', async () => {
|
|
|
|
|
const element = createElement(DashboardReport, {
|
|
|
|
|
title: 'Logoless',
|
|
|
|
|
branding: { ...branding, logoUrl: null },
|
|
|
|
|
generatedAt: '2026-05-21T12:00:00.000Z',
|
|
|
|
|
config: { kind: 'dashboard', widgetIds: ['kpi_overview'] },
|
|
|
|
|
data: {
|
|
|
|
|
kpis: {
|
|
|
|
|
totalClients: 0,
|
|
|
|
|
activeInterests: 0,
|
|
|
|
|
pipelineValue: 0,
|
|
|
|
|
pipelineValueCurrency: 'USD',
|
|
|
|
|
occupancyRate: 0,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const buf = await renderToBuffer(element as any);
|
|
|
|
|
expect(buf.byteLength).toBeGreaterThan(1_000);
|
|
|
|
|
}, 30_000);
|
|
|
|
|
});
|