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>
This commit is contained in:
2026-05-21 20:42:55 +02:00
parent 3b199c245c
commit 47c2ba9a99
12 changed files with 910 additions and 15 deletions

View File

@@ -3,6 +3,9 @@ import { renderToBuffer } from '@react-pdf/renderer';
import { createElement } from 'react';
import { DashboardReport } from '@/lib/pdf/reports/dashboard-report';
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';
import type { ReportBranding } from '@/lib/pdf/reports/types';
const branding: ReportBranding = {
@@ -103,6 +106,102 @@ describe('PDF report renderer', () => {
expect(buf.byteLength).toBeGreaterThan(1_000);
}, 30_000);
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);
it('falls back to a stable layout when no logo URL is supplied', async () => {
const element = createElement(DashboardReport, {
title: 'Logoless',