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:
458
src/lib/services/reports/build-payload.ts
Normal file
458
src/lib/services/reports/build-payload.ts
Normal 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),
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
1023
src/lib/services/reports/operational.service.ts
Normal file
1023
src/lib/services/reports/operational.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
1617
src/lib/services/reports/sales.service.ts
Normal file
1617
src/lib/services/reports/sales.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user