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

@@ -40,6 +40,7 @@ import {
type ClientRow,
} from '@/components/clients/client-columns';
import { ColumnPicker } from '@/components/shared/column-picker';
import { ExportListPdfButton } from '@/components/reports/export-list-pdf-button';
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
@@ -202,6 +203,7 @@ export function ClientList() {
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
<ExportListPdfButton kind="clients" />
{/* New Client moved out of PageHeader actions and into the
filter row. Saves a row on mobile (no more dedicated
actions strip). ml-auto keeps the primary action at the