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:
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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user