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>
This commit is contained in:
@@ -5,7 +5,7 @@ import { useParams } from 'next/navigation';
|
||||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Anchor, CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from 'lucide-react';
|
||||
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -18,6 +18,8 @@ import { RecommendationList } from '@/components/interests/recommendation-list';
|
||||
import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel';
|
||||
import { LinkedBerthsList } from '@/components/interests/linked-berths-list';
|
||||
import { InterestTimeline } from '@/components/interests/interest-timeline';
|
||||
import { WonStatusPanel } from '@/components/interests/won-status-panel';
|
||||
import { SupplementalInfoRequestButton } from '@/components/interests/supplemental-info-request-button';
|
||||
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
|
||||
import {
|
||||
LEAD_CATEGORIES,
|
||||
@@ -28,6 +30,10 @@ import {
|
||||
} from '@/lib/constants';
|
||||
import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
|
||||
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
|
||||
import { QualificationChecklist } from '@/components/interests/qualification-checklist';
|
||||
import { PaymentsSection } from '@/components/interests/payments-section';
|
||||
import { SkipAheadBanner } from '@/components/interests/skip-ahead-banner';
|
||||
import { InterestBerthStatusBanner } from '@/components/interests/interest-berth-status-banner';
|
||||
import { InterestContractTab } from '@/components/interests/interest-contract-tab';
|
||||
import { InterestReservationTab } from '@/components/interests/interest-reservation-tab';
|
||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||
@@ -65,10 +71,23 @@ interface InterestTabsOptions {
|
||||
contractStatus: string | null;
|
||||
depositStatus: string | null;
|
||||
reservationStatus: string | null;
|
||||
/** Captured at reservation-agreement time. Drives the deposit-paid
|
||||
* auto-advance once payment totals catch up. */
|
||||
depositExpectedAmount?: string | null;
|
||||
depositExpectedCurrency?: string | null;
|
||||
/** Doc-bearing stage sub-status badges — drive the milestone past/current
|
||||
* classification independently of the pipeline stage. NULL until the
|
||||
* matching stage is reached. */
|
||||
eoiDocStatus?: string | null;
|
||||
reservationDocStatus?: string | null;
|
||||
contractDocStatus?: string | null;
|
||||
/** Final outcome — 'won' surfaces the wrap-up checklist panel. */
|
||||
outcome?: string | null;
|
||||
dateFirstContact: string | null;
|
||||
dateLastContact: string | null;
|
||||
dateEoiSent: string | null;
|
||||
dateEoiSigned: string | null;
|
||||
dateReservationSigned?: string | null;
|
||||
dateContractSent: string | null;
|
||||
dateContractSigned: string | null;
|
||||
dateDepositReceived: string | null;
|
||||
@@ -401,7 +420,7 @@ function FutureMilestones({
|
||||
currentStage,
|
||||
}: {
|
||||
milestones: Array<{
|
||||
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
|
||||
key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract';
|
||||
title: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
status: string | null;
|
||||
@@ -410,7 +429,7 @@ function FutureMilestones({
|
||||
}>;
|
||||
stageMutation: ReturnType<typeof useStageMutation>;
|
||||
advance: (stage: string) => void | Promise<void>;
|
||||
activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null;
|
||||
activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null;
|
||||
currentStage: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -511,17 +530,19 @@ function OverviewTab({
|
||||
// genuinely skips stages — the click then routes through the same
|
||||
// override-confirm flow as the inline stage picker.
|
||||
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
|
||||
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
|
||||
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
|
||||
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
|
||||
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
|
||||
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
|
||||
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
|
||||
const contractIdx = PIPELINE_STAGES.indexOf('contract');
|
||||
|
||||
// Sub-status carries the "is this milestone's doc actually signed?" bit
|
||||
// for the doc-bearing stages (eoi / reservation / contract). A milestone
|
||||
// is 'past' when stage is BEYOND its index OR when stage equals its index
|
||||
// AND the doc sub-status is 'signed'.
|
||||
const eoiSigned = interest.eoiDocStatus === 'signed';
|
||||
const reservationSigned = interest.reservationDocStatus === 'signed';
|
||||
const contractSigned = interest.contractDocStatus === 'signed';
|
||||
|
||||
const phaseFor = (milestoneEndStageIdx: number): Phase => {
|
||||
if (stageIdx === -1) return 'future';
|
||||
if (stageIdx >= milestoneEndStageIdx) return 'past';
|
||||
// The "current" milestone is the one whose end-stage hasn't been
|
||||
// reached and whose start-stage is at-or-before the current stage.
|
||||
return 'current';
|
||||
};
|
||||
// Berth Interest milestone — first thing the rep needs to capture
|
||||
// (especially for general_interest leads). Completes the moment ANY
|
||||
// berth is linked to the interest via the junction. While unset, it
|
||||
@@ -531,39 +552,59 @@ function OverviewTab({
|
||||
const hasLinkedBerth = (interest.linkedBerthCount ?? 0) > 0;
|
||||
const berthInterestPhase: Phase = hasLinkedBerth
|
||||
? 'past'
|
||||
: stageIdx === -1 || stageIdx >= eoiSignedIdx
|
||||
: stageIdx === -1 || stageIdx >= eoiIdx
|
||||
? 'past'
|
||||
: 'current';
|
||||
|
||||
const eoiPhase = phaseFor(eoiSignedIdx);
|
||||
// Deposit is current once the EOI is signed but before deposit is in.
|
||||
const eoiPhase: Phase =
|
||||
stageIdx === -1
|
||||
? 'future'
|
||||
: stageIdx > eoiIdx || (stageIdx === eoiIdx && eoiSigned)
|
||||
? 'past'
|
||||
: stageIdx === eoiIdx
|
||||
? 'current'
|
||||
: 'future';
|
||||
const reservationPhase: Phase =
|
||||
stageIdx === -1
|
||||
? 'future'
|
||||
: stageIdx > reservationIdx || (stageIdx === reservationIdx && reservationSigned)
|
||||
? 'past'
|
||||
: stageIdx === reservationIdx
|
||||
? 'current'
|
||||
: 'future';
|
||||
// Deposit becomes 'current' once the reservation is signed; auto-advance
|
||||
// moves it to 'past' the moment the running deposit total catches up.
|
||||
const depositPhase: Phase =
|
||||
stageIdx === -1
|
||||
? 'future'
|
||||
: stageIdx >= depositIdx
|
||||
: stageIdx > depositIdx
|
||||
? 'past'
|
||||
: stageIdx >= eoiSignedIdx
|
||||
? 'current'
|
||||
: 'future';
|
||||
: stageIdx === depositIdx
|
||||
? 'past'
|
||||
: stageIdx === reservationIdx && reservationSigned
|
||||
? 'current'
|
||||
: 'future';
|
||||
const contractPhase: Phase =
|
||||
stageIdx === -1
|
||||
? 'future'
|
||||
: stageIdx >= contractSignedIdx
|
||||
: stageIdx === contractIdx && contractSigned
|
||||
? 'past'
|
||||
: stageIdx >= depositIdx
|
||||
: stageIdx === contractIdx
|
||||
? 'current'
|
||||
: 'future';
|
||||
|
||||
const activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null =
|
||||
const activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null =
|
||||
berthInterestPhase === 'current'
|
||||
? 'berth_interest'
|
||||
: eoiPhase === 'current'
|
||||
? 'eoi'
|
||||
: depositPhase === 'current'
|
||||
? 'deposit'
|
||||
: contractPhase === 'current'
|
||||
? 'contract'
|
||||
: null;
|
||||
: reservationPhase === 'current'
|
||||
? 'reservation'
|
||||
: depositPhase === 'current'
|
||||
? 'deposit'
|
||||
: contractPhase === 'current'
|
||||
? 'contract'
|
||||
: null;
|
||||
|
||||
const toNum = (v: string | null | undefined): number | null => {
|
||||
if (v === null || v === undefined) return null;
|
||||
@@ -572,7 +613,7 @@ function OverviewTab({
|
||||
};
|
||||
|
||||
const milestones: Array<{
|
||||
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
|
||||
key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract';
|
||||
phase: Phase;
|
||||
title: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
@@ -612,18 +653,20 @@ function OverviewTab({
|
||||
phase: eoiPhase,
|
||||
title: 'EOI',
|
||||
icon: Send,
|
||||
status: interest.eoiStatus,
|
||||
status: interest.eoiDocStatus ?? interest.eoiStatus,
|
||||
steps: [
|
||||
{
|
||||
label: 'EOI sent',
|
||||
date: interest.dateEoiSent,
|
||||
advanceStage: 'eoi_sent',
|
||||
advanceStage: 'eoi',
|
||||
actionLabel: 'Mark EOI as sent',
|
||||
},
|
||||
{
|
||||
label: 'EOI signed',
|
||||
date: interest.dateEoiSigned,
|
||||
advanceStage: 'eoi_signed',
|
||||
// Stage stays at 'eoi'; the sub-status badge flips via a separate
|
||||
// PATCH (see MilestoneAdvanceButton.onConfirm fallback below).
|
||||
advanceStage: 'eoi',
|
||||
actionLabel: 'Mark EOI as signed',
|
||||
},
|
||||
],
|
||||
@@ -631,6 +674,24 @@ function OverviewTab({
|
||||
? `Signed ${formatDate(interest.dateEoiSigned)}`
|
||||
: 'Completed',
|
||||
},
|
||||
{
|
||||
key: 'reservation',
|
||||
phase: reservationPhase,
|
||||
title: 'Reservation',
|
||||
icon: FileSignature,
|
||||
status: interest.reservationDocStatus ?? null,
|
||||
steps: [
|
||||
{
|
||||
label: 'Reservation agreement signed',
|
||||
date: interest.dateReservationSigned ?? null,
|
||||
advanceStage: 'reservation',
|
||||
actionLabel: 'Mark reservation as signed',
|
||||
},
|
||||
],
|
||||
pastSummary: interest.dateReservationSigned
|
||||
? `Signed ${formatDate(interest.dateReservationSigned)}`
|
||||
: 'Completed',
|
||||
},
|
||||
{
|
||||
key: 'deposit',
|
||||
phase: depositPhase,
|
||||
@@ -641,25 +702,22 @@ function OverviewTab({
|
||||
{
|
||||
label: 'Deposit received',
|
||||
date: interest.dateDepositReceived,
|
||||
advanceStage: 'deposit_10pct',
|
||||
advanceStage: 'deposit_paid',
|
||||
hideAutoButton: true,
|
||||
},
|
||||
],
|
||||
footer:
|
||||
depositPhase === 'current' && !interest.dateDepositReceived ? (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<Button asChild size="sm" className="h-7 px-2.5 text-xs">
|
||||
<Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
|
||||
<Plus className="size-3.5" aria-hidden />
|
||||
Create deposit invoice
|
||||
</Link>
|
||||
</Button>
|
||||
<MilestoneAdvanceButton
|
||||
label="Mark received manually"
|
||||
variant="ghostLink"
|
||||
disabled={stageMutation.isPending}
|
||||
onConfirm={(date) => advance('deposit_10pct', date)}
|
||||
onConfirm={(date) => advance('deposit_paid', date)}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Or record a payment in the Payments section.
|
||||
</span>
|
||||
</div>
|
||||
) : null,
|
||||
pastSummary: interest.dateDepositReceived
|
||||
@@ -671,18 +729,18 @@ function OverviewTab({
|
||||
phase: contractPhase,
|
||||
title: 'Contract',
|
||||
icon: FileSignature,
|
||||
status: interest.contractStatus,
|
||||
status: interest.contractDocStatus ?? interest.contractStatus,
|
||||
steps: [
|
||||
{
|
||||
label: 'Contract sent',
|
||||
date: interest.dateContractSent,
|
||||
advanceStage: 'contract_sent',
|
||||
advanceStage: 'contract',
|
||||
actionLabel: 'Mark contract as sent',
|
||||
},
|
||||
{
|
||||
label: 'Contract signed',
|
||||
date: interest.dateContractSigned,
|
||||
advanceStage: 'contract_signed',
|
||||
advanceStage: 'contract',
|
||||
actionLabel: 'Mark contract as signed',
|
||||
},
|
||||
],
|
||||
@@ -698,6 +756,35 @@ function OverviewTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Skip-ahead nudge — informational only; fires when the deal jumped
|
||||
past a milestone without stamping the matching date. */}
|
||||
<SkipAheadBanner interest={interest} />
|
||||
|
||||
{/* Conflict callout — fires when a linked berth is sold or already
|
||||
under offer to another active deal. Doesn't block the rep; just
|
||||
surfaces the situation so they treat the deal as a backup. */}
|
||||
<InterestBerthStatusBanner
|
||||
interestId={interestId}
|
||||
interestPipelineStage={interest.pipelineStage}
|
||||
interestOutcome={interest.outcome}
|
||||
archivedAt={null}
|
||||
/>
|
||||
|
||||
{/* Qualification checklist — surfaces the port's per-port criteria so
|
||||
the rep can mark each one confirmed before the deal advances out
|
||||
of 'enquiry'. Hidden when the port has no enabled criteria. */}
|
||||
<QualificationChecklist interestId={interestId} currentStage={interest.pipelineStage} />
|
||||
|
||||
{/* Payments — bank-issued invoices live elsewhere; this is the
|
||||
internal audit record of money received against the deal. The
|
||||
running deposit total here drives the auto-advance into the
|
||||
deposit_paid stage server-side. */}
|
||||
<PaymentsSection
|
||||
interestId={interestId}
|
||||
depositExpectedAmount={interest.depositExpectedAmount ?? null}
|
||||
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
|
||||
/>
|
||||
|
||||
{/* Sales-process milestones — phase-aware so the user only sees
|
||||
what's actionable now. Past milestones collapse into a tight
|
||||
history strip; the current milestone gets the full card; future
|
||||
@@ -842,21 +929,30 @@ function OverviewTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/interests/${interestId}/tags`}
|
||||
currentTags={interest.tags ?? []}
|
||||
invalidateKey={['interests', interestId]}
|
||||
/>
|
||||
</div>
|
||||
<InlineTagEditor
|
||||
heading="Tags"
|
||||
wrapperClassName="md:col-span-2"
|
||||
endpoint={`/api/v1/interests/${interestId}/tags`}
|
||||
currentTags={interest.tags ?? []}
|
||||
invalidateKey={['interests', interestId]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
|
||||
what's already linked before browsing more options. Each row exposes
|
||||
per-berth role-flag toggles and the EOI bypass control (only visible
|
||||
once the parent interest's primary EOI is signed). */}
|
||||
{/* Won-status wrap-up checklist — only renders when this interest's
|
||||
outcome is `won`. Surfaces upload slots for the manual paperwork
|
||||
that didn't flow through the EOI->Contract chain automatically. */}
|
||||
<WonStatusPanel interestId={interestId} outcome={interest.outcome ?? null} />
|
||||
|
||||
{/* Pre-EOI supplemental info request. Sends the client a one-time
|
||||
public form pre-filled with what's on file so they can confirm /
|
||||
correct details before the EOI is drafted. Hides itself once
|
||||
the EOI is signed. */}
|
||||
<SupplementalInfoRequestButton interestId={interestId} eoiStatus={interest.eoiStatus} />
|
||||
|
||||
<LinkedBerthsList interestId={interestId} />
|
||||
|
||||
{/* Berth recommender (plan §5.3) - always-mounted card driven by the
|
||||
@@ -886,17 +982,19 @@ export function getInterestTabs({
|
||||
// documents; if a deal regresses the past docs remain accessible
|
||||
// via the generic Documents tab.
|
||||
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
|
||||
const detailsSentIdx = PIPELINE_STAGES.indexOf('details_sent');
|
||||
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
|
||||
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
|
||||
// EOI: from details_sent through contract_signed (the deal's whole life)
|
||||
const showEoiTab = stageIdx >= detailsSentIdx && stageIdx <= contractSignedIdx;
|
||||
// Contract: appears once the deposit's been paid (deal is committed)
|
||||
// and stays visible until the contract is signed
|
||||
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractSignedIdx;
|
||||
// Reservation: appears once the contract's signed and stays visible
|
||||
// through completion (reservation is the post-contract milestone)
|
||||
const showReservationTab = stageIdx >= contractSignedIdx;
|
||||
const qualifiedIdx = PIPELINE_STAGES.indexOf('qualified');
|
||||
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
|
||||
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
|
||||
const contractIdx = PIPELINE_STAGES.indexOf('contract');
|
||||
// EOI: from qualified through contract (the deal's whole life past lead-only).
|
||||
const showEoiTab = stageIdx >= qualifiedIdx;
|
||||
// Reservation: once the EOI is signed onward — the reservation agreement
|
||||
// is the v1 step between EOI and deposit. Stays visible through contract
|
||||
// so the rep can re-open the signed reservation later.
|
||||
const showReservationTab = stageIdx >= reservationIdx;
|
||||
// Contract: from deposit_paid onward (deal is committed and the contract
|
||||
// becomes the next active document).
|
||||
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractIdx;
|
||||
|
||||
const tabs: DetailTab[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user