From 47c2ba9a99f865a66f5f113370b8bb2f13b14ef7 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 20:42:55 +0200 Subject: [PATCH] feat(reports): client / berth / interest list-export PDF reports (phase B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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) --- src/app/api/v1/reports/generate/route.ts | 60 ++++- src/components/berths/berth-list.tsx | 2 + src/components/clients/client-list.tsx | 2 + src/components/interests/interest-list.tsx | 2 + .../reports/export-list-pdf-button.tsx | 147 +++++++++++ src/lib/pdf/reports/berth-list-report.tsx | 94 +++++++ src/lib/pdf/reports/client-list-report.tsx | 85 +++++++ src/lib/pdf/reports/interest-list-report.tsx | 93 +++++++ src/lib/pdf/reports/render-report.ts | 54 +++- src/lib/pdf/reports/report-table.tsx | 48 ++++ src/lib/services/list-report-data.service.ts | 239 ++++++++++++++++++ tests/unit/pdf-report-renderer.test.ts | 99 ++++++++ 12 files changed, 910 insertions(+), 15 deletions(-) create mode 100644 src/components/reports/export-list-pdf-button.tsx create mode 100644 src/lib/pdf/reports/berth-list-report.tsx create mode 100644 src/lib/pdf/reports/client-list-report.tsx create mode 100644 src/lib/pdf/reports/interest-list-report.tsx create mode 100644 src/lib/pdf/reports/report-table.tsx create mode 100644 src/lib/services/list-report-data.service.ts diff --git a/src/app/api/v1/reports/generate/route.ts b/src/app/api/v1/reports/generate/route.ts index e9ff2dd5..f2e67b96 100644 --- a/src/app/api/v1/reports/generate/route.ts +++ b/src/app/api/v1/reports/generate/route.ts @@ -7,6 +7,11 @@ import { errorResponse, ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { renderReport, type ReportData } from '@/lib/pdf/reports/render-report'; import { resolveDashboardReportData } from '@/lib/services/dashboard-report-data.service'; +import { + resolveClientReportData, + resolveBerthReportData, + resolveInterestReportData, +} from '@/lib/services/list-report-data.service'; import { createAuditLog } from '@/lib/audit'; const dashboardConfigSchema = z.object({ @@ -22,12 +27,34 @@ const dashboardConfigSchema = z.object({ .optional(), }); +const listFilters = z.object({ includeArchived: z.boolean().optional() }).passthrough().optional(); + +const clientsConfigSchema = z.object({ + kind: z.literal('clients'), + columns: z.array(z.string()).max(20).optional(), + filters: listFilters, +}); + +const berthsConfigSchema = z.object({ + kind: z.literal('berths'), + columns: z.array(z.string()).max(20).optional(), + filters: listFilters, +}); + +const interestsConfigSchema = z.object({ + kind: z.literal('interests'), + columns: z.array(z.string()).max(20).optional(), + filters: listFilters, +}); + const requestSchema = z.object({ title: z.string().min(1).max(200), subtitle: z.string().max(400).optional(), config: z.discriminatedUnion('kind', [ dashboardConfigSchema, - // Phase B will widen this union with clients / berths / interests. + clientsConfigSchema, + berthsConfigSchema, + interestsConfigSchema, ]), }); @@ -59,10 +86,33 @@ export const POST = withAuth( case 'dashboard': data.dashboard = await resolveDashboardReportData(ctx.portId, body.config.widgetIds); break; - default: - // Unreachable while only the dashboard kind is wired; kept - // for the type-narrowing exhaustiveness check. - throw new ValidationError('Unsupported report kind'); + case 'clients': + data.clients = await resolveClientReportData(ctx.portId, { + includeArchived: Boolean( + (body.config.filters as { includeArchived?: boolean } | undefined)?.includeArchived, + ), + }); + break; + case 'berths': + data.berths = await resolveBerthReportData(ctx.portId, { + includeArchived: Boolean( + (body.config.filters as { includeArchived?: boolean } | undefined)?.includeArchived, + ), + }); + break; + case 'interests': + data.interests = await resolveInterestReportData(ctx.portId, { + includeArchived: Boolean( + (body.config.filters as { includeArchived?: boolean } | undefined)?.includeArchived, + ), + }); + break; + default: { + const _exhaustive: never = body.config; + throw new ValidationError( + `Unsupported report kind: ${(_exhaustive as { kind: string }).kind}`, + ); + } } const buffer = await renderReport({ diff --git a/src/components/berths/berth-list.tsx b/src/components/berths/berth-list.tsx index fea78537..c465ef48 100644 --- a/src/components/berths/berth-list.tsx +++ b/src/components/berths/berth-list.tsx @@ -11,6 +11,7 @@ import { FilterBar } from '@/components/shared/filter-bar'; import { PageHeader } from '@/components/shared/page-header'; import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown'; import { ColumnPicker } from '@/components/shared/column-picker'; +import { ExportListPdfButton } from '@/components/reports/export-list-pdf-button'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { EmptyState } from '@/components/shared/empty-state'; @@ -136,6 +137,7 @@ export function BerthList() { )}