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

@@ -0,0 +1,94 @@
import { View, Text } from '@react-pdf/renderer';
import { formatCurrency } from '@/lib/utils/currency';
import { BrandedReportDocument } from './branded-document';
import { ReportTable } from './report-table';
import { makeReportStyles } from './styles';
import type { BerthListReportConfig, ReportBranding } from './types';
import type { BerthReportData } from '@/lib/services/list-report-data.service';
interface Props {
title: string;
subtitle?: string;
branding: ReportBranding;
generatedAt: string;
config: BerthListReportConfig;
data: BerthReportData;
}
const DEFAULT_COLUMNS: ReadonlyArray<{ key: string; label: string; widthPct: number }> = [
{ key: 'mooringNumber', label: 'Mooring', widthPct: 12 },
{ key: 'area', label: 'Area', widthPct: 8 },
{ key: 'status', label: 'Status', widthPct: 12 },
{ key: 'tenureType', label: 'Tenure', widthPct: 13 },
{ key: 'lengthFt', label: 'Length (ft)', widthPct: 12 },
{ key: 'widthFt', label: 'Width (ft)', widthPct: 11 },
{ key: 'draftFt', label: 'Draft (ft)', widthPct: 11 },
{ key: 'price', label: 'Price', widthPct: 21 },
];
export function BerthListReport({ title, subtitle, branding, generatedAt, config, data }: Props) {
const styles = makeReportStyles(branding);
const columns = pickColumns(config.columns);
const cappedNotice = data.capHit
? `Showing the first ${data.rows.length} of ${data.total.toLocaleString()} matching berths.`
: `${data.rows.length} ${data.rows.length === 1 ? 'berth' : 'berths'}`;
return (
<BrandedReportDocument
branding={branding}
title={title}
subtitle={subtitle ?? 'Berth inventory'}
generatedAt={generatedAt}
>
<View>
<Text style={styles.sectionSubtitle}>{cappedNotice}</Text>
<ReportTable
styles={styles}
headers={columns.map((c) => c.label)}
widths={columns.map((c) => c.widthPct)}
rows={data.rows.map((r) => columns.map((c) => formatCell(c.key, r)))}
/>
</View>
</BrandedReportDocument>
);
}
function pickColumns(override?: string[]) {
if (!override || override.length === 0) return [...DEFAULT_COLUMNS];
const map = new Map(DEFAULT_COLUMNS.map((c) => [c.key, c]));
return override.flatMap((k) => (map.has(k) ? [map.get(k)!] : []));
}
function formatCell(key: string, row: BerthReportData['rows'][number]): string {
switch (key) {
case 'mooringNumber':
return row.mooringNumber;
case 'area':
return row.area ?? '';
case 'status':
return formatStatus(row.status);
case 'tenureType':
return row.tenureType === 'permanent'
? 'Permanent'
: row.tenureType === 'fixed_term'
? 'Fixed-term'
: row.tenureType;
case 'lengthFt':
return row.lengthFt ? Number(row.lengthFt).toFixed(1) : '';
case 'widthFt':
return row.widthFt ? Number(row.widthFt).toFixed(1) : '';
case 'draftFt':
return row.draftFt ? Number(row.draftFt).toFixed(1) : '';
case 'price':
return row.price
? formatCurrency(row.price, row.priceCurrency, { maxFractionDigits: 0 })
: '';
default:
return '';
}
}
function formatStatus(s: string): string {
return s.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase());
}

View File

@@ -0,0 +1,85 @@
import { View, Text } from '@react-pdf/renderer';
import { BrandedReportDocument } from './branded-document';
import { ReportTable } from './report-table';
import { makeReportStyles } from './styles';
import type { ClientListReportConfig, ReportBranding } from './types';
import type { ClientReportData } from '@/lib/services/list-report-data.service';
interface Props {
title: string;
subtitle?: string;
branding: ReportBranding;
generatedAt: string;
config: ClientListReportConfig;
data: ClientReportData;
}
const DEFAULT_COLUMNS: ReadonlyArray<{ key: string; label: string; widthPct: number }> = [
{ key: 'fullName', label: 'Name', widthPct: 30 },
{ key: 'primaryEmail', label: 'Email', widthPct: 25 },
{ key: 'primaryPhone', label: 'Phone', widthPct: 15 },
{ key: 'source', label: 'Source', widthPct: 12 },
{ key: 'nationality', label: 'Nationality', widthPct: 8 },
{ key: 'createdAt', label: 'Created', widthPct: 10 },
];
/**
* Filtered clients list report. Columns can be subset via
* `config.columns`; default set is the rep-facing subset most useful
* on a printed report (skip internal IDs / metadata fields).
*/
export function ClientListReport({ title, subtitle, branding, generatedAt, config, data }: Props) {
const styles = makeReportStyles(branding);
const columns = pickColumns(config.columns);
const cappedNotice = data.capHit
? `Showing the most recent ${data.rows.length} of ${data.total.toLocaleString()} matching clients.`
: `${data.rows.length} ${data.rows.length === 1 ? 'client' : 'clients'}`;
return (
<BrandedReportDocument
branding={branding}
title={title}
subtitle={subtitle ?? 'Client list'}
generatedAt={generatedAt}
>
<View>
<Text style={styles.sectionSubtitle}>{cappedNotice}</Text>
<ReportTable
styles={styles}
headers={columns.map((c) => c.label)}
widths={columns.map((c) => c.widthPct)}
rows={data.rows.map((r) => columns.map((c) => formatCell(c.key, r)))}
/>
</View>
</BrandedReportDocument>
);
}
function pickColumns(override?: string[]) {
if (!override || override.length === 0) return [...DEFAULT_COLUMNS];
// Filter the default set to the requested subset; preserve the
// ordering from the override array so reps can reorder columns
// via the saved-template UI in phase C.
const map = new Map(DEFAULT_COLUMNS.map((c) => [c.key, c]));
return override.flatMap((k) => (map.has(k) ? [map.get(k)!] : []));
}
function formatCell(key: string, row: ClientReportData['rows'][number]): string {
switch (key) {
case 'fullName':
return row.fullName;
case 'primaryEmail':
return row.primaryEmail ?? '';
case 'primaryPhone':
return row.primaryPhone ?? '';
case 'source':
return row.source ?? '';
case 'nationality':
return row.nationality ?? '';
case 'createdAt':
return new Date(row.createdAt).toLocaleDateString('en-GB');
default:
return '';
}
}

View File

@@ -0,0 +1,93 @@
import { View, Text } from '@react-pdf/renderer';
import { stageLabel } from '@/lib/constants';
import { BrandedReportDocument } from './branded-document';
import { ReportTable } from './report-table';
import { makeReportStyles } from './styles';
import type { InterestListReportConfig, ReportBranding } from './types';
import type { InterestReportData } from '@/lib/services/list-report-data.service';
interface Props {
title: string;
subtitle?: string;
branding: ReportBranding;
generatedAt: string;
config: InterestListReportConfig;
data: InterestReportData;
}
const DEFAULT_COLUMNS: ReadonlyArray<{ key: string; label: string; widthPct: number }> = [
{ key: 'clientName', label: 'Client', widthPct: 32 },
{ key: 'primaryMooring', label: 'Mooring', widthPct: 13 },
{ key: 'pipelineStage', label: 'Stage', widthPct: 22 },
{ key: 'source', label: 'Source', widthPct: 15 },
{ key: 'outcome', label: 'Outcome', widthPct: 8 },
{ key: 'createdAt', label: 'Created', widthPct: 10 },
];
/**
* Interest pipeline export. Rows are grouped within the underlying
* query by stage (sort by updatedAt desc, but the rep can re-sort
* the printed PDF mentally via the Stage column).
*
* `outcome` is rendered with a single-letter shorthand (W / L / -)
* so the column doesn't dominate the row width.
*/
export function InterestListReport({
title,
subtitle,
branding,
generatedAt,
config,
data,
}: Props) {
const styles = makeReportStyles(branding);
const columns = pickColumns(config.columns);
const cappedNotice = data.capHit
? `Showing the most recent ${data.rows.length} of ${data.total.toLocaleString()} matching interests.`
: `${data.rows.length} ${data.rows.length === 1 ? 'interest' : 'interests'}`;
return (
<BrandedReportDocument
branding={branding}
title={title}
subtitle={subtitle ?? 'Interest pipeline'}
generatedAt={generatedAt}
>
<View>
<Text style={styles.sectionSubtitle}>{cappedNotice}</Text>
<ReportTable
styles={styles}
headers={columns.map((c) => c.label)}
widths={columns.map((c) => c.widthPct)}
rows={data.rows.map((r) => columns.map((c) => formatCell(c.key, r)))}
/>
</View>
</BrandedReportDocument>
);
}
function pickColumns(override?: string[]) {
if (!override || override.length === 0) return [...DEFAULT_COLUMNS];
const map = new Map(DEFAULT_COLUMNS.map((c) => [c.key, c]));
return override.flatMap((k) => (map.has(k) ? [map.get(k)!] : []));
}
function formatCell(key: string, row: InterestReportData['rows'][number]): string {
switch (key) {
case 'clientName':
return row.clientName ?? '';
case 'primaryMooring':
return row.primaryMooring ?? '';
case 'pipelineStage':
return stageLabel(row.pipelineStage);
case 'source':
return row.source ?? '';
case 'outcome':
return row.outcome === 'won' ? 'W' : row.outcome === 'lost' ? 'L' : '-';
case 'createdAt':
return new Date(row.createdAt).toLocaleDateString('en-GB');
default:
return '';
}
}

View File

@@ -8,6 +8,9 @@ import { getPortBrandingConfig } from '@/lib/services/port-config';
import { NotFoundError } from '@/lib/errors';
import { DashboardReport, type DashboardReportData } from './dashboard-report';
import { ClientListReport } from './client-list-report';
import { BerthListReport } from './berth-list-report';
import { InterestListReport } from './interest-list-report';
import type {
ReportBranding,
ReportConfig,
@@ -17,6 +20,11 @@ import type {
BerthListReportConfig,
InterestListReportConfig,
} from './types';
import type {
ClientReportData,
BerthReportData,
InterestReportData,
} from '@/lib/services/list-report-data.service';
/**
* Pre-fetched data payloads each report kind needs at render time.
@@ -25,10 +33,9 @@ import type {
*/
export interface ReportData {
dashboard?: DashboardReportData;
// Phase B will fill these in.
clients?: never;
berths?: never;
interests?: never;
clients?: ClientReportData;
berths?: BerthReportData;
interests?: InterestReportData;
}
interface RenderArgs {
@@ -102,14 +109,41 @@ function pickDocument(
data: data.dashboard ?? {},
});
case 'clients':
if (!data.clients) {
throw new Error('Client report requested without client data resolved');
}
return createElement(ClientListReport, {
title: request.title,
subtitle: request.subtitle,
branding,
generatedAt,
config: cfg satisfies ClientListReportConfig,
data: data.clients,
});
case 'berths':
if (!data.berths) {
throw new Error('Berth report requested without berth data resolved');
}
return createElement(BerthListReport, {
title: request.title,
subtitle: request.subtitle,
branding,
generatedAt,
config: cfg satisfies BerthListReportConfig,
data: data.berths,
});
case 'interests':
// Phase B adds the dispatch + matching component. Surface a
// clear error so an early-merged Phase A doesn't silently
// render a blank PDF when a rep picks one of these kinds.
throw new Error(
`Report kind '${(cfg as ClientListReportConfig | BerthListReportConfig | InterestListReportConfig).kind}' not implemented yet (Phase B).`,
);
if (!data.interests) {
throw new Error('Interest report requested without interest data resolved');
}
return createElement(InterestListReport, {
title: request.title,
subtitle: request.subtitle,
branding,
generatedAt,
config: cfg satisfies InterestListReportConfig,
data: data.interests,
});
default: {
// Exhaustiveness check — surfaces a compile error if a new
// ReportConfig variant is added without a matching case here.

View File

@@ -0,0 +1,48 @@
import { View, Text } from '@react-pdf/renderer';
import type { makeReportStyles } from './styles';
interface ReportTableProps {
styles: ReturnType<typeof makeReportStyles>;
headers: string[];
/** Percentage widths per column. Must sum to 100; 1px slack is fine. */
widths: number[];
rows: string[][];
}
/**
* Shared zebra-striped table primitive used by every list-style
* report kind. Header row is gray-100; even rows are white; odd rows
* tinted #fafafa so scanning a 50-row page doesn't lose the eye-line.
*
* The component is intentionally untyped beyond strings — every
* report component formats numbers / dates / currencies to strings
* before passing rows in. Keeps the table primitive deliberately
* dumb (no formatting decisions live here).
*/
export function ReportTable({ styles, headers, widths, rows }: ReportTableProps) {
return (
<View style={styles.table}>
<View style={styles.tableHeader}>
{headers.map((header, i) => (
<Text key={`h-${i}`} style={{ ...styles.tableHeaderCell, width: `${widths[i]}%` }}>
{header}
</Text>
))}
</View>
{rows.map((row, rowIdx) => (
<View
key={`r-${rowIdx}`}
style={rowIdx % 2 === 1 ? styles.tableRowZebra : styles.tableRow}
wrap={false}
>
{row.map((cell, i) => (
<Text key={`c-${rowIdx}-${i}`} style={{ ...styles.tableCell, width: `${widths[i]}%` }}>
{cell}
</Text>
))}
</View>
))}
</View>
);
}

View File

@@ -0,0 +1,239 @@
/**
* Server-side data resolvers for the list-report PDF kinds
* (clients, berths, interests). Each returns a capped flat array
* matching the shape the matching React-PDF report component
* expects.
*
* Caps:
* - 1 000 rows max per export. Above that, the PDF becomes
* unreadable (hundreds of pages) and pdf-renderer memory cost
* grows linearly. Reps wanting fuller exports use CSV.
* - When the cap is hit, the report renders a "Showing top N of
* <total>" line so the export isn't silently truncated.
*
* Filters carried via the `filters` config:
* Clients: search, source, nationality, includeArchived
* Berths: search, status, area, includeArchived
* Interests: search, pipelineStage, includeArchived
*
* Validation happens at the route layer; this service trusts the
* caller has run the inputs through the same zod schemas the
* existing list endpoints use.
*/
import { and, desc, eq, isNull, sql, type SQL } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths';
import { interests, interestBerths } from '@/lib/db/schema/interests';
const REPORT_ROW_CAP = 1_000;
export interface ClientReportRow {
id: string;
fullName: string;
source: string | null;
nationality: string | null;
primaryEmail: string | null;
primaryPhone: string | null;
createdAt: string;
}
export interface ClientReportData {
rows: ClientReportRow[];
total: number;
capHit: boolean;
}
/**
* Slim client export. Pulls the bare minimum columns the report
* surface renders so payload size and PDF length stay reasonable
* even on a 1 000-row port.
*/
export async function resolveClientReportData(
portId: string,
filters: { includeArchived?: boolean } = {},
): Promise<ClientReportData> {
const whereParts: SQL[] = [eq(clients.portId, portId)];
if (!filters.includeArchived) whereParts.push(isNull(clients.archivedAt));
const whereClause = whereParts.length > 1 ? and(...whereParts) : whereParts[0];
const countRows = await db
.select({ count: sql<number>`count(*)::int` })
.from(clients)
.where(whereClause);
const total = Number(countRows[0]?.count ?? 0);
// Subqueries pick a single email / phone per client. `is_primary
// DESC, created_at DESC` picks the primary if set; otherwise the
// most recent contact in that channel. Matches the ordering the
// canonical `listClients` service uses.
const rows = await db
.select({
id: clients.id,
fullName: clients.fullName,
source: clients.source,
nationality: clients.nationalityIso,
primaryEmail: sql<string | null>`(
SELECT cc.value
FROM client_contacts cc
WHERE cc.client_id = ${clients.id} AND cc.channel = 'email'
ORDER BY cc.is_primary DESC, cc.created_at DESC
LIMIT 1
)`,
primaryPhone: sql<string | null>`(
SELECT cc.value
FROM client_contacts cc
WHERE cc.client_id = ${clients.id} AND cc.channel = 'phone'
ORDER BY cc.is_primary DESC, cc.created_at DESC
LIMIT 1
)`,
createdAt: clients.createdAt,
})
.from(clients)
.where(whereClause)
.orderBy(desc(clients.createdAt))
.limit(REPORT_ROW_CAP);
return {
rows: rows.map((r) => ({
id: r.id,
fullName: r.fullName,
source: r.source ?? null,
nationality: r.nationality ?? null,
primaryEmail: r.primaryEmail ?? null,
primaryPhone: r.primaryPhone ?? null,
createdAt: r.createdAt.toISOString(),
})),
total,
capHit: total > REPORT_ROW_CAP,
};
}
export interface BerthReportRow {
id: string;
mooringNumber: string;
area: string | null;
status: string;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
price: string | null;
priceCurrency: string;
tenureType: string;
}
export interface BerthReportData {
rows: BerthReportRow[];
total: number;
capHit: boolean;
}
export async function resolveBerthReportData(
portId: string,
filters: { includeArchived?: boolean } = {},
): Promise<BerthReportData> {
const whereParts: SQL[] = [eq(berths.portId, portId)];
if (!filters.includeArchived) whereParts.push(isNull(berths.archivedAt));
const whereClause = whereParts.length > 1 ? and(...whereParts) : whereParts[0];
const countRows = await db
.select({ count: sql<number>`count(*)::int` })
.from(berths)
.where(whereClause);
const total = Number(countRows[0]?.count ?? 0);
const rows = await db
.select({
id: berths.id,
mooringNumber: berths.mooringNumber,
area: berths.area,
status: berths.status,
lengthFt: berths.lengthFt,
widthFt: berths.widthFt,
draftFt: berths.draftFt,
price: berths.price,
priceCurrency: berths.priceCurrency,
tenureType: berths.tenureType,
})
.from(berths)
.where(whereClause)
.orderBy(berths.mooringNumber)
.limit(REPORT_ROW_CAP);
return {
rows,
total,
capHit: total > REPORT_ROW_CAP,
};
}
export interface InterestReportRow {
id: string;
clientName: string | null;
primaryMooring: string | null;
pipelineStage: string;
source: string | null;
outcome: string | null;
createdAt: string;
}
export interface InterestReportData {
rows: InterestReportRow[];
total: number;
capHit: boolean;
}
export async function resolveInterestReportData(
portId: string,
filters: { includeArchived?: boolean } = {},
): Promise<InterestReportData> {
const whereParts: SQL[] = [eq(interests.portId, portId)];
if (!filters.includeArchived) whereParts.push(isNull(interests.archivedAt));
const whereClause = whereParts.length > 1 ? and(...whereParts) : whereParts[0];
const countRows = await db
.select({ count: sql<number>`count(*)::int` })
.from(interests)
.where(whereClause);
const total = Number(countRows[0]?.count ?? 0);
// Join client (one-to-one) + primary berth (one-to-one via the
// `is_primary=true` row). Keep the join LEFT so interests without
// a primary berth still render — those are the early-stage deals
// that haven't been pitched a specific mooring yet.
const rows = await db
.select({
id: interests.id,
clientName: clients.fullName,
primaryMooring: berths.mooringNumber,
pipelineStage: interests.pipelineStage,
source: interests.source,
outcome: interests.outcome,
createdAt: interests.createdAt,
})
.from(interests)
.leftJoin(clients, eq(clients.id, interests.clientId))
.leftJoin(
interestBerths,
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
)
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
.where(whereClause)
.orderBy(desc(interests.updatedAt))
.limit(REPORT_ROW_CAP);
return {
rows: rows.map((r) => ({
id: r.id,
clientName: r.clientName,
primaryMooring: r.primaryMooring,
pipelineStage: r.pipelineStage,
source: r.source,
outcome: r.outcome,
createdAt: r.createdAt.toISOString(),
})),
total,
capHit: total > REPORT_ROW_CAP,
};
}