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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
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