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:
2026-05-14 03:39:21 +02:00
parent b10bf9bf8e
commit 6b28459c45
110 changed files with 5402 additions and 796 deletions

View File

@@ -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[] = [
{