feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports

End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1
in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial)
remain deferred per the gap audit at the bottom of that doc.

Highlights:
- Sales performance report: 7 KPI tiles, pipeline funnel + stage
  velocity + win-rate-over-time + source conversion + rep leaderboard
  charts, deal-heat section, 5 detail tables, stage / lead-cat /
  outcome filters.
- Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy
  churn, tenure histogram, signing box plot, occupancy by area, docs
  in pipeline), 4 tables. Module-OFF banner when tenancies disabled.
- Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths,
  tenancies), column-whitelist composer, date filter, CSV download,
  save-as-template. Registry-only extension path for the remaining 6
  entities documented at src/lib/reports/custom/registry.ts.
- Templates: load / modify / save / save-as on Sales / Operational /
  Custom. ?templateId= URL deep-link hydration via useRef guard.
  Active-template badge clears when the user drives view-state via
  wrapped setters; raw setters used on template apply so the badge
  survives.
- Scheduled runs: BullMQ poll fires due schedules, mints report_runs,
  renders, optionally emails. Recipients optional (zero-recipient
  schedules archive without sending). PDF-only output for v1.
  Schedule dialog re-mounts via key prop on schedule.id transitions
  to avoid setState-in-effect reset patterns.
- Server-side PDF endpoint + shared payload renderer
  (lib/pdf/reports/payload-report.tsx) so client + scheduler share
  one rendering path.
- Shared currency formatter (lib/reports/format-currency.ts)
  consolidates 5 duplicated formatMoney helpers; fixes hardcoded
  'USD' in detail tables; pre-formats money rows so PDF export
  (which strips column.format callbacks at the JSON boundary)
  renders consistently with CSV / XLSX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 22:41:53 +02:00
parent 909dd44605
commit 3bdf59e917
41 changed files with 10704 additions and 203 deletions

View File

@@ -46,6 +46,16 @@ import { ActivityReportPdf } from '@/lib/pdf/templates/reports/activity-report';
import { OccupancyReportPdf } from '@/lib/pdf/templates/reports/occupancy-report';
import { PipelineReportPdf } from '@/lib/pdf/templates/reports/pipeline-report';
import { RevenueReportPdf } from '@/lib/pdf/templates/reports/revenue-report';
import { PayloadReportDocument } from '@/lib/pdf/reports/payload-report';
import { absolutizeBrandingUrl } from '@/lib/branding/url';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import {
buildSalesReportPayload,
buildOperationalReportPayload,
} from '@/lib/services/reports/build-payload';
import { renderToBuffer } from '@react-pdf/renderer';
import { createElement } from 'react';
import type { ReportPayload } from '@/lib/reports/types';
interface RenderCtx {
portName: string;
@@ -190,6 +200,14 @@ export async function renderReportRun(reportRunId: string): Promise<ReportRun> {
let putStoragePath: string | null = null;
try {
// Standalone report kinds (sales, operational) take a different
// render path: they build a generic ReportPayload from saved-template
// config + live data, then feed it through PayloadReportDocument.
// The legacy 4 kinds still flow through REPORT_RENDER_MAP below.
if (run.kind === 'sales' || run.kind === 'operational') {
return await renderStandaloneReportRun(run);
}
const renderer = REPORT_RENDER_MAP[run.kind];
if (!renderer) {
throw new CodedError('VALIDATION_ERROR', {
@@ -361,3 +379,104 @@ export async function emailReportRun(reportRunId: string): Promise<void> {
emailedAt: new Date(),
});
}
/**
* Render path for the standalone Sales / Operational reports. Builds the
* shared `ReportPayload` from the saved-template config + live data, then
* routes through `PayloadReportDocument` — same path the interactive
* Export PDF button uses. Output format is PDF; CSV/XLSX for scheduled
* runs is not yet wired (use the interactive Export for those formats).
*/
async function renderStandaloneReportRun(run: ReportRun): Promise<ReportRun> {
let putStoragePath: string | null = null;
try {
const port = await db.query.ports.findFirst({ where: eq(ports.id, run.portId) });
if (!port) {
throw new Error(`Cannot render report ${run.id}: port ${run.portId} not found`);
}
let payload: ReportPayload;
if (run.kind === 'sales') {
payload = await buildSalesReportPayload(
run.portId,
run.config as Parameters<typeof buildSalesReportPayload>[1],
);
} else {
payload = await buildOperationalReportPayload(
run.portId,
run.config as Parameters<typeof buildOperationalReportPayload>[1],
);
}
// CSV / XLSX rendering on the worker is deferred — PDF only for v1.
// The interactive Export button covers CSV + XLSX client-side.
if (run.outputFormat !== 'pdf') {
throw new CodedError('VALIDATION_ERROR', {
internalMessage: `Scheduled ${run.kind} reports currently support PDF only (got ${run.outputFormat}).`,
});
}
const cfg = await getPortBrandingConfig(run.portId);
const branding = {
logoUrl: absolutizeBrandingUrl(cfg.logoUrl),
primaryColor: cfg.primaryColor,
portName: port.name,
};
const generatedAt = new Date().toISOString();
const element = createElement(PayloadReportDocument, {
payload,
branding,
generatedAt,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bytes = (await renderToBuffer(element as any)) as Buffer;
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'reports', run.id, fileId, 'pdf');
const backend = await getStorageBackend();
await backend.put(storagePath, bytes, {
contentType: 'application/pdf',
sizeBytes: bytes.length,
});
putStoragePath = storagePath;
await db.insert(files).values({
id: fileId,
portId: run.portId,
filename: `${run.kind}-${run.id.slice(0, 8)}.pdf`,
originalName: `${run.kind}-report.pdf`,
mimeType: 'application/pdf',
sizeBytes: String(bytes.length),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'misc',
uploadedBy: run.triggeredByUserId ?? 'system',
});
const updated = await updateReportRunStatus(run.id, run.portId, {
status: 'complete',
storageKey: fileId,
sizeBytes: bytes.length,
});
putStoragePath = null;
return updated;
} catch (err) {
logger.error({ err, reportRunId: run.id }, 'renderStandaloneReportRun failed');
await updateReportRunStatus(run.id, run.portId, {
status: 'failed',
errorMessage: err instanceof Error ? err.message : String(err),
}).catch(() => undefined);
if (putStoragePath) {
try {
await (await getStorageBackend()).delete(putStoragePath);
} catch (compErr) {
logger.error(
{ compErr, putStoragePath },
'Compensating storage.delete failed after render error',
);
}
}
throw err;
}
}

View File

@@ -0,0 +1,458 @@
/**
* Server-side payload builders for the standalone Sales + Operational
* reports. The interactive Export button builds the same payload in the
* browser via the report client's local state — but scheduled runs
* execute in a worker context with no browser state, so we replicate
* the same shape from saved-template configs here.
*
* Output is a `ReportPayload` ready to feed `PayloadReportDocument`
* (PDF) or any other format-agnostic exporter.
*/
import { STAGE_LABELS, OUTCOME_LABELS, type PipelineStage } from '@/lib/constants';
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
import { formatMoney } from '@/lib/reports/format-currency';
import type { ReportPayload } from '@/lib/reports/types';
import {
getSalesKpis,
getPipelineFunnel,
getStageVelocity,
getWinRateOverTime,
getSourceConversion,
getRepLeaderboard,
getDealHeat,
getRepPerformanceDetail,
getStalledDeals,
getClosingThisMonth,
getRecentWins,
getLostReasonBreakdown,
type SalesFilters,
} from '@/lib/services/reports/sales.service';
import {
getOperationalKpis,
getOccupancyByArea,
getTenanciesEndingSoon,
getVacantBerths,
getStuckSigning,
getHighestValueVacant,
} from '@/lib/services/reports/operational.service';
/** Shape of a stored template `config` for the Sales report. */
interface SalesTemplateConfig {
kind: 'sales';
range?: DateRange;
filters?: {
stage?: string[];
leadCategory?: string[];
outcome?: string[];
};
}
/** Shape of a stored template `config` for the Operational report. */
interface OperationalTemplateConfig {
kind: 'operational';
range?: DateRange;
statusMixMode?: 'absolute' | 'proportional';
}
export async function buildSalesReportPayload(
portId: string,
config: SalesTemplateConfig,
): Promise<ReportPayload> {
const range = config.range ?? '30d';
const bounds = rangeToBounds(range);
const filters: SalesFilters | undefined = config.filters
? {
stages: config.filters.stage as PipelineStage[] | undefined,
leadCategories: config.filters.leadCategory,
outcomes: config.filters.outcome,
}
: undefined;
const [
kpis,
funnel,
stageVelocity,
winRateOverTime,
sourceConversion,
repLeaderboard,
dealHeat,
stalledDeals,
closingThisMonth,
recentWins,
lostReasonBreakdown,
] = await Promise.all([
getSalesKpis(portId, bounds),
getPipelineFunnel(portId),
getStageVelocity(portId),
getWinRateOverTime(portId, bounds),
getSourceConversion(portId),
getRepLeaderboard(portId, bounds),
getDealHeat(portId),
getStalledDeals(portId, filters),
getClosingThisMonth(portId, filters),
getRecentWins(portId, filters),
getLostReasonBreakdown(portId, bounds, filters),
]);
// RepPerformanceDetail is unused in the scheduled-output payload —
// the leaderboard table covers the same ground; adding it on a PDF
// page just duplicates the data.
void getRepPerformanceDetail;
// All money values returned by the sales service are already in the
// port's reporting currency (service converts on read). Money rows
// are pre-formatted into strings below so the column emits a ready-
// to-render value regardless of whether the downstream renderer keeps
// the column.format callback (XLSX / on-page CSV) or drops it (server
// PDF over a JSON boundary).
const portCurrency = kpis.pipelineValueCurrency;
const fmtAmount = (v: number | null | undefined): string =>
v === null || v === undefined ? '—' : formatMoney(v, portCurrency);
return {
title: 'Sales performance',
description: 'Rep performance, win rates, pipeline value, stalled deals, deal heat.',
filenameSlug: 'sales-performance',
range: bounds,
kpis: [
{ label: 'Active interests', value: kpis.activeInterests },
{ label: 'Won in period', value: kpis.wonInWindow },
{
label: 'Lost in period',
value: kpis.lostInWindow,
hint: kpis.lossBreakdown
.map((b) => `${b.count} ${b.outcome.replace(/^lost_/, '')}`)
.join(', '),
},
{
label: 'Win rate',
value: kpis.winRate === null ? '—' : `${(kpis.winRate * 100).toFixed(1)}%`,
},
{
label: 'Pipeline value',
value: formatMoney(kpis.pipelineValue, kpis.pipelineValueCurrency),
hint: `${kpis.pipelineValueTotalActiveCount} active interests`,
},
{
label: 'Avg time to close',
value:
kpis.medianTimeToCloseDays === null
? '—'
: `${kpis.medianTimeToCloseDays.toFixed(1)} days`,
hint:
kpis.medianTimeToCloseDays !== null
? `based on ${kpis.timeToCloseSampleSize} won deals`
: 'need ≥3 won deals',
},
{
label: 'New leads',
value: kpis.newLeadsInWindow,
hint: kpis.newLeadsBySource.map((s) => `${s.count} ${s.source}`).join(', '),
},
],
sections: [
{
title: 'Pipeline funnel',
columns: [
{ key: 'stage', label: 'Stage' },
{ key: 'count', label: 'Active deals', align: 'right' },
{
key: 'dropoffFromPrior',
label: 'Drop-off vs prior',
align: 'right',
format: (v) =>
v === null || v === undefined ? '' : `${((v as number) * 100).toFixed(0)}%`,
},
],
rows: funnel.map((r) => ({
stage: STAGE_LABELS[r.stage],
count: r.count,
dropoffFromPrior: r.dropoffFromPrior,
})),
},
{
title: 'Stage velocity',
columns: [
{ key: 'stage', label: 'Stage' },
{
key: 'medianDays',
label: 'Median days in stage',
align: 'right',
format: (v) => (v === null || v === undefined ? '—' : (v as number).toFixed(1)),
},
{
key: 'p90Days',
label: 'p90 days',
align: 'right',
format: (v) => (v === null || v === undefined ? '—' : (v as number).toFixed(1)),
},
{ key: 'transitions', label: 'Sample size', align: 'right' },
],
rows: stageVelocity.map((r) => ({
stage: STAGE_LABELS[r.stage],
medianDays: r.medianDays,
p90Days: r.p90Days,
transitions: r.transitions,
})),
},
{
title: `Win rate over time (${winRateOverTime.granularity})`,
columns: [
{ key: 'bucket', label: 'Period' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{
key: 'winRate',
label: 'Win rate',
align: 'right',
format: (v) =>
v === null || v === undefined ? '—' : `${((v as number) * 100).toFixed(1)}%`,
},
],
rows: winRateOverTime.points.map((p) => ({ ...p })),
},
{
title: 'Source → win conversion',
columns: [
{ key: 'source', label: 'Source' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{ key: 'cancelled', label: 'Cancelled', align: 'right' },
{ key: 'in_flight', label: 'In flight', align: 'right' },
{ key: 'total', label: 'Total', align: 'right' },
],
rows: sourceConversion.map((r) => ({
source: r.source,
won: r.counts.won,
lost: r.counts.lost,
cancelled: r.counts.cancelled,
in_flight: r.counts.in_flight,
total: r.total,
})),
},
{
title: 'Rep leaderboard',
columns: [
{ key: 'displayName', label: 'Rep' },
{ key: 'newDeals', label: 'New', align: 'right' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{ key: 'inFlight', label: 'In flight', align: 'right' },
{ key: 'pipelineValue', label: 'Pipeline value', align: 'right' },
{
key: 'winRate',
label: 'Win rate',
align: 'right',
format: (v) =>
v === null || v === undefined ? '' : `${((v as number) * 100).toFixed(0)}%`,
},
],
rows: repLeaderboard.map((r) => ({
...r,
pipelineValue: formatMoney(r.pipelineValue, r.pipelineValueCurrency),
})),
},
{
title: 'Deal heat — hottest deals',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'mooringNumber', label: 'Berth' },
{
key: 'stage',
label: 'Stage',
format: (v) => STAGE_LABELS[v as PipelineStage] ?? '',
},
{ key: 'bucket', label: 'Heat' },
{
key: 'daysSinceLastContact',
label: 'Days since contact',
align: 'right',
format: (v) => (v === null || v === undefined ? 'never' : String(v)),
},
{ key: 'pipelineValue', label: 'Value', align: 'right' },
],
rows: dealHeat.topDeals.map((d) => ({
...d,
pipelineValue: formatMoney(d.pipelineValue, d.pipelineValueCurrency),
})),
},
{
title: 'Stalled deals',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'primaryBerth', label: 'Berth' },
{ key: 'stage', label: 'Stage', format: (v) => STAGE_LABELS[v as PipelineStage] ?? '' },
{ key: 'rep', label: 'Rep' },
{ key: 'daysSinceLastContact', label: 'Days since contact', align: 'right' },
{ key: 'stageValue', label: 'Value', align: 'right' },
],
rows: stalledDeals.map((r) => ({
...r,
stageValue: fmtAmount(r.stageValue),
})),
},
{
title: 'Closing this month',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'primaryBerth', label: 'Berth' },
{ key: 'stage', label: 'Stage', format: (v) => STAGE_LABELS[v as PipelineStage] ?? '' },
{ key: 'rep', label: 'Rep' },
{ key: 'daysInStage', label: 'Days in stage', align: 'right' },
{ key: 'stageValue', label: 'Value', align: 'right' },
],
rows: closingThisMonth.map((r) => ({
...r,
stageValue: fmtAmount(r.stageValue),
})),
},
{
title: 'Recent wins',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'primaryBerth', label: 'Berth' },
{ key: 'rep', label: 'Rep' },
{ key: 'outcomeAt', label: 'Closed at', format: (v) => String(v).slice(0, 10) },
{ key: 'finalValue', label: 'Value', align: 'right' },
{ key: 'daysToClose', label: 'Days to close', align: 'right' },
],
rows: recentWins.map((r) => ({
...r,
finalValue: formatMoney(r.finalValue, r.currency),
})),
},
{
title: 'Lost-reason breakdown',
columns: [
{
key: 'outcome',
label: 'Outcome',
format: (v) => OUTCOME_LABELS[v as string] ?? String(v),
},
{ key: 'count', label: 'Count', align: 'right' },
{ key: 'totalValueLost', label: 'Value lost', align: 'right' },
{
key: 'avgDaysFromFirstContactToLoss',
label: 'Avg days to loss',
align: 'right',
format: (v) => (v === null || v === undefined ? '—' : (v as number).toFixed(1)),
},
],
rows: lostReasonBreakdown.map((r) => ({
...r,
totalValueLost: formatMoney(r.totalValueLost, r.currency),
})),
},
],
};
}
export async function buildOperationalReportPayload(
portId: string,
config: OperationalTemplateConfig,
): Promise<ReportPayload> {
const range = config.range ?? '30d';
const bounds = rangeToBounds(range);
const [kpis, occupancyByArea, endingSoon, vacantBerths, stuckSigning, highestValueVacant] =
await Promise.all([
getOperationalKpis(portId, bounds),
getOccupancyByArea(portId),
getTenanciesEndingSoon(portId),
getVacantBerths(portId),
getStuckSigning(portId),
getHighestValueVacant(portId),
]);
const tenanciesOn = kpis.tenanciesModuleEnabled;
return {
title: 'Operational',
description:
'Berth utilisation, tenancy lifecycle, signing turnaround, and operational bottlenecks.',
filenameSlug: 'operational',
range: bounds,
kpis: [
{ label: 'Total berths', value: kpis.totalBerths },
{ label: 'Sold %', value: `${kpis.soldPct.toFixed(1)}%` },
{ label: 'Under offer %', value: `${kpis.underOfferPct.toFixed(1)}%` },
{
label: 'Active tenancies',
value: kpis.activeTenancies ?? '—',
hint: tenanciesOn ? undefined : 'Tenancies module disabled',
},
{
label: 'Avg tenancy length',
value:
kpis.avgTenancyLengthYears !== null
? `${kpis.avgTenancyLengthYears.toFixed(1)} years`
: '—',
},
{ label: 'Berths in conflict', value: kpis.berthsInConflict },
],
sections: [
{
title: 'Occupancy by area',
columns: [
{ key: 'area', label: 'Area' },
{ key: 'available', label: 'Available', align: 'right' },
{ key: 'underOffer', label: 'Under offer', align: 'right' },
{ key: 'sold', label: 'Sold', align: 'right' },
{ key: 'total', label: 'Total', align: 'right' },
],
rows: occupancyByArea.map((r) => ({ ...r })),
},
{
title: 'Tenancies ending soon (next 6 months)',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'primaryBerth', label: 'Berth' },
{ key: 'tenureType', label: 'Tenure type' },
{ key: 'endDate', label: 'End date', format: (v) => String(v).slice(0, 10) },
{ key: 'daysUntilEnd', label: 'Days until end', align: 'right' },
],
rows: endingSoon.map((r) => ({ ...r })),
},
{
title: 'Vacant berths (>60 days)',
columns: [
{ key: 'mooring', label: 'Mooring' },
{ key: 'area', label: 'Area' },
{ key: 'dimensions', label: 'Dimensions' },
{ key: 'price', label: 'Price', align: 'right' },
{ key: 'daysAvailable', label: 'Days available', align: 'right' },
],
// Pre-format `price` per row using each row's currency so the
// column emits a single ready-to-render string (the shared
// format callback can't see the row).
rows: vacantBerths.map((r) => ({
...r,
price: r.price !== null ? formatMoney(r.price, r.currency) : '—',
})),
},
{
title: 'Stuck signing',
columns: [
{ key: 'documentType', label: 'Document type' },
{ key: 'title', label: 'Title' },
{ key: 'clientName', label: 'Client' },
{ key: 'sentAt', label: 'Sent at', format: (v) => String(v).slice(0, 10) },
{ key: 'daysOutstanding', label: 'Days outstanding', align: 'right' },
],
rows: stuckSigning.map((r) => ({ ...r })),
},
{
title: 'Highest-value vacant berths',
columns: [
{ key: 'mooring', label: 'Mooring' },
{ key: 'price', label: 'Price', align: 'right' },
],
rows: highestValueVacant.map((r) => ({
...r,
price: formatMoney(r.price, r.currency),
})),
},
],
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff