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:
2026-05-27 22:41:53 +02:00
parent 909dd44605
commit 3bdf59e917
41 changed files with 10704 additions and 203 deletions

View 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,
},
};

View 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)}`;
}

View 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`;
}

View 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)}`;
}

View 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
View 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;
}