Files
pn-new-crm/tests/unit/pdf-report-renderer.test.ts

225 lines
6.6 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
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: {
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
total: 115,
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
available: 80,
underOffer: 10,
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);
// 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
// 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);
});