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>
240 lines
7.0 KiB
TypeScript
240 lines
7.0 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|