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