diff --git a/src/components/reports/custom/custom-report-builder.tsx b/src/components/reports/custom/custom-report-builder.tsx index 69ac7389..760dbd15 100644 --- a/src/components/reports/custom/custom-report-builder.tsx +++ b/src/components/reports/custom/custom-report-builder.tsx @@ -30,7 +30,7 @@ import { import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; -import { ENTITY_KEYS, ENTITY_REGISTRY, type EntityKey } from '@/lib/reports/custom/registry'; +import { ENTITY_KEYS, ENTITY_META, type EntityKey } from '@/lib/reports/custom/registry-meta'; import { formatMoney, formatNumber } from '@/lib/reports/format-currency'; /** @@ -67,7 +67,7 @@ interface CustomTemplateConfig extends Record { } function defaultColumnsFor(entity: EntityKey): string[] { - return ENTITY_REGISTRY[entity].columns.filter((c) => c.defaultSelected).map((c) => c.key); + return ENTITY_META[entity].columns.filter((c) => c.defaultSelected).map((c) => c.key); } export function CustomReportBuilder({ portSlug: _portSlug }: { portSlug: string }) { @@ -187,7 +187,7 @@ export function CustomReportBuilder({ portSlug: _portSlug }: { portSlug: string toast.success(`Downloaded ${filename}`); } - const def = ENTITY_REGISTRY[entity]; + const def = ENTITY_META[entity]; return (
@@ -224,7 +224,7 @@ export function CustomReportBuilder({ portSlug: _portSlug }: { portSlug: string {ENTITY_KEYS.map((k) => ( - {ENTITY_REGISTRY[k].label} + {ENTITY_META[k].label} ))} diff --git a/src/lib/reports/custom/registry-meta.ts b/src/lib/reports/custom/registry-meta.ts new file mode 100644 index 00000000..33dec73c --- /dev/null +++ b/src/lib/reports/custom/registry-meta.ts @@ -0,0 +1,144 @@ +/** + * Custom-report entity registry — client-safe metadata. + * + * This module holds ONLY the pure-data parts of the custom-report + * registry: entity keys, the filter/column type contracts, the + * per-entity column allowlists, and the per-entity metadata + * (label/description/dateAxis/columns). It has NO `@/lib/db`, drizzle, + * or schema imports, so it is safe to import from client components + * (e.g. the column-picker UI in the custom report builder). + * + * The server-only query logic (`runQuery` + drizzle) lives in + * `registry.ts`, which imports the column arrays + meta from here and + * composes the full `ENTITY_REGISTRY` consumed by the run endpoint. + * + * Adding a new entity: + * 1. Append it to ENTITY_KEYS. + * 2. Add its column array + an ENTITY_META entry here. + * 3. Add the matching `runQuery` + ENTITY_REGISTRY entry in + * `registry.ts`. + * 4. The UI's entity-picker reads ENTITY_META directly. + */ + +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; +} + +/** + * Client-safe metadata for an entity — everything except the + * server-only `runQuery`. The full `CustomEntityDefinition` (meta + + * runQuery) lives in `registry.ts`. + */ +export interface CustomEntityMeta { + 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[]; +} + +// ─── Clients ───────────────────────────────────────────────────────────────── + +export 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' }, +]; + +// ─── Interests ─────────────────────────────────────────────────────────────── + +export 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 }, +]; + +// ─── Berths ────────────────────────────────────────────────────────────────── + +export 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' }, +]; + +// ─── Tenancies ─────────────────────────────────────────────────────────────── + +export 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' }, +]; + +// ─── Metadata registry ─────────────────────────────────────────────────────── + +export const ENTITY_META: Record = { + clients: { + key: 'clients', + label: 'Clients', + description: 'People in your CRM: name, source, contact preferences.', + dateAxis: 'Created', + columns: CLIENTS_COLUMNS, + }, + interests: { + key: 'interests', + label: 'Interests / deals', + description: 'Sales pipeline: stage, outcome, value, deposit details.', + dateAxis: 'Created', + columns: INTERESTS_COLUMNS, + }, + berths: { + key: 'berths', + label: 'Berths', + description: 'Mooring inventory: dimensions, status, price.', + dateAxis: 'Created', + columns: BERTHS_COLUMNS, + }, + tenancies: { + key: 'tenancies', + label: 'Tenancies', + description: 'Berth leases / annual contracts: dates, tenure type, status.', + dateAxis: 'Created', + columns: TENANCIES_COLUMNS, + }, +}; diff --git a/src/lib/reports/custom/registry.ts b/src/lib/reports/custom/registry.ts index 55fe1fb3..cc175f6b 100644 --- a/src/lib/reports/custom/registry.ts +++ b/src/lib/reports/custom/registry.ts @@ -1,5 +1,5 @@ /** - * Custom-report entity registry. + * Custom-report entity registry — server query layer. * * The custom builder is the catch-all for slices the four canonical * reports don't cover — pick an entity, pick columns, optionally @@ -8,19 +8,27 @@ * from the launch-readiness scope (companies, yachts, invoices, * payments, deals, sends) layer in as their schemas are wired. * + * This module is SERVER-ONLY: it pulls in `@/lib/db` + drizzle to run + * the underlying queries. The client-safe metadata (entity keys, + * column allowlists, labels/descriptions, the filter/column type + * contracts) lives in `registry-meta.ts` and is imported here. Client + * components (e.g. the column-picker UI) MUST import from + * `registry-meta.ts`, never this file, or they drag the DB layer into + * the browser bundle. + * * 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. + * - `columns`: an allowlist of column keys + human labels (sourced + * from `registry-meta.ts`). The allowlist 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). + * 1. Append it to ENTITY_KEYS (in registry-meta.ts). + * 2. Add its column array + ENTITY_META entry in registry-meta.ts. + * 3. Add the matching `runQuery` + ENTITY_REGISTRY entry here. + * 4. The UI's entity-picker reads ENTITY_META directly. */ import { and, asc, desc, eq, gte, lte, sql, type SQL } from 'drizzle-orm'; @@ -32,35 +40,25 @@ 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]; +import { + ENTITY_KEYS, + ENTITY_META, + type ColumnDefinition, + type CustomEntityMeta, + type CustomFilter, + type EntityKey, +} from './registry-meta'; -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; -} +// Re-export the client-safe contracts so existing SERVER imports of +// this module keep working unchanged. +export { ENTITY_KEYS }; +export type { ColumnDefinition, CustomFilter, EntityKey }; -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[]; +/** + * Full server-side entity definition: the client-safe metadata plus + * the server-only `runQuery`. + */ +export interface CustomEntityDefinition extends CustomEntityMeta { /** 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. */ @@ -82,16 +80,6 @@ function applyDateRange(column: ReturnType>, filter: CustomFilt // ─── 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, @@ -120,20 +108,6 @@ async function runClientsQuery({ // ─── 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, @@ -182,18 +156,6 @@ async function runInterestsQuery({ // ─── 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, @@ -224,16 +186,6 @@ async function runBerthsQuery({ // ─── 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, @@ -267,37 +219,21 @@ async function runTenanciesQuery({ // ─── Registry ──────────────────────────────────────────────────────────────── -export const ENTITY_REGISTRY: Record = { - 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, - }, +const RUN_QUERIES: Record = { + clients: runClientsQuery, + interests: runInterestsQuery, + berths: runBerthsQuery, + tenancies: runTenanciesQuery, }; + +/** + * Full server registry: client-safe metadata (from `registry-meta.ts`) + * composed with the matching `runQuery` for each entity. + */ +export const ENTITY_REGISTRY: Record = ENTITY_KEYS.reduce( + (acc, key) => { + acc[key] = { ...ENTITY_META[key], runQuery: RUN_QUERIES[key] }; + return acc; + }, + {} as Record, +);