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:
266
src/lib/pdf/reports/payload-report.tsx
Normal file
266
src/lib/pdf/reports/payload-report.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { Document, Image, Page, StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||
|
||||
import type { ReportPayload, ReportSection } from '@/lib/reports/types';
|
||||
import type { ReportBranding } from './types';
|
||||
|
||||
/**
|
||||
* Generic payload-driven PDF document. Takes a `ReportPayload` from any
|
||||
* report (Sales / Operational / Custom / future) and renders it in a
|
||||
* branded shell — cover with port logo + title + period + generated-at
|
||||
* stamp, KPI grid below, then one section per ReportSection with a
|
||||
* tabular layout.
|
||||
*
|
||||
* This is the "v2" PDF surface; the legacy per-kind documents
|
||||
* (DashboardReport, ClientListReport, etc.) under this folder remain
|
||||
* for the original `/api/v1/reports/[id]` route and aren't touched.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
payload: ReportPayload;
|
||||
branding: ReportBranding;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export function PayloadReportDocument({ payload, branding, generatedAt }: Props) {
|
||||
const styles = makeStyles(branding);
|
||||
return (
|
||||
<Document
|
||||
title={payload.title}
|
||||
author={branding.portName}
|
||||
subject={payload.description ?? `${branding.portName} report`}
|
||||
creator="Port Nimara CRM"
|
||||
producer="Port Nimara CRM"
|
||||
>
|
||||
<Page size="A4" style={styles.page} wrap>
|
||||
{/* Cover header — logo + title + period */}
|
||||
<View style={styles.coverHeader}>
|
||||
{branding.logoUrl ? (
|
||||
<Image src={branding.logoUrl} style={styles.logo} cache />
|
||||
) : (
|
||||
<View style={{ width: 36, height: 36 }} />
|
||||
)}
|
||||
<View style={styles.coverHeaderText}>
|
||||
<Text style={styles.title}>{payload.title}</Text>
|
||||
{payload.description ? (
|
||||
<Text style={styles.subtitle}>{payload.description}</Text>
|
||||
) : null}
|
||||
<Text style={styles.period}>
|
||||
{formatDate(payload.range.from)} – {formatDate(payload.range.to)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* KPI grid — three per row */}
|
||||
{payload.kpis.length > 0 ? (
|
||||
<View style={styles.kpiGrid}>
|
||||
{payload.kpis.map((kpi, i) => (
|
||||
<View key={i} style={styles.kpiTile}>
|
||||
<Text style={styles.kpiLabel}>{String(kpi.label).toUpperCase()}</Text>
|
||||
<Text style={styles.kpiValue}>{String(kpi.value)}</Text>
|
||||
{kpi.hint ? <Text style={styles.kpiHint}>{kpi.hint}</Text> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Sections */}
|
||||
{payload.sections.map((section, i) => (
|
||||
<SectionBlock key={i} section={section} styles={styles} />
|
||||
))}
|
||||
|
||||
{/* Footer (fixed across pages) */}
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerLeft}>{branding.portName}</Text>
|
||||
<Text style={styles.footerRight}>Generated {formatDateTime(generatedAt)}</Text>
|
||||
<Text
|
||||
style={styles.footerCenter}
|
||||
render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`}
|
||||
fixed
|
||||
/>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionBlock({
|
||||
section,
|
||||
styles,
|
||||
}: {
|
||||
section: ReportSection;
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.section} wrap>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
{section.rows.length === 0 ? (
|
||||
<Text style={styles.empty}>No data in this section.</Text>
|
||||
) : (
|
||||
<View style={styles.table}>
|
||||
{/* Header */}
|
||||
<View style={[styles.tableRow, styles.tableHeader]}>
|
||||
{section.columns.map((col, i) => {
|
||||
const cellStyle =
|
||||
col.align === 'right'
|
||||
? [styles.tableCell, styles.tableHeaderCell, styles.tableCellRight]
|
||||
: [styles.tableCell, styles.tableHeaderCell];
|
||||
return (
|
||||
<Text key={i} style={cellStyle}>
|
||||
{col.label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{/* Body */}
|
||||
{section.rows.map((row, ri) => {
|
||||
const rowStyle =
|
||||
ri % 2 === 1 ? [styles.tableRow, styles.tableRowZebra] : styles.tableRow;
|
||||
return (
|
||||
<View key={ri} style={rowStyle}>
|
||||
{section.columns.map((col, ci) => {
|
||||
const v = row[col.key];
|
||||
const text = col.format ? col.format(v) : formatPlain(v);
|
||||
const cellStyle =
|
||||
col.align === 'right'
|
||||
? [styles.tableCell, styles.tableCellRight]
|
||||
: styles.tableCell;
|
||||
return (
|
||||
<Text key={ci} style={cellStyle}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPlain(v: unknown): string {
|
||||
if (v === null || v === undefined) return '';
|
||||
if (v instanceof Date) return v.toISOString().slice(0, 10);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return iso.replace('T', ' ').slice(0, 16) + ' UTC';
|
||||
}
|
||||
|
||||
function makeStyles(branding: ReportBranding) {
|
||||
return StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 40,
|
||||
paddingBottom: 56,
|
||||
paddingHorizontal: 36,
|
||||
fontSize: 9.5,
|
||||
fontFamily: 'Helvetica',
|
||||
color: '#1e2844',
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
coverHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: branding.primaryColor,
|
||||
paddingBottom: 14,
|
||||
marginBottom: 18,
|
||||
},
|
||||
logo: { width: 36, height: 36, objectFit: 'contain' },
|
||||
coverHeaderText: { flexDirection: 'column', flex: 1 },
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: branding.primaryColor,
|
||||
marginBottom: 3,
|
||||
},
|
||||
subtitle: { fontSize: 10, color: '#475569', marginBottom: 4 },
|
||||
period: { fontSize: 9, color: '#64748b', fontFamily: 'Helvetica' },
|
||||
kpiGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginBottom: 18,
|
||||
},
|
||||
kpiTile: {
|
||||
width: '32%',
|
||||
borderWidth: 0.5,
|
||||
borderColor: '#e2e8f0',
|
||||
borderRadius: 3,
|
||||
padding: 8,
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
kpiLabel: {
|
||||
fontSize: 7,
|
||||
color: '#64748b',
|
||||
letterSpacing: 1,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
marginBottom: 3,
|
||||
},
|
||||
kpiValue: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: '#0f172a',
|
||||
marginBottom: 2,
|
||||
},
|
||||
kpiHint: { fontSize: 7.5, color: '#94a3b8' },
|
||||
section: { marginBottom: 16 },
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: branding.primaryColor,
|
||||
marginBottom: 6,
|
||||
paddingBottom: 3,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: '#cbd5e1',
|
||||
},
|
||||
empty: { fontSize: 9, color: '#94a3b8', fontStyle: 'italic', paddingVertical: 6 },
|
||||
table: { borderTopWidth: 0.5, borderTopColor: '#e2e8f0' },
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: '#e2e8f0',
|
||||
paddingVertical: 4,
|
||||
},
|
||||
tableRowZebra: { backgroundColor: '#f8fafc' },
|
||||
tableHeader: { backgroundColor: branding.primaryColor },
|
||||
tableHeaderCell: { color: '#ffffff', fontFamily: 'Helvetica-Bold', fontSize: 8 },
|
||||
tableCell: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 6,
|
||||
fontSize: 8.5,
|
||||
color: '#1e293b',
|
||||
},
|
||||
tableCellRight: { textAlign: 'right' },
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 36,
|
||||
right: 36,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: '#e2e8f0',
|
||||
paddingTop: 6,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
footerLeft: { fontSize: 8, color: '#64748b' },
|
||||
footerRight: { fontSize: 8, color: '#94a3b8' },
|
||||
footerCenter: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 6,
|
||||
textAlign: 'center',
|
||||
fontSize: 8,
|
||||
color: '#64748b',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -143,9 +143,20 @@ export const reportsWorker = new Worker(
|
||||
.where(eq(reportSchedules.id, schedule.id));
|
||||
|
||||
try {
|
||||
const { REPORT_KINDS } = await import('@/lib/validators/reports');
|
||||
const kindNarrowed = (REPORT_KINDS as readonly string[]).includes(template.kind)
|
||||
? (template.kind as (typeof REPORT_KINDS)[number])
|
||||
: null;
|
||||
if (!kindNarrowed) {
|
||||
logger.warn(
|
||||
{ scheduleId: schedule.id, templateId: schedule.templateId, kind: template.kind },
|
||||
'Skipping schedule: template kind not in REPORT_KINDS allowlist',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const run = await createReportRun(
|
||||
{
|
||||
kind: template.kind as 'dashboard' | 'clients' | 'berths' | 'interests',
|
||||
kind: kindNarrowed,
|
||||
config: template.config,
|
||||
outputFormat: schedule.outputFormat as 'pdf' | 'csv' | 'png',
|
||||
templateId: template.id,
|
||||
@@ -183,15 +194,38 @@ export const reportsWorker = new Worker(
|
||||
const { renderReportRun } = await import('@/lib/services/report-render.service');
|
||||
const run = await renderReportRun(reportRunId);
|
||||
|
||||
// Schedule-driven runs auto-cascade into the email job. User-
|
||||
// triggered runs are inert — the rep downloads via the UI.
|
||||
if (run.triggeredBy === 'schedule' && run.status === 'complete') {
|
||||
const { getQueue: enqueue } = await import('@/lib/queue');
|
||||
await enqueue('reports').add(
|
||||
'report-run-email',
|
||||
{ reportRunId: run.id },
|
||||
{ jobId: `report-run-email:${run.id}` },
|
||||
);
|
||||
// Schedule-driven runs auto-cascade into the email job ONLY when
|
||||
// the schedule has recipients configured. Email is optional per
|
||||
// locked decision (2026-05-27): an admin can schedule a run that
|
||||
// just appears in /reports/runs without forcing a blast.
|
||||
// User-triggered runs are inert — the rep downloads via the UI.
|
||||
if (
|
||||
run.triggeredBy === 'schedule' &&
|
||||
run.status === 'complete' &&
|
||||
run.scheduleId !== null
|
||||
) {
|
||||
const { db: dbForSched } = await import('@/lib/db');
|
||||
const { reportSchedules: schedTbl } = await import('@/lib/db/schema/reports');
|
||||
const { eq: eqOp } = await import('drizzle-orm');
|
||||
const sched = await dbForSched.query.reportSchedules.findFirst({
|
||||
where: eqOp(schedTbl.id, run.scheduleId),
|
||||
columns: { recipients: true },
|
||||
});
|
||||
const hasRecipients =
|
||||
Array.isArray(sched?.recipients) && (sched?.recipients?.length ?? 0) > 0;
|
||||
if (hasRecipients) {
|
||||
const { getQueue: enqueue } = await import('@/lib/queue');
|
||||
await enqueue('reports').add(
|
||||
'report-run-email',
|
||||
{ reportRunId: run.id },
|
||||
{ jobId: `report-run-email:${run.id}` },
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
{ reportRunId: run.id, scheduleId: run.scheduleId },
|
||||
'Schedule has no recipients; skipping email cascade (run archived only)',
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
303
src/lib/reports/custom/registry.ts
Normal file
303
src/lib/reports/custom/registry.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Custom-report entity registry.
|
||||
*
|
||||
* The custom builder is the catch-all for slices the four canonical
|
||||
* reports don't cover — pick an entity, pick columns, optionally
|
||||
* filter by date, get a CSV. v1 ships with the four highest-value
|
||||
* entities (clients, interests, berths, tenancies); the remaining six
|
||||
* from the launch-readiness scope (companies, yachts, invoices,
|
||||
* payments, deals, sends) layer in as their schemas are wired.
|
||||
*
|
||||
* Each entity defines:
|
||||
* - `columns`: an allowlist of column keys + human labels + a
|
||||
* resolver that extracts the value from a fetched row. The
|
||||
* allowlist matters: it gates which fields a rep can pull into a
|
||||
* CSV, so PII columns can be opt-in per role later.
|
||||
* - `runQuery`: a Drizzle select that joins whatever the columns
|
||||
* need, applies the port filter + optional date range, and
|
||||
* returns raw rows.
|
||||
*
|
||||
* Adding a new entity:
|
||||
* 1. Append it to ENTITY_KEYS.
|
||||
* 2. Add a CustomEntityDefinition entry to ENTITY_REGISTRY.
|
||||
* 3. Update the UI's entity-picker (it reads ENTITY_REGISTRY directly).
|
||||
*/
|
||||
|
||||
import { and, asc, desc, eq, gte, lte, sql, type SQL } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
import { berthTenancies as tenancies } from '@/lib/db/schema/tenancies';
|
||||
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
|
||||
|
||||
export const ENTITY_KEYS = ['clients', 'interests', 'berths', 'tenancies'] as const;
|
||||
export type EntityKey = (typeof ENTITY_KEYS)[number];
|
||||
|
||||
export interface CustomFilter {
|
||||
/** ISO 8601 — inclusive lower bound on the entity's "date" column
|
||||
* (createdAt or equivalent — see entity definition). */
|
||||
from?: Date;
|
||||
/** ISO 8601 — inclusive upper bound. */
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
export interface ColumnDefinition {
|
||||
/** Stable key. Persisted in saved-template configs. */
|
||||
key: string;
|
||||
/** Human-readable column header used in CSV/PDF output + the UI
|
||||
* multi-select. */
|
||||
label: string;
|
||||
/** Default selection in the UI. Reps can uncheck. */
|
||||
defaultSelected?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomEntityDefinition {
|
||||
key: EntityKey;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Friendly name for the date filter — different entities anchor
|
||||
* the date range to different timestamps. */
|
||||
dateAxis: string;
|
||||
columns: ColumnDefinition[];
|
||||
/** Execute the underlying query and return raw rows keyed by column
|
||||
* key. The runner is responsible for the joins + port scoping;
|
||||
* callers only pass which columns they want + the filter. */
|
||||
runQuery: (input: {
|
||||
portId: string;
|
||||
columns: string[];
|
||||
filter: CustomFilter;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function applyDateRange(column: ReturnType<typeof sql<Date>>, filter: CustomFilter): SQL[] {
|
||||
const conds: SQL[] = [];
|
||||
if (filter.from) conds.push(gte(column as never, filter.from));
|
||||
if (filter.to) conds.push(lte(column as never, filter.to));
|
||||
return conds;
|
||||
}
|
||||
|
||||
// ─── Clients ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const CLIENTS_COLUMNS: ColumnDefinition[] = [
|
||||
{ key: 'fullName', label: 'Full name', defaultSelected: true },
|
||||
{ key: 'nationalityIso', label: 'Nationality', defaultSelected: false },
|
||||
{ key: 'preferredLanguage', label: 'Preferred language' },
|
||||
{ key: 'preferredContactMethod', label: 'Preferred contact', defaultSelected: false },
|
||||
{ key: 'source', label: 'Source', defaultSelected: true },
|
||||
{ key: 'createdAt', label: 'Created', defaultSelected: true },
|
||||
{ key: 'archivedAt', label: 'Archived at' },
|
||||
];
|
||||
|
||||
async function runClientsQuery({
|
||||
portId,
|
||||
filter,
|
||||
}: {
|
||||
portId: string;
|
||||
columns: string[];
|
||||
filter: CustomFilter;
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const conds = [eq(clients.portId, portId), ...applyDateRange(clients.createdAt as never, filter)];
|
||||
const rows = await db
|
||||
.select({
|
||||
fullName: clients.fullName,
|
||||
nationalityIso: clients.nationalityIso,
|
||||
preferredLanguage: clients.preferredLanguage,
|
||||
preferredContactMethod: clients.preferredContactMethod,
|
||||
source: clients.source,
|
||||
createdAt: clients.createdAt,
|
||||
archivedAt: clients.archivedAt,
|
||||
})
|
||||
.from(clients)
|
||||
.where(and(...conds))
|
||||
.orderBy(asc(clients.fullName))
|
||||
.limit(10_000);
|
||||
return rows.map((r) => ({ ...r }));
|
||||
}
|
||||
|
||||
// ─── Interests ───────────────────────────────────────────────────────────────
|
||||
|
||||
const INTERESTS_COLUMNS: ColumnDefinition[] = [
|
||||
{ key: 'clientName', label: 'Client', defaultSelected: true },
|
||||
{ key: 'primaryBerth', label: 'Primary berth', defaultSelected: true },
|
||||
{ key: 'pipelineStage', label: 'Stage', defaultSelected: true },
|
||||
{ key: 'leadCategory', label: 'Lead category' },
|
||||
{ key: 'outcome', label: 'Outcome', defaultSelected: true },
|
||||
{ key: 'source', label: 'Source', defaultSelected: false },
|
||||
{ key: 'depositExpectedAmount', label: 'Deposit expected (amt)', defaultSelected: false },
|
||||
{ key: 'depositExpectedCurrency', label: 'Deposit expected (ccy)' },
|
||||
{ key: 'dateFirstContact', label: 'First contact', defaultSelected: false },
|
||||
{ key: 'dateLastContact', label: 'Last contact', defaultSelected: false },
|
||||
{ key: 'createdAt', label: 'Created', defaultSelected: true },
|
||||
];
|
||||
|
||||
async function runInterestsQuery({
|
||||
portId,
|
||||
filter,
|
||||
}: {
|
||||
portId: string;
|
||||
columns: string[];
|
||||
filter: CustomFilter;
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const conds = [
|
||||
eq(interests.portId, portId),
|
||||
...applyDateRange(interests.createdAt as never, filter),
|
||||
];
|
||||
const rows = await db
|
||||
.select({
|
||||
clientName: clients.fullName,
|
||||
primaryBerth: berths.mooringNumber,
|
||||
pipelineStage: interests.pipelineStage,
|
||||
leadCategory: interests.leadCategory,
|
||||
outcome: interests.outcome,
|
||||
source: interests.source,
|
||||
depositExpectedAmount: interests.depositExpectedAmount,
|
||||
depositExpectedCurrency: interests.depositExpectedCurrency,
|
||||
dateFirstContact: interests.dateFirstContact,
|
||||
dateLastContact: interests.dateLastContact,
|
||||
createdAt: interests.createdAt,
|
||||
})
|
||||
.from(interests)
|
||||
.innerJoin(clients, eq(interests.clientId, clients.id))
|
||||
.leftJoin(
|
||||
interestBerths,
|
||||
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
||||
)
|
||||
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
|
||||
.where(and(...conds))
|
||||
.orderBy(desc(interests.createdAt))
|
||||
.limit(10_000);
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
// Re-label stage to the human form so the CSV is readable;
|
||||
// analysts can still join back via the raw enum on display.
|
||||
pipelineStage: r.pipelineStage
|
||||
? (STAGE_LABELS[r.pipelineStage as PipelineStage] ?? r.pipelineStage)
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Berths ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const BERTHS_COLUMNS: ColumnDefinition[] = [
|
||||
{ key: 'mooringNumber', label: 'Mooring', defaultSelected: true },
|
||||
{ key: 'area', label: 'Area' },
|
||||
{ key: 'status', label: 'Status', defaultSelected: true },
|
||||
{ key: 'length', label: 'Length (m)' },
|
||||
{ key: 'width', label: 'Width (m)' },
|
||||
{ key: 'draft', label: 'Draft (m)' },
|
||||
{ key: 'price', label: 'Price', defaultSelected: true },
|
||||
{ key: 'priceCurrency', label: 'Currency' },
|
||||
{ key: 'createdAt', label: 'Created' },
|
||||
];
|
||||
|
||||
async function runBerthsQuery({
|
||||
portId,
|
||||
filter,
|
||||
}: {
|
||||
portId: string;
|
||||
columns: string[];
|
||||
filter: CustomFilter;
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const conds = [eq(berths.portId, portId), ...applyDateRange(berths.createdAt as never, filter)];
|
||||
const rows = await db
|
||||
.select({
|
||||
mooringNumber: berths.mooringNumber,
|
||||
area: berths.area,
|
||||
status: berths.status,
|
||||
length: berths.lengthM,
|
||||
width: berths.widthM,
|
||||
draft: berths.draftM,
|
||||
price: berths.price,
|
||||
priceCurrency: berths.priceCurrency,
|
||||
createdAt: berths.createdAt,
|
||||
})
|
||||
.from(berths)
|
||||
.where(and(...conds))
|
||||
.orderBy(asc(berths.mooringNumber))
|
||||
.limit(10_000);
|
||||
return rows.map((r) => ({ ...r }));
|
||||
}
|
||||
|
||||
// ─── Tenancies ───────────────────────────────────────────────────────────────
|
||||
|
||||
const TENANCIES_COLUMNS: ColumnDefinition[] = [
|
||||
{ key: 'clientName', label: 'Client', defaultSelected: true },
|
||||
{ key: 'mooringNumber', label: 'Berth', defaultSelected: true },
|
||||
{ key: 'tenureType', label: 'Tenure type', defaultSelected: true },
|
||||
{ key: 'startDate', label: 'Start', defaultSelected: true },
|
||||
{ key: 'endDate', label: 'End', defaultSelected: true },
|
||||
{ key: 'status', label: 'Status', defaultSelected: true },
|
||||
{ key: 'createdAt', label: 'Created' },
|
||||
];
|
||||
|
||||
async function runTenanciesQuery({
|
||||
portId,
|
||||
filter,
|
||||
}: {
|
||||
portId: string;
|
||||
columns: string[];
|
||||
filter: CustomFilter;
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const conds = [
|
||||
eq(tenancies.portId, portId),
|
||||
...applyDateRange(tenancies.createdAt as never, filter),
|
||||
];
|
||||
const rows = await db
|
||||
.select({
|
||||
clientName: clients.fullName,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
tenureType: tenancies.tenureType,
|
||||
startDate: tenancies.startDate,
|
||||
endDate: tenancies.endDate,
|
||||
status: tenancies.status,
|
||||
createdAt: tenancies.createdAt,
|
||||
})
|
||||
.from(tenancies)
|
||||
.leftJoin(clients, eq(tenancies.clientId, clients.id))
|
||||
.leftJoin(berths, eq(tenancies.berthId, berths.id))
|
||||
.where(and(...conds))
|
||||
.orderBy(desc(tenancies.startDate))
|
||||
.limit(10_000);
|
||||
return rows.map((r) => ({ ...r }));
|
||||
}
|
||||
|
||||
// ─── Registry ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ENTITY_REGISTRY: Record<EntityKey, CustomEntityDefinition> = {
|
||||
clients: {
|
||||
key: 'clients',
|
||||
label: 'Clients',
|
||||
description: 'People in your CRM: name, source, contact preferences.',
|
||||
dateAxis: 'Created',
|
||||
columns: CLIENTS_COLUMNS,
|
||||
runQuery: runClientsQuery,
|
||||
},
|
||||
interests: {
|
||||
key: 'interests',
|
||||
label: 'Interests / deals',
|
||||
description: 'Sales pipeline: stage, outcome, value, deposit details.',
|
||||
dateAxis: 'Created',
|
||||
columns: INTERESTS_COLUMNS,
|
||||
runQuery: runInterestsQuery,
|
||||
},
|
||||
berths: {
|
||||
key: 'berths',
|
||||
label: 'Berths',
|
||||
description: 'Mooring inventory: dimensions, status, price.',
|
||||
dateAxis: 'Created',
|
||||
columns: BERTHS_COLUMNS,
|
||||
runQuery: runBerthsQuery,
|
||||
},
|
||||
tenancies: {
|
||||
key: 'tenancies',
|
||||
label: 'Tenancies',
|
||||
description: 'Berth leases / annual contracts: dates, tenure type, status.',
|
||||
dateAxis: 'Created',
|
||||
columns: TENANCIES_COLUMNS,
|
||||
runQuery: runTenanciesQuery,
|
||||
},
|
||||
};
|
||||
104
src/lib/reports/exporters/csv.ts
Normal file
104
src/lib/reports/exporters/csv.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import Papa from 'papaparse';
|
||||
|
||||
import type { ExportResult, ReportPayload, ReportSection } from '@/lib/reports/types';
|
||||
|
||||
/**
|
||||
* Serialise a ReportPayload as CSV. The single-file output contains:
|
||||
*
|
||||
* 1. A title row + period row + generated-at row (header)
|
||||
* 2. A "KPIs" section with two columns (label, value)
|
||||
* 3. Each ReportSection's title, header row, data rows
|
||||
* 4. Blank lines between sections so Excel/Numbers can detect them
|
||||
* when "Convert Text to Columns" is run after a paste.
|
||||
*
|
||||
* The output is plain UTF-8 with a leading BOM so Excel correctly
|
||||
* decodes non-ASCII characters (€ symbols, accented names, etc.)
|
||||
* without the user having to manually pick an encoding.
|
||||
*/
|
||||
interface CsvExportOptions {
|
||||
/** Override the auto-derived filename (which is
|
||||
* `${payload.filenameSlug}-${date-range}.csv`). When the user has
|
||||
* given the export a custom title, pass `${slugify(title)}.csv`
|
||||
* here so the filename matches their intent without the verbose
|
||||
* date suffix. */
|
||||
filenameOverride?: string;
|
||||
}
|
||||
|
||||
export function exportReportAsCsv(
|
||||
payload: ReportPayload,
|
||||
options: CsvExportOptions = {},
|
||||
): ExportResult {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header
|
||||
lines.push(`"${escape(payload.title)}"`);
|
||||
lines.push(
|
||||
`"Period","${payload.range.from.toISOString().slice(0, 10)}","to","${payload.range.to
|
||||
.toISOString()
|
||||
.slice(0, 10)}"`,
|
||||
);
|
||||
lines.push(`"Generated","${new Date().toISOString()}"`);
|
||||
lines.push('');
|
||||
|
||||
// KPI section
|
||||
if (payload.kpis.length > 0) {
|
||||
lines.push('"KPIs"');
|
||||
lines.push(
|
||||
Papa.unparse({
|
||||
fields: ['Metric', 'Value', 'Hint'],
|
||||
data: payload.kpis.map((k) => [k.label, formatValue(k.value), k.hint ?? '']),
|
||||
}),
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Per-section blocks
|
||||
for (const section of payload.sections) {
|
||||
lines.push(`"${escape(section.title)}"`);
|
||||
lines.push(sectionToCsv(section));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// BOM + UTF-8 so Excel decodes correctly without prompting.
|
||||
const bom = '';
|
||||
const body = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
|
||||
|
||||
return {
|
||||
filename: options.filenameOverride ?? `${payload.filenameSlug}-${dateSlug(payload.range)}.csv`,
|
||||
mimeType: 'text/csv;charset=utf-8',
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reusable filename derivation so the UI's "filename preview" matches
|
||||
* what the exporter will actually emit. */
|
||||
export function defaultCsvFilename(payload: ReportPayload): string {
|
||||
return `${payload.filenameSlug}-${dateSlug(payload.range)}.csv`;
|
||||
}
|
||||
|
||||
function sectionToCsv(section: ReportSection): string {
|
||||
return Papa.unparse({
|
||||
fields: section.columns.map((c) => c.label),
|
||||
data: section.rows.map((row) =>
|
||||
section.columns.map((c) => {
|
||||
const v = row[c.key];
|
||||
return c.format ? c.format(v) : formatValue(v);
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return '';
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (typeof v === 'number') return String(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function escape(s: string): string {
|
||||
return s.replace(/"/g, '""');
|
||||
}
|
||||
|
||||
function dateSlug(range: { from: Date; to: Date }): string {
|
||||
return `${range.from.toISOString().slice(0, 10)}_${range.to.toISOString().slice(0, 10)}`;
|
||||
}
|
||||
86
src/lib/reports/exporters/pdf.ts
Normal file
86
src/lib/reports/exporters/pdf.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ExportResult, ReportPayload } from '@/lib/reports/types';
|
||||
|
||||
/**
|
||||
* PDF export. Unlike CSV + Excel (which can serialise in the browser),
|
||||
* PDF generation runs server-side via `@react-pdf/renderer` so the
|
||||
* client posts the payload to `/api/v1/reports/export-pdf` and receives
|
||||
* the rendered bytes back.
|
||||
*
|
||||
* The server resolves the active port's branding (logo + primary
|
||||
* color + name) so per-port theming flows through automatically — the
|
||||
* client doesn't need to send branding fields.
|
||||
*/
|
||||
|
||||
interface PdfExportOptions {
|
||||
/** Filename override mirroring the CSV / Excel exporters. */
|
||||
filenameOverride?: string;
|
||||
}
|
||||
|
||||
export async function exportReportAsPdf(
|
||||
payload: ReportPayload,
|
||||
options: PdfExportOptions = {},
|
||||
): Promise<ExportResult> {
|
||||
// Serialise dates to ISO so they survive the JSON trip.
|
||||
const wireBody = {
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
filenameSlug: payload.filenameSlug,
|
||||
range: {
|
||||
from: payload.range.from.toISOString(),
|
||||
to: payload.range.to.toISOString(),
|
||||
},
|
||||
kpis: payload.kpis,
|
||||
sections: payload.sections.map((s) => ({
|
||||
title: s.title,
|
||||
columns: s.columns.map((c) => ({
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
align: c.align,
|
||||
// `format` is a function and isn't serialisable; the server
|
||||
// falls back to plain stringification, which matches the CSV
|
||||
// exporter's default behaviour when no format is set.
|
||||
})),
|
||||
// Apply client-side format functions BEFORE serialising so the
|
||||
// server sees pre-formatted strings. This preserves money /
|
||||
// percentage / date formatting that the original ReportPayload
|
||||
// declared.
|
||||
rows: s.rows.map((row) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const col of s.columns) {
|
||||
out[col.key] = col.format ? col.format(row[col.key]) : row[col.key];
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
})),
|
||||
filenameOverride: options.filenameOverride,
|
||||
};
|
||||
|
||||
const res = await fetch('/api/v1/reports/export-pdf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(wireBody),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(text || `PDF generation failed (${res.status})`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const cdHeader = res.headers.get('content-disposition') ?? '';
|
||||
const match = cdHeader.match(/filename="([^"]+)"/);
|
||||
const filename = match?.[1] ?? options.filenameOverride ?? defaultPdfFilename(payload);
|
||||
|
||||
return {
|
||||
filename,
|
||||
mimeType: 'application/pdf',
|
||||
body: blob,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultPdfFilename(payload: ReportPayload): string {
|
||||
const fromIso = payload.range.from.toISOString().slice(0, 10);
|
||||
const toIso = payload.range.to.toISOString().slice(0, 10);
|
||||
return `${payload.filenameSlug}-${fromIso}_${toIso}.pdf`;
|
||||
}
|
||||
169
src/lib/reports/exporters/xlsx.ts
Normal file
169
src/lib/reports/exporters/xlsx.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
import type { ExportResult, ReportPayload, ReportSection } from '@/lib/reports/types';
|
||||
|
||||
/**
|
||||
* Multi-sheet Excel export. Sheet layout:
|
||||
* - "Summary" — title + period + each KPI as a labelled row
|
||||
* - One sheet per ReportSection — header row + data rows
|
||||
*
|
||||
* Excel sheet names are capped at 31 chars + can't contain certain
|
||||
* characters (\\/?*[]:); we sanitise + truncate accordingly.
|
||||
*/
|
||||
|
||||
interface XlsxExportOptions {
|
||||
/** Filename without extension. Defaults to the payload's filenameSlug
|
||||
* + the date range, matching the CSV exporter's pattern. */
|
||||
filenameOverride?: string;
|
||||
}
|
||||
|
||||
export async function exportReportAsXlsx(
|
||||
payload: ReportPayload,
|
||||
options: XlsxExportOptions = {},
|
||||
): Promise<ExportResult> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'Port Nimara CRM';
|
||||
workbook.created = new Date();
|
||||
workbook.modified = new Date();
|
||||
|
||||
// ─── Summary sheet ─────────────────────────────────────────────────────
|
||||
const summary = workbook.addWorksheet('Summary', {
|
||||
properties: { tabColor: { argb: 'FF3A7BC8' } },
|
||||
});
|
||||
|
||||
// Title block
|
||||
summary.mergeCells('A1:C1');
|
||||
const titleCell = summary.getCell('A1');
|
||||
titleCell.value = payload.title;
|
||||
titleCell.font = { name: 'Arial', size: 16, bold: true, color: { argb: 'FF0A1628' } };
|
||||
titleCell.alignment = { vertical: 'middle' };
|
||||
|
||||
summary.mergeCells('A2:C2');
|
||||
summary.getCell('A2').value = payload.description ?? '';
|
||||
summary.getCell('A2').font = {
|
||||
name: 'Arial',
|
||||
size: 10,
|
||||
italic: true,
|
||||
color: { argb: 'FF6B6557' },
|
||||
};
|
||||
|
||||
summary.mergeCells('A3:C3');
|
||||
summary.getCell('A3').value = `Period: ${formatDate(payload.range.from)} – ${formatDate(
|
||||
payload.range.to,
|
||||
)}`;
|
||||
summary.getCell('A3').font = { name: 'Arial', size: 10, color: { argb: 'FF6B6557' } };
|
||||
|
||||
summary.mergeCells('A4:C4');
|
||||
summary.getCell('A4').value = `Generated: ${new Date().toISOString()}`;
|
||||
summary.getCell('A4').font = { name: 'Arial', size: 9, color: { argb: 'FF94A3B8' } };
|
||||
|
||||
// KPI rows
|
||||
summary.addRow([]);
|
||||
const kpiHeader = summary.addRow(['Metric', 'Value', 'Hint']);
|
||||
kpiHeader.font = { name: 'Arial', bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
kpiHeader.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E2844' } };
|
||||
for (const kpi of payload.kpis) {
|
||||
summary.addRow([kpi.label, kpi.value, kpi.hint ?? '']);
|
||||
}
|
||||
|
||||
// Column widths
|
||||
summary.getColumn(1).width = 28;
|
||||
summary.getColumn(2).width = 22;
|
||||
summary.getColumn(3).width = 40;
|
||||
|
||||
// Freeze the title block + KPI header
|
||||
summary.views = [{ state: 'frozen', ySplit: 6 }];
|
||||
|
||||
// ─── One sheet per section ─────────────────────────────────────────────
|
||||
for (const section of payload.sections) {
|
||||
const sheetName = sanitizeSheetName(section.title);
|
||||
const sheet = workbook.addWorksheet(sheetName);
|
||||
|
||||
// Header row from section columns
|
||||
const headerValues = section.columns.map((c) => c.label);
|
||||
const header = sheet.addRow(headerValues);
|
||||
header.font = { name: 'Arial', bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E2844' } };
|
||||
header.alignment = { horizontal: 'left', vertical: 'middle' };
|
||||
header.height = 22;
|
||||
|
||||
// Data rows
|
||||
addSectionRows(sheet, section);
|
||||
|
||||
// Column widths — set based on header length plus content peek
|
||||
section.columns.forEach((col, i) => {
|
||||
const headerLen = col.label.length;
|
||||
const sampleLen = section.rows
|
||||
.slice(0, 20)
|
||||
.reduce((max, r) => Math.max(max, formatCell(col, r[col.key]).length), 0);
|
||||
sheet.getColumn(i + 1).width = Math.min(Math.max(headerLen, sampleLen) + 4, 50);
|
||||
if (col.align === 'right') {
|
||||
sheet.getColumn(i + 1).alignment = { horizontal: 'right' };
|
||||
}
|
||||
});
|
||||
|
||||
// Freeze the header row
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
|
||||
// Auto-filter on header
|
||||
sheet.autoFilter = {
|
||||
from: { row: 1, column: 1 },
|
||||
to: { row: 1, column: section.columns.length },
|
||||
};
|
||||
}
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
|
||||
return {
|
||||
filename:
|
||||
options.filenameOverride ?? `${payload.filenameSlug}-${dateRangeSlug(payload.range)}.xlsx`,
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
body: blob,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reusable filename derivation for UI previews. */
|
||||
export function defaultXlsxFilename(payload: ReportPayload): string {
|
||||
return `${payload.filenameSlug}-${dateRangeSlug(payload.range)}.xlsx`;
|
||||
}
|
||||
|
||||
function addSectionRows(sheet: ExcelJS.Worksheet, section: ReportSection): void {
|
||||
for (const row of section.rows) {
|
||||
const values = section.columns.map((col) => {
|
||||
const v = row[col.key];
|
||||
// Excel does best with native numbers / dates / strings; let
|
||||
// the column.format hint take precedence for display, fall back
|
||||
// to raw value for native typing.
|
||||
if (col.format) {
|
||||
return col.format(v);
|
||||
}
|
||||
if (v instanceof Date) return v;
|
||||
if (typeof v === 'number') return v;
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v);
|
||||
});
|
||||
sheet.addRow(values);
|
||||
}
|
||||
}
|
||||
|
||||
function formatCell(col: { format?: (v: unknown) => string }, value: unknown): string {
|
||||
if (col.format) return col.format(value);
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Excel sheet name constraints: max 31 chars, no \\/?*[]:. */
|
||||
function sanitizeSheetName(raw: string): string {
|
||||
return raw.replace(/[\\/?*[\]:]/g, '-').slice(0, 31);
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dateRangeSlug(range: { from: Date; to: Date }): string {
|
||||
return `${formatDate(range.from)}_${formatDate(range.to)}`;
|
||||
}
|
||||
67
src/lib/reports/format-currency.ts
Normal file
67
src/lib/reports/format-currency.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Shared currency formatting for the reports surfaces. Three duplicated
|
||||
* `formatMoney` helpers used to live in sales-report-client,
|
||||
* sales-detail-tables, sales-deal-heat, sales-rep-leaderboard, and a
|
||||
* fourth shape buried inside the operational report — all variations of
|
||||
* the same Intl.NumberFormat call. Consolidated here so a single change
|
||||
* (e.g. switching to compact / showing decimals for tiny values)
|
||||
* propagates everywhere.
|
||||
*
|
||||
* Locked decisions (2026-05-27 currency-formatting sweep):
|
||||
* - Use `style: 'currency'` with the row's / report's currency code so
|
||||
* the locale's native glyph appears (€, $, £, zł). Falls back to
|
||||
* `<rounded number> <code>` when the runtime doesn't know the code.
|
||||
* - `maximumFractionDigits: 0` — marina deals are six figures+, the
|
||||
* decimals add noise.
|
||||
* - `undefined` locale → browser / Node default (en-US in CI; user's
|
||||
* locale on the client). The Intl behaviour matches what every
|
||||
* other money render site in the app already does.
|
||||
*/
|
||||
|
||||
export function formatMoney(amount: number, currency: string): string {
|
||||
const safeCurrency = (currency || 'USD').toUpperCase();
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: safeCurrency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
} catch {
|
||||
return `${Math.round(amount).toLocaleString()} ${safeCurrency}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact form for KPI tiles when space is tight — `€1.2M` instead of
|
||||
* `€1,234,567`. Only fires above the threshold so small portfolios still
|
||||
* read literal.
|
||||
*/
|
||||
export function formatMoneyCompact(amount: number, currency: string): string {
|
||||
const safeCurrency = (currency || 'USD').toUpperCase();
|
||||
if (Math.abs(amount) < 100_000) return formatMoney(amount, safeCurrency);
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: safeCurrency,
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(amount);
|
||||
} catch {
|
||||
return formatMoney(amount, safeCurrency);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-number formatter with thousand separators. Use for amount
|
||||
* columns where the currency is shown in an adjacent column (the
|
||||
* custom builder's "Deposit expected" + "Currency" pair, for instance) —
|
||||
* keeping the value parseable as a number for spreadsheet analysis while
|
||||
* still being readable on screen.
|
||||
*/
|
||||
export function formatNumber(amount: number): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(amount);
|
||||
} catch {
|
||||
return Math.round(amount).toLocaleString();
|
||||
}
|
||||
}
|
||||
69
src/lib/reports/types.ts
Normal file
69
src/lib/reports/types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Normalised report payload shared by every report and every export
|
||||
* format. Builders produce a ReportPayload; exporters (csv/xlsx/pdf)
|
||||
* consume it. Keeping one shape decouples report content from output
|
||||
* format — adding a new report doesn't require touching any exporter
|
||||
* and vice-versa.
|
||||
*/
|
||||
|
||||
export interface ReportPayload {
|
||||
/** Display title (e.g. "Sales performance"). Used as the PDF cover
|
||||
* title + xlsx workbook name + CSV filename root. */
|
||||
title: string;
|
||||
/** Period the report covers. Rendered on the PDF cover; baked into
|
||||
* the CSV/xlsx filename. */
|
||||
range: { from: Date; to: Date };
|
||||
/** Optional one-line subtitle. */
|
||||
description?: string;
|
||||
/** Filename slug (kebab/snake). Used as the basis for the downloaded
|
||||
* file's name; the format extension is appended by the exporter. */
|
||||
filenameSlug: string;
|
||||
/** Single-number KPI cards rendered at the top of every output. The
|
||||
* CSV exporter emits these as the first section; xlsx puts them on
|
||||
* a "Summary" sheet; PDF renders them as a banner. */
|
||||
kpis: ReportKpi[];
|
||||
/** Tabular sections. Each section becomes a CSV block (with a blank
|
||||
* line between sections), an xlsx sheet, and a PDF table. */
|
||||
sections: ReportSection[];
|
||||
}
|
||||
|
||||
export interface ReportKpi {
|
||||
label: string;
|
||||
value: string | number;
|
||||
/** Optional secondary line under the value (e.g. "based on 12 won deals"). */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ReportSection {
|
||||
/** Human-readable section title. xlsx uses it as the sheet name
|
||||
* (truncated to 31 chars per Excel's limit); CSV writes it as a
|
||||
* comment row. */
|
||||
title: string;
|
||||
/** Ordered column definitions. The CSV header row + xlsx column
|
||||
* headers + PDF table columns come from this. */
|
||||
columns: ReportColumn[];
|
||||
/** Row data. Each row is an object keyed by column.key. */
|
||||
rows: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface ReportColumn {
|
||||
/** Object key used to extract the cell value from a row. */
|
||||
key: string;
|
||||
/** Display header. */
|
||||
label: string;
|
||||
/** Optional formatter applied per cell at export time. Default is
|
||||
* String(value). */
|
||||
format?: (value: unknown) => string;
|
||||
/** Alignment hint for xlsx + PDF (CSV ignores). */
|
||||
align?: 'left' | 'right' | 'center';
|
||||
}
|
||||
|
||||
/** What a sender produces; what an exporter returns. */
|
||||
export interface ExportResult {
|
||||
filename: string;
|
||||
/** MIME type appropriate to the format. */
|
||||
mimeType: string;
|
||||
/** Raw bytes (xlsx/pdf) or UTF-8 string (csv). The caller serialises
|
||||
* to a download via createObjectURL / Blob. */
|
||||
body: Blob;
|
||||
}
|
||||
@@ -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
@@ -28,7 +28,15 @@ export type ListReportsInput = z.infer<typeof listReportsSchema>;
|
||||
// adds the CRUD layer on the new tables. The legacy `generatedReports` flow
|
||||
// above stays for the existing dashboard-export button until it migrates.
|
||||
|
||||
export const REPORT_KINDS = ['dashboard', 'clients', 'berths', 'interests'] as const;
|
||||
export const REPORT_KINDS = [
|
||||
'dashboard',
|
||||
'clients',
|
||||
'berths',
|
||||
'interests',
|
||||
'sales',
|
||||
'operational',
|
||||
'custom',
|
||||
] as const;
|
||||
export type ReportKind = (typeof REPORT_KINDS)[number];
|
||||
|
||||
export const REPORT_OUTPUT_FORMATS = ['pdf', 'csv', 'png'] as const;
|
||||
@@ -87,7 +95,11 @@ export type ListReportSchedulesInput = z.infer<typeof listReportSchedulesSchema>
|
||||
export const createReportScheduleSchema = z.object({
|
||||
templateId: z.string().min(1),
|
||||
cadence: z.enum(REPORT_SCHEDULE_CADENCES),
|
||||
recipients: z.array(recipientSchema).min(1).max(50),
|
||||
// Empty recipients list = "run + archive, don't email". Per locked
|
||||
// decision (2026-05-27): auto-email is OPTIONAL — an admin can
|
||||
// schedule a run that just appears in /reports/runs without
|
||||
// forcing an email blast.
|
||||
recipients: z.array(recipientSchema).max(50).default([]),
|
||||
outputFormat: z.enum(REPORT_OUTPUT_FORMATS).default('pdf'),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
@@ -95,7 +107,7 @@ export type CreateReportScheduleInput = z.infer<typeof createReportScheduleSchem
|
||||
|
||||
export const updateReportScheduleSchema = z.object({
|
||||
cadence: z.enum(REPORT_SCHEDULE_CADENCES).optional(),
|
||||
recipients: z.array(recipientSchema).min(1).max(50).optional(),
|
||||
recipients: z.array(recipientSchema).max(50).optional(),
|
||||
outputFormat: z.enum(REPORT_OUTPUT_FORMATS).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user