Files
pn-new-crm/src/lib/constants.ts
Matt 6b28459c45 feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00

424 lines
15 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',
};
// 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[]> = {
enquiry: ['qualified', '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];
// ─── Document Statuses ───────────────────────────────────────────────────────
export const DOCUMENT_STATUSES = [
'draft',
'sent',
'partially_signed',
'completed',
'expired',
'cancelled',
] 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];