Files
pn-new-crm/src/lib/constants.ts
Matt 4b9743a594 audit: 33-agent comprehensive audit + critical fixes
Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md
(5900+ lines, 30+ critical findings). Already-fixed this commit:
- permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard
- /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration
- admin email-change: rotates account.accountId + revokes sessions
- middleware: token-gated email confirm/cancel routes whitelisted
- NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets

Feature work landing same commit: optional username sign-in
(migration 0054), per-user permission overrides (0055) with three-state
matrix tabbed inside UserForm, user disable button, role + outcome +
stage label normalisation across the platform, admin email-change
with auto-notification template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:52:35 +02:00

389 lines
14 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 ─────────────────────────────────────────────────────────
export const PIPELINE_STAGES = [
'open',
'details_sent',
'in_communication',
'eoi_sent',
'eoi_signed',
'deposit_10pct',
'contract_sent',
'contract_signed',
'completed',
] as const;
export type PipelineStage = (typeof PIPELINE_STAGES)[number];
export const STAGE_LABELS: Record<PipelineStage, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Comms',
eoi_sent: 'EOI Sent',
eoi_signed: 'EOI Signed',
deposit_10pct: 'Deposit 10%',
contract_sent: 'Contract Sent',
contract_signed: 'Contract Signed',
completed: 'Completed',
};
// Compact labels for cramped contexts (mobile chart axes, dense tables).
export const STAGE_SHORT_LABELS: Record<PipelineStage, string> = {
open: 'Open',
details_sent: 'Details',
in_communication: 'Comms',
eoi_sent: 'EOI →',
eoi_signed: 'EOI ✓',
deposit_10pct: 'Dep.',
contract_sent: 'Ctr →',
contract_signed: 'Ctr ✓',
completed: 'Done',
};
export const STAGE_BADGE: Record<PipelineStage, string> = {
open: 'bg-slate-100 text-slate-700',
details_sent: 'bg-blue-100 text-blue-700',
in_communication: 'bg-sky-100 text-sky-700',
eoi_sent: 'bg-indigo-100 text-indigo-700',
eoi_signed: 'bg-amber-100 text-amber-700',
deposit_10pct: 'bg-orange-100 text-orange-700',
contract_sent: 'bg-yellow-100 text-yellow-700',
contract_signed: 'bg-green-100 text-green-700',
completed: 'bg-emerald-100 text-emerald-700',
};
export const STAGE_DOT: Record<PipelineStage, string> = {
open: 'bg-slate-400',
details_sent: 'bg-blue-500',
in_communication: 'bg-sky-500',
eoi_sent: 'bg-indigo-500',
eoi_signed: 'bg-amber-500',
deposit_10pct: 'bg-orange-500',
contract_sent: 'bg-yellow-500',
contract_signed: 'bg-green-500',
completed: 'bg-emerald-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> = {
open: 0.05,
details_sent: 0.1,
in_communication: 0.2,
eoi_sent: 0.4,
eoi_signed: 0.6,
deposit_10pct: 0.75,
contract_sent: 0.85,
contract_signed: 0.95,
completed: 1.0,
};
// Allowed transitions out of each stage. Used by changeInterestStage to guard
// against accidental skips (e.g. dragging a card from Completed back to Open,
// or jumping Open straight to Completed). Forward moves of 1-2 stages are
// permitted; backward moves are limited to the immediate predecessor unless
// the lifecycle (EOI/contract chain) needs an explicit rewind.
export const STAGE_TRANSITIONS: Record<PipelineStage, readonly PipelineStage[]> = {
open: ['details_sent', 'in_communication', 'eoi_sent', 'eoi_signed'],
details_sent: ['open', 'in_communication', 'eoi_sent', 'eoi_signed'],
in_communication: ['open', 'details_sent', 'eoi_sent', 'eoi_signed'],
eoi_sent: ['in_communication', 'eoi_signed', 'deposit_10pct'],
eoi_signed: ['eoi_sent', 'deposit_10pct', 'contract_sent', 'contract_signed'],
deposit_10pct: ['eoi_signed', 'contract_sent', 'contract_signed'],
contract_sent: ['eoi_signed', 'deposit_10pct', 'contract_signed'],
contract_signed: ['contract_sent', 'deposit_10pct', 'completed'],
completed: ['contract_signed'],
};
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) : 'open';
}
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());
}
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];