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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user