Files
pn-new-crm/src/lib/constants.ts

494 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── Pipeline Stages ─────────────────────────────────────────────────────────
//
// 7 canonical stages (one optional). Document-signing stages (EOI, Reservation,
// Contract) collapse "Sent + Signed" into one stage; the sub-status lives on
// per-stage doc-status columns (`eoi_doc_status`, etc.) and is rendered as a
// badge inside the kanban card.
//
// `nurturing` is built but disabled-by-default for ports that don't have
// supply constraints (e.g. Port Nimara pre-launch). Admins enable it per port.
export const PIPELINE_STAGES = [
'enquiry',
'qualified',
'nurturing',
'eoi',
'reservation',
'deposit_paid',
'contract',
] as const;
export type PipelineStage = (typeof PIPELINE_STAGES)[number];
/**
* Sub-status values for document-signing stages (EOI, Reservation, Contract).
* Stored on per-stage columns `eoi_doc_status` / `reservation_doc_status` /
* `contract_doc_status` on the interests table.
*/
export const DOC_STATUSES = ['pending', 'sent', 'signed', 'declined', 'voided'] as const;
export type DocStatus = (typeof DOC_STATUSES)[number];
export const STAGE_LABELS: Record<PipelineStage, string> = {
enquiry: 'New Enquiry',
qualified: 'Qualified',
nurturing: 'Nurturing',
eoi: 'EOI',
reservation: 'Reservation',
deposit_paid: 'Deposit Paid',
contract: 'Contract',
};
/**
* Map legacy 9-stage enum values to their 7-stage equivalents. Audit logs
* and any pre-migration data still carry the legacy values; this lets the
* activity feed, audit diffs, and reporting render the modern label
* without having to back-fill the underlying rows.
*
* Mirrors the migration applied in `seed-synthetic-data.ts` (and
* documented in the 9→7 pipeline refactor):
* details_sent → enquiry
* in_communication → qualified
* eoi_sent, eoi_signed → eoi (doc-status carries sent/signed sub-state)
* deposit_10pct → deposit_paid
* contract_sent, contract_signed → contract
* completed → contract (with outcome=won)
* open → enquiry (legacy alias for the initial stage)
*/
export const LEGACY_STAGE_REMAP: Record<string, PipelineStage> = {
open: 'enquiry',
details_sent: 'enquiry',
in_communication: 'qualified',
eoi_sent: 'eoi',
eoi_signed: 'eoi',
deposit_10pct: 'deposit_paid',
contract_sent: 'contract',
contract_signed: 'contract',
completed: 'contract',
};
/**
* Resolve any stage-like string to a canonical 7-stage value. Returns
* the modern stage as-is, maps legacy values via LEGACY_STAGE_REMAP,
* and falls back to 'enquiry' for genuinely unknown values.
*/
export function canonicalizeStage(value: string | null | undefined): PipelineStage {
if (!value) return 'enquiry';
if (PIPELINE_STAGES.includes(value as PipelineStage)) return value as PipelineStage;
return LEGACY_STAGE_REMAP[value] ?? 'enquiry';
}
/**
* Human-friendly label for any stage-like string - modern or legacy. Use
* this in any read surface (activity feed, audit diff, notification copy,
* reports) that might be handed pre-migration data.
*/
export function stageLabelFor(value: string | null | undefined): string {
return STAGE_LABELS[canonicalizeStage(value)];
}
// Compact labels for cramped contexts (mobile chart axes, dense tables).
export const STAGE_SHORT_LABELS: Record<PipelineStage, string> = {
enquiry: 'Enquiry',
qualified: 'Qual.',
nurturing: 'Nurt.',
eoi: 'EOI',
reservation: 'Resv.',
deposit_paid: 'Dep.',
contract: 'Contract',
};
export const STAGE_BADGE: Record<PipelineStage, string> = {
enquiry: 'bg-slate-100 text-slate-700',
qualified: 'bg-blue-100 text-blue-700',
nurturing: 'bg-purple-100 text-purple-700',
eoi: 'bg-indigo-100 text-indigo-700',
reservation: 'bg-amber-100 text-amber-700',
deposit_paid: 'bg-orange-100 text-orange-700',
contract: 'bg-green-100 text-green-700',
};
export const STAGE_DOT: Record<PipelineStage, string> = {
enquiry: 'bg-slate-400',
qualified: 'bg-blue-500',
nurturing: 'bg-purple-500',
eoi: 'bg-indigo-500',
reservation: 'bg-amber-500',
deposit_paid: 'bg-orange-500',
contract: 'bg-green-500',
};
// Default revenue-forecast probability weights per stage (01).
// Editable per port via settings (`pipeline_weights`); these are the fallbacks.
export const STAGE_WEIGHTS: Record<PipelineStage, number> = {
enquiry: 0.05,
qualified: 0.15,
nurturing: 0.15,
eoi: 0.4,
reservation: 0.7,
deposit_paid: 0.85,
contract: 0.95,
};
/**
* Allowed transitions out of each stage. Skip-aheads (e.g. enquiry →
* deposit_paid) are gated by the explicit `override:true` path in
* `changeInterestStage` and surface as a backfill banner on the interest.
*
* Nurturing is bidirectional with qualified (deal pauses → reopens),
* and can re-enter the EOI path when supply opens up.
*/
export const STAGE_TRANSITIONS: Record<PipelineStage, readonly PipelineStage[]> = {
// L2: include `nurturing` so a fresh enquiry can be parked straight into
// the nurturing column without first round-tripping through `qualified`.
enquiry: ['qualified', 'nurturing', 'eoi'],
qualified: ['enquiry', 'nurturing', 'eoi'],
nurturing: ['qualified', 'eoi'],
eoi: ['qualified', 'reservation', 'deposit_paid'],
reservation: ['eoi', 'deposit_paid'],
deposit_paid: ['reservation', 'contract'],
contract: ['deposit_paid'],
};
export function canTransitionStage(from: string, to: string): boolean {
if (from === to) return true;
const fromStage = safeStage(from);
const toStage = safeStage(to);
return STAGE_TRANSITIONS[fromStage].includes(toStage);
}
export function safeStage(value: string | null | undefined): PipelineStage {
return PIPELINE_STAGES.includes(value as PipelineStage) ? (value as PipelineStage) : 'enquiry';
}
export function stageLabel(stage: string | null | undefined): string {
return STAGE_LABELS[safeStage(stage)];
}
export function stageBadgeClass(stage: string | null | undefined): string {
return STAGE_BADGE[safeStage(stage)];
}
export function stageDotClass(stage: string | null | undefined): string {
return STAGE_DOT[safeStage(stage)];
}
// ─── Berth Statuses ──────────────────────────────────────────────────────────
export const BERTH_STATUSES = ['available', 'under_offer', 'sold'] as const;
export type BerthStatus = (typeof BERTH_STATUSES)[number];
// ─── Berth single-select catalogues (mirror NocoDB) ──────────────────────────
// Stored as free text in the DB so legacy values still load, but the form
// presents only the canonical options below.
export const BERTH_AREAS = ['A', 'B', 'C', 'D', 'E'] as const;
export const BERTH_BOW_FACING_OPTIONS = ['North', 'South', 'East', 'West'] as const;
export const BERTH_SIDE_PONTOON_OPTIONS = [
'No',
'Quay SB',
'Quay PT',
'Quay SB, Yes PT',
'Quay PT, Yes SB',
'Yes SB',
'Yes PT',
'Yes SB, PT',
'Finger SB',
'Finger PT',
] as const;
export const BERTH_MOORING_TYPES = [
'Side Pier / Med Mooring',
'2x Med Mooring',
'Side Pier / Finger',
'Finger / Med Mooring',
'2x Finger',
] as const;
export const BERTH_CLEAT_TYPES = ['A3', 'A5'] as const;
export const BERTH_CLEAT_CAPACITIES = ['10-14 ton break load', '20-24 ton break load'] as const;
export const BERTH_BOLLARD_TYPES = ['Bull bollard type A', 'Bull bollard type B'] as const;
export const BERTH_BOLLARD_CAPACITIES = ['20 ton break load', '40 ton break load'] as const;
export const BERTH_ACCESS_OPTIONS = [
'Car to Vessel',
'Car to Quai, Cart to Vessel',
'Cart to Vessel',
'Car (3t) to Vessel',
'Car (3.5t) to Vessel',
] as const;
/**
* Map a readonly enum tuple into shadcn `<Select>` `{value, label}` objects.
* `value` is the raw enum string (what the API expects); `label` is a
* human-formatted version (underscores → spaces, Title Case) so reps
* see "Under Offer" instead of "under_offer" in dropdowns. Specific
* acronyms keep their canonical casing.
*/
const LABEL_OVERRIDES: Record<string, string> = {
// 3-letter acronyms - preserve all-caps where the enum stores lowercase.
vhf: 'VHF',
eoi: 'EOI',
nda: 'NDA',
// Status enums where the natural title-cased form differs slightly.
under_offer: 'Under Offer',
fixed_term: 'Fixed Term',
reservation_agreement: 'Reservation Agreement',
hot_lead: 'Hot lead',
general_interest: 'General interest',
specific_qualified: 'Specific qualified',
};
function humanizeEnum(raw: string): string {
const override = LABEL_OVERRIDES[raw.toLowerCase()];
if (override) return override;
return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Format an arbitrary enum-shaped string ("hot_lead" → "Hot Lead",
* "in_progress" → "In Progress"). Centralised so list columns, badge
* components, and detail pages render the same value consistently -
* replaces the scattered ad-hoc `.replace(/_/g, ' ')` calls flagged
* by ui-ux-auditor H1.
*/
export function formatEnum(value: string | null | undefined): string {
if (!value) return '';
return humanizeEnum(value);
}
/** Format a pipeline stage value. Falls back to formatEnum for unknown values. */
export function formatStage(value: string | null | undefined): string {
if (!value) return '';
return STAGE_LABELS[safeStage(value)] ?? formatEnum(value);
}
/** Format a generic status (eoi_status, contract_status, deposit_status,
* invoice status, document status). Same shape as the enum but kept as
* a separate exported alias so call sites read intentionally. */
export function formatStatus(value: string | null | undefined): string {
return formatEnum(value);
}
/** Format a priority enum ('low' | 'medium' | 'high' | 'urgent'). */
export function formatPriority(value: string | null | undefined): string {
return formatEnum(value);
}
export function toSelectOptions<T extends readonly string[]>(
values: T,
): Array<{ value: T[number]; label: string }> {
return values.map((v) => ({ value: v, label: humanizeEnum(v) }));
}
// ─── Lead Categories ─────────────────────────────────────────────────────────
export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;
export type LeadCategory = (typeof LEAD_CATEGORIES)[number];
// ─── Sources (interests + clients + residential) ─────────────────────────────
// Single source of truth for the source dropdown. Keep these in lockstep
// across forms, inline-edit selects, list-column labels and chart bucketing
// so values written from one surface render with the same label on another.
export const SOURCES = [
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
{ value: 'other', label: 'Other' },
] as const;
export type SourceValue = (typeof SOURCES)[number]['value'];
export const SOURCE_LABELS: Record<SourceValue, string> = SOURCES.reduce(
(acc, s) => ({ ...acc, [s.value]: s.label }),
{} as Record<SourceValue, string>,
);
/** Returns the canonical label for a stored source value, falling back to a
* Title-Case rendering of the raw string for legacy / free-text values. */
export function formatSource(source: string | null | undefined): string | null {
if (!source) return null;
if (source in SOURCE_LABELS) return SOURCE_LABELS[source as SourceValue];
return source.charAt(0).toUpperCase() + source.slice(1);
}
// ─── Role names ──────────────────────────────────────────────────────────────
// Roles are stored verbatim in the `roles` table as the seeded snake_case
// identifier (super_admin, sales_agent, …) so every comparison + permission
// lookup keeps using the stable name. UI surfaces should render through
// `formatRole()` so customers see "Sales Agent" instead of "sales_agent".
// Custom roles created by admins keep their typed name; we only Title-Case
// snake_case identifiers, so a hand-typed role like "Marina Lead" comes
// through untouched.
export const ROLE_LABELS: Record<string, string> = {
super_admin: 'Super Admin',
director: 'Director',
sales_manager: 'Sales Manager',
sales_agent: 'Sales Agent',
finance_manager: 'Finance Manager',
viewer: 'Viewer',
residential_partner: 'Residential Partner',
};
/** Returns the human label for a stored role name. Falls back to a
* Title-Case rendering for legacy / custom roles. */
export function formatRole(role: string | null | undefined): string {
if (!role) return 'Staff';
if (role in ROLE_LABELS) return ROLE_LABELS[role]!;
// Title-Case any snake_case input (covers custom roles that happen to be
// entered in lowercase_with_underscores). Free-text role names that
// already contain spaces pass through unchanged.
return role
.split('_')
.map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part))
.join(' ');
}
// ─── Interest outcomes ───────────────────────────────────────────────────────
// Mirrors INTEREST_OUTCOMES in src/lib/validators/interests.ts. Lives here
// so render sites can format outcome strings without pulling in the
// validator (which would drag zod into RSC bundles). Validator → enforces
// the set; here → labels for humans.
export const OUTCOME_LABELS: Record<string, string> = {
won: 'Won',
lost_other_marina: 'Lost - chose another marina',
lost_unqualified: 'Lost - not qualified',
lost_no_response: 'Lost - no response',
lost_other: 'Lost - other',
cancelled: 'Cancelled',
};
/** Returns the human label for a stored outcome value. Falls back to a
* pretty Title-Case rendering for any new values added at the validator
* before this map catches up. */
export function formatOutcome(outcome: string | null | undefined): string | null {
if (!outcome) return null;
if (outcome in OUTCOME_LABELS) return OUTCOME_LABELS[outcome]!;
return outcome
.split('_')
.map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part))
.join(' ');
}
// ─── Document Types ──────────────────────────────────────────────────────────
export const DOCUMENT_TYPES = ['eoi', 'contract', 'nda', 'reservation_agreement', 'other'] as const;
export type DocumentType = (typeof DOCUMENT_TYPES)[number];
/**
* Display labels for `DOCUMENT_TYPES`. Use these everywhere a doc type
* is rendered in user-facing copy (selectors, badges, exports). The
* raw enum values are kebab-case-ish and not safe to title-case via
* a naive `replace(/_/g, ' ')` — "Eoi"/"Nda" read wrong; the proper
* labels surface acronyms and friendly multi-word forms.
*/
export const DOCUMENT_TYPE_LABELS: Record<DocumentType, string> = {
eoi: 'EOI',
contract: 'Contract',
nda: 'NDA',
reservation_agreement: 'Reservation Agreement',
other: 'Other',
};
// ─── Document Statuses ───────────────────────────────────────────────────────
export const DOCUMENT_STATUSES = [
'draft',
'sent',
'partially_signed',
'completed',
'expired',
'cancelled',
// Documenso writes both 'rejected' and 'declined' depending on which
// webhook path fires; we mirror that on the document row. Surface
// both so DocumentStatus checks against either spelling type-check.
'rejected',
'declined',
] as const;
export type DocumentStatus = (typeof DOCUMENT_STATUSES)[number];
// ─── Expense Categories ──────────────────────────────────────────────────────
export const EXPENSE_CATEGORIES = [
'fuel',
'maintenance',
'cleaning',
'docking',
'insurance',
'utilities',
'marina_fees',
'repairs',
'equipment',
'crew',
'administration',
'marketing',
'travel',
'entertainment',
'other',
] as const;
export type ExpenseCategory = (typeof EXPENSE_CATEGORIES)[number];
// ─── Payment Methods ─────────────────────────────────────────────────────────
export const PAYMENT_METHODS = [
'bank_transfer',
'credit_card',
'debit_card',
'cash',
'cheque',
'crypto',
'other',
] as const;
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];
// ─── Notification Types ──────────────────────────────────────────────────────
export const NOTIFICATION_TYPES = [
// Interest / pipeline
'interest_stage_changed',
'interest_created',
'interest_assigned',
// Documents
'document_sent',
'document_signed',
'document_completed',
'document_expired',
'document_reminder',
// Reminders
'reminder_due',
'reminder_overdue',
'reminder_assigned',
// Financial
'invoice_sent',
'invoice_paid',
'invoice_overdue',
// Notes
'mention',
// Email
'email_received',
// System
'system_alert',
'job_failed',
'bulk_operation_complete',
'export_ready',
// Berths
'berth_status_changed',
'berth_waiting_list_update',
] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];