From 886119cbde35fb16718d7795172c846e41a84f33 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 1 May 2026 23:33:53 +0200 Subject: [PATCH] refactor(sales): consolidate pipeline stages + wire EOI auto-advance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 8→9 stage refresh from earlier today only updated constants.ts and the DB — 20 component/service files still hardcoded the old enum, leaving labels blank, filter dropdowns wrong, kanban columns mismatched, and the analytics funnel silently dropping new-stage rows. The platform also never advanced pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus but left the user-visible stage stuck. This commit closes both gaps: 1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS, STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus stageLabel / stageBadgeClass / stageDotClass / safeStage / canTransitionStage helpers. components/clients/pipeline-constants.ts becomes a re-export shim so existing imports keep working. 2. 18 stale-enum surfaces migrated — interest list (table, card, filters, form, stage picker), pipeline board, client card, berth interests tab, portal client interests page, dashboard pipeline / funnel / revenue- forecast charts, settings pipeline_weights default, dashboard.service weights, analytics.service funnel stages, alert-rules stale-interest filter, interest-scoring stage rank. 3. Documents tab wired into interest detail — replaced the placeholder in interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the EOI launcher is back where salespeople work. 4. Auto-advance — new advanceStageIfBehind() in interests.service.ts (forward-only, no-op if interest is already past the target). Called from documents.service.ts on send (→ eoi_sent), Documenso completed webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed). 5. Transition guard — canTransitionStage() blocks egregious skips (e.g. completed → open, open → contract_signed). Enforced in changeInterestStage before the DB write. Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832, ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(portal)/portal/interests/page.tsx | 34 +- .../admin/settings/settings-manager.tsx | 8 +- src/components/berths/berth-interests-tab.tsx | 32 +- src/components/clients/client-card.tsx | 33 +- src/components/clients/pipeline-constants.ts | 14 + src/components/dashboard/pipeline-chart.tsx | 24 +- .../dashboard/pipeline-funnel-chart.tsx | 14 +- src/components/dashboard/revenue-forecast.tsx | 22 +- src/components/interests/interest-card.tsx | 42 +-- src/components/interests/interest-columns.tsx | 32 +- src/components/interests/interest-filters.tsx | 15 +- src/components/interests/interest-form.tsx | 26 +- .../interests/interest-stage-picker.tsx | 19 +- src/components/interests/interest-tabs.tsx | 340 +++++++++++++----- src/components/interests/pipeline-board.tsx | 15 +- src/lib/constants.ts | 111 +++++- src/lib/db/seed-data.ts | 23 +- src/lib/services/alert-rules.ts | 4 +- src/lib/services/analytics.service.ts | 12 +- src/lib/services/dashboard.service.ts | 25 +- src/lib/services/documents.service.ts | 27 +- src/lib/services/interest-scoring.service.ts | 16 +- src/lib/services/interests.service.ts | 55 ++- tests/integration/analytics-service.test.ts | 13 +- tests/unit/constants.test.ts | 31 +- tests/unit/validators.test.ts | 9 +- 26 files changed, 577 insertions(+), 419 deletions(-) create mode 100644 src/components/clients/pipeline-constants.ts diff --git a/src/app/(portal)/portal/interests/page.tsx b/src/app/(portal)/portal/interests/page.tsx index a997f02..1adb445 100644 --- a/src/app/(portal)/portal/interests/page.tsx +++ b/src/app/(portal)/portal/interests/page.tsx @@ -5,28 +5,19 @@ import type { Metadata } from 'next'; import { getPortalSession } from '@/lib/portal/auth'; import { getClientInterests } from '@/lib/services/portal.service'; import { Badge } from '@/components/ui/badge'; +import { stageLabel, safeStage, type PipelineStage } from '@/lib/constants'; export const metadata: Metadata = { title: 'Interests' }; -const STAGE_LABELS: Record = { - open: 'Open', - details_sent: 'Details Sent', - in_communication: 'In Communication', - visited: 'Visited', - signed_eoi_nda: 'EOI / NDA Signed', - deposit_10pct: 'Deposit Received', - contract: 'Contract Stage', - completed: 'Completed', -}; - -const STAGE_COLORS: Record = { +const STAGE_VARIANT: Record = { open: 'secondary', details_sent: 'secondary', in_communication: 'default', - visited: 'default', - signed_eoi_nda: 'default', + eoi_sent: 'default', + eoi_signed: 'default', deposit_10pct: 'default', - contract: 'default', + contract_sent: 'default', + contract_signed: 'default', completed: 'outline', }; @@ -40,9 +31,7 @@ export default async function PortalInterestsPage() {

Berth Interests

-

- Your berth enquiries and applications -

+

Your berth enquiries and applications

{interests.length === 0 ? ( @@ -56,10 +45,7 @@ export default async function PortalInterestsPage() { ) : (
{interests.map((interest) => ( -
+
@@ -98,8 +84,8 @@ export default async function PortalInterestsPage() { )}
- - {STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage} + + {stageLabel(interest.pipelineStage)}
diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx index c37690b..050ab9f 100644 --- a/src/components/admin/settings/settings-manager.tsx +++ b/src/components/admin/settings/settings-manager.tsx @@ -68,9 +68,11 @@ const KNOWN_SETTINGS: Array<{ open: 0.05, details_sent: 0.1, in_communication: 0.2, - signed_eoi_nda: 0.4, - deposit_10pct: 0.6, - contract: 0.8, + eoi_sent: 0.4, + eoi_signed: 0.6, + deposit_10pct: 0.75, + contract_sent: 0.85, + contract_signed: 0.95, completed: 1.0, }, }, diff --git a/src/components/berths/berth-interests-tab.tsx b/src/components/berths/berth-interests-tab.tsx index b965baa..286fba5 100644 --- a/src/components/berths/berth-interests-tab.tsx +++ b/src/components/berths/berth-interests-tab.tsx @@ -19,6 +19,7 @@ import { import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { EmptyState } from '@/components/shared/empty-state'; import { Bookmark } from 'lucide-react'; +import { PIPELINE_STAGES, stageLabel } from '@/lib/constants'; import type { InterestRow } from '@/components/interests/interest-columns'; interface BerthInterestsTabProps { @@ -28,27 +29,10 @@ interface BerthInterestsTabProps { type StageFilter = 'all' | 'active' | 'lost'; type SortMode = 'newest' | 'stage' | 'category'; -const STAGE_LABELS: Record = { - open: 'Open', - details_sent: 'Details Sent', - in_communication: 'In Communication', - visited: 'Visited', - signed_eoi_nda: 'Signed EOI/NDA', - deposit_10pct: 'Deposit 10%', - contract: 'Contract', - completed: 'Completed', -}; - -const STAGE_ORDER: Record = { - open: 0, - details_sent: 1, - in_communication: 2, - visited: 3, - signed_eoi_nda: 4, - deposit_10pct: 5, - contract: 6, - completed: 7, -}; +function stageRank(stage: string): number { + const idx = PIPELINE_STAGES.indexOf(stage as (typeof PIPELINE_STAGES)[number]); + return idx === -1 ? 99 : idx; +} const CATEGORY_RANK: Record = { hot_lead: 0, @@ -104,8 +88,8 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) { }); const sorted = [...filtered].sort((a, b) => { if (sortMode === 'stage') { - const sa = STAGE_ORDER[a.pipelineStage] ?? 99; - const sb = STAGE_ORDER[b.pipelineStage] ?? 99; + const sa = stageRank(a.pipelineStage); + const sb = stageRank(b.pipelineStage); if (sa !== sb) return sb - sa; // furthest along first } if (sortMode === 'category') { @@ -189,7 +173,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) { - {STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage} + {stageLabel(i.pipelineStage)} diff --git a/src/components/clients/client-card.tsx b/src/components/clients/client-card.tsx index 6a07717..ee391b4 100644 --- a/src/components/clients/client-card.tsx +++ b/src/components/clients/client-card.tsx @@ -17,6 +17,7 @@ import { deriveInitials, } from '@/components/shared/list-card'; import { getCountryName } from '@/lib/i18n/countries'; +import { stageBadgeClass, stageLabel } from '@/lib/constants'; import type { ClientRow } from './client-columns'; const SOURCE_LABELS: Record = { @@ -37,15 +38,20 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr const primary = client.contacts?.find((c) => c.isPrimary); const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null; const sourceLabel = client.source ? (SOURCE_LABELS[client.source] ?? client.source) : null; - const yachtCount = client.yachtCount ?? 0; - const companyCount = client.companyCount ?? 0; const tags = client.tags ?? []; const meta = [nationality, sourceLabel].filter(Boolean) as string[]; - const counts: string[] = []; - if (yachtCount > 0) counts.push(`${yachtCount} ${yachtCount === 1 ? 'yacht' : 'yachts'}`); - if (companyCount > 0) - counts.push(`${companyCount} ${companyCount === 1 ? 'company' : 'companies'}`); + + const interest = client.latestInterest ?? null; + const interestCount = client.interestCount ?? 0; + const interestBerthLabel = interest + ? interest.mooringNumber + ? `Berth ${interest.mooringNumber}` + : 'General interest' + : null; + const interestStageLabel = interest ? stageLabel(interest.stage) : null; + const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null; + const extraInterests = interestCount > 1 ? interestCount - 1 : 0; return ( ) : null} - {counts.length > 0 ? ( -

{counts.join(' · ')}

+ {interest ? ( +
+ {interestBerthLabel} + · + + {interestStageLabel} + + {extraInterests > 0 ? ( + +{extraInterests} + ) : null} +
) : null} {tags.length > 0 ? ( diff --git a/src/components/clients/pipeline-constants.ts b/src/components/clients/pipeline-constants.ts new file mode 100644 index 0000000..baafc70 --- /dev/null +++ b/src/components/clients/pipeline-constants.ts @@ -0,0 +1,14 @@ +// Re-export from the canonical source so legacy imports keep working. +export { + PIPELINE_STAGES, + STAGE_LABELS, + STAGE_BADGE, + STAGE_DOT, + STAGE_WEIGHTS, + STAGE_TRANSITIONS, + safeStage, + stageLabel, + stageBadgeClass, + stageDotClass, + type PipelineStage, +} from '@/lib/constants'; diff --git a/src/components/dashboard/pipeline-chart.tsx b/src/components/dashboard/pipeline-chart.tsx index 77acf01..9f718f8 100644 --- a/src/components/dashboard/pipeline-chart.tsx +++ b/src/components/dashboard/pipeline-chart.tsx @@ -1,19 +1,12 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { - Bar, - BarChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { apiFetch } from '@/lib/api/client'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { CardSkeleton } from '@/components/shared/loading-skeleton'; +import { stageLabel } from '@/lib/constants'; import { WidgetErrorBoundary } from './widget-error-boundary'; interface PipelineRow { @@ -21,17 +14,6 @@ interface PipelineRow { count: number; } -const STAGE_LABELS: Record = { - open: 'Open', - details_sent: 'Details Sent', - in_communication: 'In Communication', - visited: 'Visited', - signed_eoi_nda: 'Signed EOI/NDA', - deposit_10pct: 'Deposit 10%', - contract: 'Contract', - completed: 'Completed', -}; - function PipelineChartInner() { const { data, isLoading } = useQuery({ queryKey: ['dashboard', 'pipeline'], @@ -45,7 +27,7 @@ function PipelineChartInner() { } const chartData = (data ?? []).map((row) => ({ - stage: STAGE_LABELS[row.stage] ?? row.stage, + stage: stageLabel(row.stage), count: row.count, })); diff --git a/src/components/dashboard/pipeline-funnel-chart.tsx b/src/components/dashboard/pipeline-funnel-chart.tsx index 038ebcf..0dabca7 100644 --- a/src/components/dashboard/pipeline-funnel-chart.tsx +++ b/src/components/dashboard/pipeline-funnel-chart.tsx @@ -4,21 +4,11 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi import { CardSkeleton } from '@/components/shared/loading-skeleton'; import { EmptyState } from '@/components/shared/empty-state'; +import { stageLabel } from '@/lib/constants'; import { ChartCard } from './chart-card'; import { useFunnel } from './use-analytics'; import type { DateRange } from '@/lib/services/analytics.service'; -const STAGE_LABELS: Record = { - open: 'Open', - details_sent: 'Details Sent', - in_communication: 'In Communication', - visited: 'Visited', - signed_eoi_nda: 'Signed EOI/NDA', - deposit_10pct: 'Deposit 10%', - contract: 'Contract', - completed: 'Completed', -}; - interface Props { range: DateRange; } @@ -28,7 +18,7 @@ export function PipelineFunnelChart({ range }: Props) { const stages = data?.stages ?? []; const chartData = stages.map((s) => ({ - stage: STAGE_LABELS[s.stage] ?? s.stage, + stage: stageLabel(s.stage), count: s.count, conversionPct: s.conversionPct, })); diff --git a/src/components/dashboard/revenue-forecast.tsx b/src/components/dashboard/revenue-forecast.tsx index 6497e80..e98c5ad 100644 --- a/src/components/dashboard/revenue-forecast.tsx +++ b/src/components/dashboard/revenue-forecast.tsx @@ -6,6 +6,7 @@ import { apiFetch } from '@/lib/api/client'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { CardSkeleton } from '@/components/shared/loading-skeleton'; +import { stageLabel } from '@/lib/constants'; import { WidgetErrorBoundary } from './widget-error-boundary'; interface StageBreakdownRow { @@ -20,17 +21,6 @@ interface ForecastData { weightsSource: 'db' | 'default'; } -const STAGE_LABELS: Record = { - open: 'Open', - details_sent: 'Details Sent', - in_communication: 'In Communication', - visited: 'Visited', - signed_eoi_nda: 'Signed EOI/NDA', - deposit_10pct: 'Deposit 10%', - contract: 'Contract', - completed: 'Completed', -}; - function formatCurrency(value: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', @@ -66,9 +56,7 @@ function RevenueForecastInner() {

Weighted Pipeline Value

-

- {formatCurrency(data?.totalWeightedValue ?? 0)} -

+

{formatCurrency(data?.totalWeightedValue ?? 0)}

{activeStages.length > 0 && ( @@ -76,12 +64,10 @@ function RevenueForecastInner() { {activeStages.map((s) => (
- {STAGE_LABELS[s.stage] ?? s.stage} + {stageLabel(s.stage)} ({s.count}) - - {formatCurrency(s.weightedValue)} - + {formatCurrency(s.weightedValue)}
))}
diff --git a/src/components/interests/interest-card.tsx b/src/components/interests/interest-card.tsx index 399f6c8..96ff94b 100644 --- a/src/components/interests/interest-card.tsx +++ b/src/components/interests/interest-card.tsx @@ -17,43 +17,9 @@ import { deriveInitials, } from '@/components/shared/list-card'; import { cn } from '@/lib/utils'; +import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants'; import type { InterestRow } from './interest-columns'; -const STAGE_LABELS: Record = { - open: 'Open', - details_sent: 'Details Sent', - in_communication: 'In Communication', - visited: 'Visited', - signed_eoi_nda: 'Signed EOI/NDA', - deposit_10pct: 'Deposit 10%', - contract: 'Contract', - completed: 'Completed', -}; - -/** Pill colors (used for the stage badge in the meta row). */ -const STAGE_PILL: Record = { - open: 'bg-slate-100 text-slate-700', - details_sent: 'bg-blue-100 text-blue-700', - in_communication: 'bg-sky-100 text-sky-700', - visited: 'bg-violet-100 text-violet-700', - signed_eoi_nda: 'bg-amber-100 text-amber-700', - deposit_10pct: 'bg-orange-100 text-orange-700', - contract: 'bg-green-100 text-green-700', - completed: 'bg-emerald-100 text-emerald-700', -}; - -/** Accent-bar colors — saturate progressively so the pipeline depth reads at a glance. */ -const STAGE_ACCENT: Record = { - open: 'bg-slate-300', - details_sent: 'bg-blue-400', - in_communication: 'bg-sky-400', - visited: 'bg-violet-400', - signed_eoi_nda: 'bg-amber-400', - deposit_10pct: 'bg-orange-400', - contract: 'bg-green-500', - completed: 'bg-emerald-500', -}; - const CATEGORY_LABELS: Record = { general_interest: 'General', specific_qualified: 'Qualified', @@ -75,9 +41,9 @@ interface InterestCardProps { } export function InterestCard({ interest, portSlug, onEdit, onArchive }: InterestCardProps) { - const stageLabel = STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage; - const stagePill = STAGE_PILL[interest.pipelineStage] ?? 'bg-gray-100 text-gray-700'; - const accentClass = STAGE_ACCENT[interest.pipelineStage] ?? 'bg-slate-300'; + const stageLabel = toStageLabel(interest.pipelineStage); + const stagePill = stageBadgeClass(interest.pipelineStage); + const accentClass = stageDotClass(interest.pipelineStage); const isHotLead = interest.leadCategory === 'hot_lead'; const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null; const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null; diff --git a/src/components/interests/interest-columns.tsx b/src/components/interests/interest-columns.tsx index c04f295..fb7b0e5 100644 --- a/src/components/interests/interest-columns.tsx +++ b/src/components/interests/interest-columns.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Badge } from '@/components/ui/badge'; import { TagBadge } from '@/components/shared/tag-badge'; +import { stageBadgeClass, stageLabel } from '@/lib/constants'; export interface InterestRow { id: string; @@ -29,28 +30,6 @@ export interface InterestRow { tags?: Array<{ id: string; name: string; color: string }>; } -const STAGE_LABELS: Record = { - open: 'Open', - details_sent: 'Details Sent', - in_communication: 'In Communication', - visited: 'Visited', - signed_eoi_nda: 'Signed EOI/NDA', - deposit_10pct: 'Deposit 10%', - contract: 'Contract', - completed: 'Completed', -}; - -const STAGE_COLORS: Record = { - open: 'bg-slate-100 text-slate-700', - details_sent: 'bg-blue-100 text-blue-700', - in_communication: 'bg-sky-100 text-sky-700', - visited: 'bg-violet-100 text-violet-700', - signed_eoi_nda: 'bg-amber-100 text-amber-700', - deposit_10pct: 'bg-orange-100 text-orange-700', - contract: 'bg-green-100 text-green-700', - completed: 'bg-emerald-100 text-emerald-700', -}; - const CATEGORY_LABELS: Record = { general_interest: 'General Interest', specific_qualified: 'Specific Qualified', @@ -117,9 +96,9 @@ export function getInterestColumns({ const stage = getValue() as string; return ( - {STAGE_LABELS[stage] ?? stage} + {stageLabel(stage)} ); }, @@ -205,10 +184,7 @@ export function getInterestColumns({ Edit - onArchive(row.original)} - > + onArchive(row.original)}> Archive diff --git a/src/components/interests/interest-filters.tsx b/src/components/interests/interest-filters.tsx index a93cf0c..e97f01a 100644 --- a/src/components/interests/interest-filters.tsx +++ b/src/components/interests/interest-filters.tsx @@ -1,16 +1,5 @@ import type { FilterDefinition } from '@/components/shared/filter-bar'; -import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants'; - -const STAGE_LABELS: Record = { - open: 'Open', - details_sent: 'Details Sent', - in_communication: 'In Communication', - visited: 'Visited', - signed_eoi_nda: 'Signed EOI/NDA', - deposit_10pct: 'Deposit 10%', - contract: 'Contract', - completed: 'Completed', -}; +import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES } from '@/lib/constants'; const CATEGORY_LABELS: Record = { general_interest: 'General Interest', @@ -30,7 +19,7 @@ export const interestFilterDefinitions: FilterDefinition[] = [ label: 'Stage', type: 'multi-select', options: PIPELINE_STAGES.map((s) => ({ - label: STAGE_LABELS[s] ?? s, + label: STAGE_LABELS[s], value: s, })), }, diff --git a/src/components/interests/interest-form.tsx b/src/components/interests/interest-form.tsx index 69bb9c9..43311ce 100644 --- a/src/components/interests/interest-form.tsx +++ b/src/components/interests/interest-form.tsx @@ -35,20 +35,9 @@ import { YachtPicker } from '@/components/yachts/yacht-picker'; import { apiFetch } from '@/lib/api/client'; import { useEntityOptions } from '@/hooks/use-entity-options'; import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests'; -import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants'; +import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES } from '@/lib/constants'; import { cn } from '@/lib/utils'; -const STAGE_LABELS: Record = { - open: 'Open', - details_sent: 'Details Sent', - in_communication: 'In Communication', - visited: 'Visited', - signed_eoi_nda: 'Signed EOI/NDA', - deposit_10pct: 'Deposit 10%', - contract: 'Contract', - completed: 'Completed', -}; - const CATEGORY_LABELS: Record = { general_interest: 'General Interest', specific_qualified: 'Specific Qualified', @@ -58,6 +47,11 @@ const CATEGORY_LABELS: Record = { interface InterestFormProps { open: boolean; onOpenChange: (open: boolean) => void; + /** + * Pre-fill clientId when launching the form from a client detail page. + * Ignored when `interest` is provided (edit mode). + */ + defaultClientId?: string; interest?: { id: string; clientId: string; @@ -75,7 +69,7 @@ interface InterestFormProps { }; } -export function InterestForm({ open, onOpenChange, interest }: InterestFormProps) { +export function InterestForm({ open, onOpenChange, defaultClientId, interest }: InterestFormProps) { const queryClient = useQueryClient(); const isEdit = !!interest; @@ -140,14 +134,14 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps }); } else if (!interest && open) { reset({ - clientId: '', + clientId: defaultClientId ?? '', yachtId: undefined, pipelineStage: 'open', reminderEnabled: false, tagIds: [], }); } - }, [interest, open, reset]); + }, [interest, defaultClientId, open, reset]); const mutation = useMutation({ mutationFn: async (data: CreateInterestInput) => { @@ -347,7 +341,7 @@ export function InterestForm({ open, onOpenChange, interest }: InterestFormProps {PIPELINE_STAGES.map((s) => ( - {STAGE_LABELS[s] ?? s} + {STAGE_LABELS[s]} ))} diff --git a/src/components/interests/interest-stage-picker.tsx b/src/components/interests/interest-stage-picker.tsx index d3d04bb..7a92924 100644 --- a/src/components/interests/interest-stage-picker.tsx +++ b/src/components/interests/interest-stage-picker.tsx @@ -22,18 +22,7 @@ import { SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; -import { PIPELINE_STAGES } from '@/lib/constants'; - -const STAGE_LABELS: Record = { - open: 'Open', - details_sent: 'Details Sent', - in_communication: 'In Communication', - visited: 'Visited', - signed_eoi_nda: 'Signed EOI/NDA', - deposit_10pct: 'Deposit 10%', - contract: 'Contract', - completed: 'Completed', -}; +import { PIPELINE_STAGES, STAGE_LABELS, stageLabel } from '@/lib/constants'; interface InterestStagePickerProps { open: boolean; @@ -76,9 +65,7 @@ export function InterestStagePicker({
-

- {STAGE_LABELS[currentStage] ?? currentStage} -

+

{stageLabel(currentStage)}

@@ -90,7 +77,7 @@ export function InterestStagePicker({ {PIPELINE_STAGES.map((s) => ( - {STAGE_LABELS[s] ?? s} + {STAGE_LABELS[s]} ))} diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index e1e20a9..6c5dd51 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -1,16 +1,21 @@ 'use client'; -import { format } from 'date-fns'; +import { format, formatDistanceToNowStrict } from 'date-fns'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react'; import type { DetailTab } from '@/components/shared/detail-layout'; +import { Button } from '@/components/ui/button'; import { NotesList } from '@/components/shared/notes-list'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { RecommendationList } from '@/components/interests/recommendation-list'; import { InterestTimeline } from '@/components/interests/interest-timeline'; +import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab'; +import { InterestFilesTab } from '@/components/interests/interest-files-tab'; import { LEAD_CATEGORIES } from '@/lib/constants'; import { apiFetch } from '@/lib/api/client'; +import { cn } from '@/lib/utils'; type InterestPatchField = 'leadCategory' | 'source' | 'notes'; @@ -58,6 +63,21 @@ function useInterestPatch(interestId: string) { }); } +function useStageMutation(interestId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ stage, reason }: { stage: string; reason?: string }) => + apiFetch(`/api/v1/interests/${interestId}/stage`, { + method: 'PATCH', + body: { pipelineStage: stage, reason }, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['interests', interestId] }); + qc.invalidateQueries({ queryKey: ['interests'] }); + }, + }); +} + function EditableRow({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -82,6 +102,117 @@ function formatDate(date: string | null) { return format(new Date(date), 'MMM d, yyyy'); } +function relativeDate(date: string | null) { + if (!date) return null; + return `${formatDistanceToNowStrict(new Date(date))} ago`; +} + +interface MilestoneSectionProps { + title: string; + icon: React.ComponentType<{ className?: string }>; + /** Lifecycle for this milestone, in chronological order. */ + steps: Array<{ + label: string; + date: string | null; + /** Stage to advance to when the user clicks the action button for this step. */ + advanceStage?: string; + /** Optional override for the action label. */ + actionLabel?: string; + }>; + status: string | null; + onAdvance: (stage: string) => void; + isPending: boolean; +} + +/** + * One milestone section (EOI / Deposit / Contract) — shows a vertical lifecycle + * with completed steps checked, the next step exposing a quick "mark as…" + * button that bumps the pipeline stage. Each stage flip auto-stamps its date + * via the service layer (interests.service.ts). When external systems wire in + * (Documenso webhook, paid invoice → deposit, etc.), they patch the same + * stage endpoint and these checkmarks light up automatically. + */ +function MilestoneSection({ + title, + icon: Icon, + steps, + status, + onAdvance, + isPending, +}: MilestoneSectionProps) { + const firstUnsetIdx = steps.findIndex((s) => !s.date); + + return ( +
+
+
+ +

{title}

+
+ {status ? ( + + {status.replace(/_/g, ' ')} + + ) : null} +
+ +
    + {steps.map((step, i) => { + const done = !!step.date; + const isNext = !done && i === firstUnsetIdx; + return ( +
  1. + {done ? ( + + ) : ( + + )} +
    +
    + + {step.label} + + {step.date ? ( + + {formatDate(step.date)} · {relativeDate(step.date)} + + ) : null} +
    + {isNext && step.advanceStage ? ( + + ) : null} +
    +
  2. + ); + })} +
+
+ ); +} + function OverviewTab({ interestId, interest, @@ -90,88 +221,145 @@ function OverviewTab({ interest: InterestTabsOptions['interest']; }) { const mutation = useInterestPatch(interestId); + const stageMutation = useStageMutation(interestId); const save = (field: InterestPatchField) => async (next: string | null) => { await mutation.mutateAsync({ [field]: next }); }; + const advance = (stage: string) => + stageMutation.mutate({ stage, reason: 'Marked from overview' }); return ( -
- {/* Lead & Source (editable) */} -
-

Lead

-
- - - - - - -
+
+ {/* Sales-process milestones — the heart of the system. Each section is a + mini lifecycle that auto-completes as actions happen on the platform + (Documenso webhook, paid deposit invoice, signed contract). Until the + automation lands, salespeople nudge stages forward via the inline + buttons here, which auto-stamp the milestone date server-side. */} +
+ + +
- {/* EOI & Contract Status (read-only — derived) */} -
-

Status

-
- - - - -
-
- - {/* Key Dates (read-only — set by workflow events) */} -
-

Key Dates

-
- - - - - - - -
-
- - {/* Reminder */} - {interest.reminderEnabled && ( +
+ {/* Lead & Source (editable) */}
-

Reminder

+

Lead

- - + + + + + +
- )} - {/* Notes (editable, multiline) */} -
-

Notes

- -
+ {/* Contact dates (read-only — kept compact next to Lead) */} +
+

Contact

+
+ + + {interest.reservationStatus ? ( + + ) : null} +
+
- {/* Tags */} -
-

Tags

- + {/* Reminder */} + {interest.reminderEnabled && ( +
+

Reminder

+
+ + +
+
+ )} + + {/* Notes (editable, multiline) */} +
+

Notes

+ +
+ + {/* Tags */} +
+

Tags

+ +
); @@ -198,20 +386,12 @@ export function getInterestTabs({ { id: 'documents', label: 'Documents', - content: ( -
-

Documents tab available after document system is built

-
- ), + content: , }, { id: 'files', label: 'Files', - content: ( -
-

Files tab available after file system is built

-
- ), + content: , }, { id: 'recommendations', diff --git a/src/components/interests/pipeline-board.tsx b/src/components/interests/pipeline-board.tsx index 4703008..a03dc22 100644 --- a/src/components/interests/pipeline-board.tsx +++ b/src/components/interests/pipeline-board.tsx @@ -8,18 +8,7 @@ import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core'; import { PipelineColumn } from '@/components/interests/pipeline-column'; import { apiFetch } from '@/lib/api/client'; import { usePipelineStore } from '@/stores/pipeline-store'; -import { PIPELINE_STAGES } from '@/lib/constants'; - -const STAGE_LABELS: Record = { - open: 'Open', - details_sent: 'Details Sent', - in_communication: 'In Communication', - visited: 'Visited', - signed_eoi_nda: 'Signed EOI/NDA', - deposit_10pct: 'Deposit 10%', - contract: 'Contract', - completed: 'Completed', -}; +import { PIPELINE_STAGES, STAGE_LABELS } from '@/lib/constants'; interface InterestRow { id: string; @@ -116,7 +105,7 @@ export function PipelineBoard() { ))} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 120db80..ade48f9 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -4,15 +4,106 @@ export const PIPELINE_STAGES = [ 'open', 'details_sent', 'in_communication', - 'visited', - 'signed_eoi_nda', + 'eoi_sent', + 'eoi_signed', 'deposit_10pct', - 'contract', + 'contract_sent', + 'contract_signed', 'completed', ] as const; export type PipelineStage = (typeof PIPELINE_STAGES)[number]; +export const STAGE_LABELS: Record = { + 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', +}; + +export const STAGE_BADGE: Record = { + 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 = { + 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 (0–1). +// Editable per port via settings (`pipeline_weights`); these are the fallbacks. +export const STAGE_WEIGHTS: Record = { + 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 = { + 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; @@ -21,23 +112,13 @@ export type BerthStatus = (typeof BERTH_STATUSES)[number]; // ─── Lead Categories ───────────────────────────────────────────────────────── -export const LEAD_CATEGORIES = [ - 'general_interest', - 'specific_qualified', - 'hot_lead', -] as const; +export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const; export type LeadCategory = (typeof LEAD_CATEGORIES)[number]; // ─── Document Types ────────────────────────────────────────────────────────── -export const DOCUMENT_TYPES = [ - 'eoi', - 'contract', - 'nda', - 'reservation_agreement', - 'other', -] as const; +export const DOCUMENT_TYPES = ['eoi', 'contract', 'nda', 'reservation_agreement', 'other'] as const; export type DocumentType = (typeof DOCUMENT_TYPES)[number]; diff --git a/src/lib/db/seed-data.ts b/src/lib/db/seed-data.ts index 4d1078b..12312dd 100644 --- a/src/lib/db/seed-data.ts +++ b/src/lib/db/seed-data.ts @@ -862,11 +862,9 @@ export async function seedPortData(portId: string, portSlug: string): Promise // Pipeline stuck in mid-funnel stages with no contact for 14+ days. async function interestStale(portId: string): Promise { - const STALE_STAGES = ['details_sent', 'in_communication', 'visited']; + // Mid-funnel stages where silence is a problem. EOI/deposit/contract stages + // have their own dedicated alerts (eoi.unsigned_long, deposit_overdue, etc.). + const STALE_STAGES = ['details_sent', 'in_communication', 'eoi_sent']; const rows = await db .select({ id: interests.id, diff --git a/src/lib/services/analytics.service.ts b/src/lib/services/analytics.service.ts index 0bba592..5a8272a 100644 --- a/src/lib/services/analytics.service.ts +++ b/src/lib/services/analytics.service.ts @@ -12,6 +12,7 @@ import { analyticsSnapshots } from '@/lib/db/schema/insights'; import { interests } from '@/lib/db/schema/interests'; import { invoices } from '@/lib/db/schema/financial'; import { berthReservations } from '@/lib/db/schema/reservations'; +import { PIPELINE_STAGES } from '@/lib/constants'; export type DateRange = '7d' | '30d' | '90d' | 'today'; @@ -117,17 +118,6 @@ function rangeToDays(range: DateRange): number { // ─── Computations ───────────────────────────────────────────────────────────── -const PIPELINE_STAGES = [ - 'open', - 'details_sent', - 'in_communication', - 'visited', - 'signed_eoi_nda', - 'deposit_10pct', - 'contract', - 'completed', -] as const; - export async function computePipelineFunnel( portId: string, range: DateRange, diff --git a/src/lib/services/dashboard.service.ts b/src/lib/services/dashboard.service.ts index 56be566..8342a72 100644 --- a/src/lib/services/dashboard.service.ts +++ b/src/lib/services/dashboard.service.ts @@ -5,20 +5,9 @@ import { clients } from '@/lib/db/schema/clients'; import { interests } from '@/lib/db/schema/interests'; import { berths } from '@/lib/db/schema/berths'; import { systemSettings, auditLogs } from '@/lib/db/schema/system'; -import { PIPELINE_STAGES } from '@/lib/constants'; +import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants'; -// ─── Default pipeline weights ──────────────────────────────────────────────── - -const DEFAULT_PIPELINE_WEIGHTS: Record = { - open: 0.05, - details_sent: 0.10, - in_communication: 0.20, - visited: 0.35, - signed_eoi_nda: 0.50, - deposit_10pct: 0.70, - contract: 0.90, - completed: 1.00, -}; +const DEFAULT_PIPELINE_WEIGHTS: Record = STAGE_WEIGHTS; // ─── KPIs ───────────────────────────────────────────────────────────────────── @@ -98,10 +87,7 @@ export async function getRevenueForecast(portId: string) { let weightsSource: 'db' | 'default' = 'default'; const settingRow = await db.query.systemSettings.findFirst({ - where: and( - eq(systemSettings.key, 'pipeline_weights'), - eq(systemSettings.portId, portId), - ), + where: and(eq(systemSettings.key, 'pipeline_weights'), eq(systemSettings.portId, portId)), }); if (settingRow?.value) { @@ -155,10 +141,7 @@ export async function getRevenueForecast(portId: string) { weightedValue: stageMap[stage]?.weightedValue ?? 0, })); - const totalWeightedValue = stageBreakdown.reduce( - (acc, s) => acc + s.weightedValue, - 0, - ); + const totalWeightedValue = stageBreakdown.reduce((acc, s) => acc + s.weightedValue, 0); return { totalWeightedValue, diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 378ae40..8d6ab7a 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -24,6 +24,7 @@ import { minioClient, buildStoragePath } from '@/lib/minio'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; import { evaluateRule } from '@/lib/services/berth-rules-engine'; +import { advanceStageIfBehind } from '@/lib/services/interests.service'; import { createDocument as documensoCreate, sendDocument as documensoSend, @@ -596,6 +597,9 @@ export async function sendForSigning(documentId: string, portId: string, meta: A // Trigger berth rules void evaluateRule('eoi_sent', interest.id, portId, meta); + + // Advance pipeline stage to eoi_sent (no-op if already further along). + void advanceStageIfBehind(interest.id, portId, 'eoi_sent', meta, 'EOI sent for signing'); } // Create document event @@ -686,6 +690,15 @@ export async function uploadSignedManually( if (interest) { void evaluateRule('eoi_signed', doc.interestId, portId, meta); + + // Advance to eoi_signed (no-op if already past it). + void advanceStageIfBehind( + doc.interestId, + portId, + 'eoi_signed', + meta, + 'Signed EOI uploaded manually', + ); } } @@ -877,12 +890,22 @@ export async function handleDocumentCompleted(eventData: { documentId: string }) .where(eq(interests.id, doc.interestId)); if (interest) { - void evaluateRule('eoi_signed', doc.interestId, doc.portId, { + const systemMeta: AuditMeta = { userId: 'system', portId: doc.portId, ipAddress: '0.0.0.0', userAgent: 'webhook', - }); + }; + void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta); + + // Advance to eoi_signed (no-op if interest already past it). + void advanceStageIfBehind( + doc.interestId, + doc.portId, + 'eoi_signed', + systemMeta, + 'EOI signed via Documenso', + ); } } diff --git a/src/lib/services/interest-scoring.service.ts b/src/lib/services/interest-scoring.service.ts index 4c8d99d..5c2aaa6 100644 --- a/src/lib/services/interest-scoring.service.ts +++ b/src/lib/services/interest-scoring.service.ts @@ -6,6 +6,7 @@ import { interests, interestNotes } from '@/lib/db/schema/interests'; import { reminders } from '@/lib/db/schema/operations'; import { emailThreads } from '@/lib/db/schema/email'; import { logger } from '@/lib/logger'; +import { PIPELINE_STAGES } from '@/lib/constants'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -42,19 +43,8 @@ function scorePipelineAge(createdAt: Date): number { } function scoreStageSpeed(createdAt: Date, pipelineStage: string): number { - // Approximate stage index based on known pipeline order - const STAGE_ORDER: Record = { - open: 0, - details_sent: 1, - in_communication: 2, - visited: 3, - signed_eoi_nda: 4, - deposit_10pct: 5, - contract: 6, - completed: 7, - }; - - const stageIndex = STAGE_ORDER[pipelineStage] ?? 0; + const idx = PIPELINE_STAGES.indexOf(pipelineStage as (typeof PIPELINE_STAGES)[number]); + const stageIndex = idx === -1 ? 0 : idx; if (stageIndex === 0) { // Still at open — no progression return 0; diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index d2f1a34..51b03e0 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -14,6 +14,7 @@ import { setEntityTags } from '@/lib/services/entity-tags.helper'; import { buildListQuery } from '@/lib/db/query-builder'; import { diffEntity } from '@/lib/entity-diff'; import { softDelete, restore, withTransaction } from '@/lib/db/utils'; +import { PIPELINE_STAGES, canTransitionStage, type PipelineStage } from '@/lib/constants'; import type { CreateInterestInput, UpdateInterestInput, @@ -459,6 +460,15 @@ export async function changeInterestStage( throw new ValidationError('yachtId is required before leaving stage=open'); } + // Block egregious skips. The transition table allows reasonable forward + // jumps (e.g. open → eoi_sent) while rejecting things like completed → open + // or open → contract_signed. Same-stage no-ops are allowed. + if (!canTransitionStage(existing.pipelineStage, data.pipelineStage)) { + throw new ValidationError( + `Cannot move interest from "${existing.pipelineStage}" directly to "${data.pipelineStage}".`, + ); + } + const oldStage = existing.pipelineStage; const [updated] = await db @@ -469,9 +479,11 @@ export async function changeInterestStage( // BR-133: Auto-populate milestones based on stage const milestoneUpdates: Record = {}; - if (data.pipelineStage === 'signed_eoi_nda') milestoneUpdates.dateEoiSigned = new Date(); - if (data.pipelineStage === 'contract') milestoneUpdates.dateContractSigned = new Date(); + if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = new Date(); + if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = new Date(); if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = new Date(); + if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = new Date(); + if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = new Date(); if (Object.keys(milestoneUpdates).length > 0) { await db .update(interests) @@ -527,6 +539,45 @@ export async function changeInterestStage( return updated!; } +// ─── Advance Stage If Behind ───────────────────────────────────────────────── +// +// Moves an interest forward to `target` if (and only if) it is currently behind +// it in the pipeline order. Used by lifecycle events (EOI sent, EOI signed, +// deposit recorded, contract signed) so the user-visible stage tracks reality +// without overwriting a more advanced state — e.g. a late-arriving signed-EOI +// webhook on an interest that has already moved on to `contract_sent` is a +// no-op rather than a regression. +// +// Returns true when the stage was changed. +export async function advanceStageIfBehind( + interestId: string, + portId: string, + target: PipelineStage, + meta: AuditMeta, + reason?: string, +): Promise { + const existing = await db.query.interests.findFirst({ + where: and(eq(interests.id, interestId), eq(interests.portId, portId)), + }); + if (!existing) return false; + + const currentIdx = PIPELINE_STAGES.indexOf(existing.pipelineStage as PipelineStage); + const targetIdx = PIPELINE_STAGES.indexOf(target); + if (currentIdx === -1 || targetIdx === -1 || currentIdx >= targetIdx) { + return false; + } + + // yachtId gate: changeInterestStage requires a yacht before leaving `open`. + // EOI events imply a yacht is in the picture, but if the data is missing we + // bail rather than throw — the EOI itself shouldn't fail because of this. + if (existing.pipelineStage === 'open' && !existing.yachtId) { + return false; + } + + await changeInterestStage(interestId, portId, { pipelineStage: target, reason }, meta); + return true; +} + // ─── Archive / Restore ──────────────────────────────────────────────────────── export async function archiveInterest(id: string, portId: string, meta: AuditMeta) { diff --git a/tests/integration/analytics-service.test.ts b/tests/integration/analytics-service.test.ts index 825adc2..46c7103 100644 --- a/tests/integration/analytics-service.test.ts +++ b/tests/integration/analytics-service.test.ts @@ -30,7 +30,14 @@ describe('analytics service', () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); // 3 open, 2 details_sent, 1 visited - for (const stage of ['open', 'open', 'open', 'details_sent', 'details_sent', 'visited']) { + for (const stage of [ + 'open', + 'open', + 'open', + 'details_sent', + 'details_sent', + 'in_communication', + ]) { await db.insert(interests).values({ portId: port.id, clientId: client.id, @@ -42,7 +49,7 @@ describe('analytics service', () => { const open = result.stages.find((s) => s.stage === 'open'); const details = result.stages.find((s) => s.stage === 'details_sent'); - const visited = result.stages.find((s) => s.stage === 'visited'); + const visited = result.stages.find((s) => s.stage === 'in_communication'); expect(open?.count).toBe(3); expect(open?.conversionPct).toBe(100); expect(details?.count).toBe(2); @@ -54,7 +61,7 @@ describe('analytics service', () => { it('returns zeros when port has no interests', async () => { const port = await makePort(); const result = await computePipelineFunnel(port.id, '30d'); - expect(result.stages).toHaveLength(8); + expect(result.stages).toHaveLength(9); expect(result.stages.every((s) => s.count === 0)).toBe(true); }); }); diff --git a/tests/unit/constants.test.ts b/tests/unit/constants.test.ts index 6dd4b18..9b7ed7b 100644 --- a/tests/unit/constants.test.ts +++ b/tests/unit/constants.test.ts @@ -1,13 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { - PIPELINE_STAGES, - BERTH_STATUSES, - NOTIFICATION_TYPES, -} from '@/lib/constants'; +import { PIPELINE_STAGES, BERTH_STATUSES, NOTIFICATION_TYPES } from '@/lib/constants'; describe('PIPELINE_STAGES', () => { - it('has exactly 8 entries', () => { - expect(PIPELINE_STAGES).toHaveLength(8); + it('has exactly 9 entries', () => { + expect(PIPELINE_STAGES).toHaveLength(9); }); it('starts with "open"', () => { @@ -23,25 +19,18 @@ describe('PIPELINE_STAGES', () => { 'open', 'details_sent', 'in_communication', - 'visited', - 'signed_eoi_nda', + 'eoi_sent', + 'eoi_signed', 'deposit_10pct', - 'contract', + 'contract_sent', + 'contract_signed', 'completed', ]); }); - it('is a readonly (frozen) tuple — cannot be mutated at runtime', () => { - expect(() => { - // TypeScript readonly doesn't prevent runtime mutation of `as const` arrays, - // but they are not Object.frozen. The important thing is the `as const` means - // the type system protects it. We verify immutability via the TypeScript type - // and check the array is not a plain mutable array. - const arr = PIPELINE_STAGES as unknown as string[]; - // Attempting splice on a readonly const-asserted array at runtime won't throw - // but the values should be what we defined. - expect(arr).toHaveLength(8); - }).not.toThrow(); + it('is a readonly tuple — type-level immutability via `as const`', () => { + const arr = PIPELINE_STAGES as unknown as string[]; + expect(arr).toHaveLength(9); }); it('has no duplicate entries', () => { diff --git a/tests/unit/validators.test.ts b/tests/unit/validators.test.ts index 62e0a52..bdd38a6 100644 --- a/tests/unit/validators.test.ts +++ b/tests/unit/validators.test.ts @@ -115,10 +115,11 @@ describe('createInterestSchema', () => { 'open', 'details_sent', 'in_communication', - 'visited', - 'signed_eoi_nda', + 'eoi_sent', + 'eoi_signed', 'deposit_10pct', - 'contract', + 'contract_sent', + 'contract_signed', 'completed', ]; for (const stage of stages) { @@ -143,7 +144,7 @@ describe('createInterestSchema', () => { describe('changeStageSchema', () => { it('accepts a valid stage', () => { - expect(changeStageSchema.safeParse({ pipelineStage: 'visited' }).success).toBe(true); + expect(changeStageSchema.safeParse({ pipelineStage: 'in_communication' }).success).toBe(true); }); it('rejects invalid stage', () => {