/** * 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 { 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 { 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), })), }, ], }; }