459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
|
|
/**
|
||
|
|
* 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),
|
||
|
|
})),
|
||
|
|
},
|
||
|
|
],
|
||
|
|
};
|
||
|
|
}
|