Files
pn-new-crm/src/lib/services/list-report-data.service.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

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,
};
}