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

@@ -100,9 +100,14 @@ interface SyntheticClientSpec {
postalCode: string;
/** Pipeline stage of the (single) interest. Omit for archived-only clients. */
stage?: PipelineStage;
/** Sub-status badges for the doc-signing stages (eoi / reservation / contract).
* Only meaningful when stage matches; otherwise null/undefined. */
eoiDocStatus?: 'sent' | 'signed';
reservationDocStatus?: 'sent' | 'signed';
contractDocStatus?: 'sent' | 'signed';
/** Index into BERTH_SNAPSHOT for the primary linked berth. */
berthIdx?: number;
/** Mark interest as won/lost when stage = completed. */
/** Mark interest as won/lost when stage = contract+signed. */
outcome?: 'won' | 'lost_unqualified' | 'lost_no_response';
/** Archive the CLIENT after creation. When 'rich', fabricate
* archive_metadata so the smart-restore wizard surfaces reversals. */
@@ -145,7 +150,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'London',
street: '14 Cheyne Walk',
postalCode: 'SW3 5RA',
stage: 'open',
stage: 'enquiry',
source: 'website',
createdDaysAgo: 4,
// Open stage: no berth link yet
@@ -159,7 +164,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Miami',
street: '880 Brickell Bay Drive',
postalCode: '33131',
stage: 'details_sent',
stage: 'enquiry',
berthIdx: 0,
source: 'broker',
createdDaysAgo: 12,
@@ -173,7 +178,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Palma de Mallorca',
street: 'Carrer de Sant Magí 23',
postalCode: '07013',
stage: 'in_communication',
stage: 'qualified',
berthIdx: 5,
source: 'referral',
createdDaysAgo: 28,
@@ -187,7 +192,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Genoa',
street: 'Via XX Settembre 47',
postalCode: '16121',
stage: 'eoi_sent',
stage: 'eoi',
eoiDocStatus: 'sent',
berthIdx: 6,
source: 'broker',
createdDaysAgo: 45,
@@ -201,7 +207,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Nice',
street: '8 Promenade des Anglais',
postalCode: '06000',
stage: 'eoi_signed',
stage: 'eoi',
eoiDocStatus: 'signed',
berthIdx: 7,
source: 'website',
createdDaysAgo: 72,
@@ -215,7 +222,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Athens',
street: 'Vouliagmenis Avenue 142',
postalCode: '16674',
stage: 'deposit_10pct',
stage: 'deposit_paid',
berthIdx: 8,
source: 'referral',
createdDaysAgo: 95,
@@ -229,7 +236,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Dublin',
street: '12 Merrion Square North',
postalCode: 'D02 E2X3',
stage: 'contract_sent',
stage: 'contract',
contractDocStatus: 'sent',
berthIdx: 9,
source: 'manual',
createdDaysAgo: 118,
@@ -243,7 +251,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Lisbon',
street: 'Rua Garrett 88',
postalCode: '1200-205',
stage: 'contract_signed',
stage: 'contract',
contractDocStatus: 'signed',
berthIdx: 4,
source: 'broker',
createdDaysAgo: 156,
@@ -257,7 +266,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Panama City',
street: 'Calle 50, Torre Banistmo Piso 18',
postalCode: '0816',
stage: 'completed',
stage: 'contract',
contractDocStatus: 'signed',
berthIdx: 10,
outcome: 'won',
source: 'referral',
@@ -272,7 +282,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
city: 'Hamburg',
street: 'Alsterufer 28',
postalCode: '20354',
stage: 'completed',
stage: 'enquiry',
berthIdx: 1,
outcome: 'lost_unqualified',
source: 'website',
@@ -552,19 +562,21 @@ export async function seedSyntheticPortData(
// ── 6. Berth status overrides for linked moorings ───────────────────────
// Match the dossier classification to the berth's pipeline stage.
// For under_offer-wave clients (eoi_sent → contract_sent), force the
// berth to under_offer. For completed-won, mark the berth sold.
// Sold = contract+signed+won. Under offer = active berth-bearing stages
// (eoi / reservation / deposit_paid / contract-not-yet-won).
const stageToBerthStatus = (
stage: PipelineStage | undefined,
spec: SyntheticClientSpec,
): 'available' | 'under_offer' | 'sold' | null => {
const stage = spec.stage;
if (!stage) return null;
if (stage === 'completed') return 'sold';
if (stage === 'contract' && spec.contractDocStatus === 'signed' && spec.outcome === 'won') {
return 'sold';
}
if (
stage === 'eoi_sent' ||
stage === 'eoi_signed' ||
stage === 'deposit_10pct' ||
stage === 'contract_sent' ||
stage === 'contract_signed'
stage === 'eoi' ||
stage === 'reservation' ||
stage === 'deposit_paid' ||
stage === 'contract'
) {
return 'under_offer';
}
@@ -573,7 +585,7 @@ export async function seedSyntheticPortData(
for (const spec of PIPELINE_CLIENTS) {
if (spec.berthIdx === undefined) continue;
const newStatus = stageToBerthStatus(spec.stage);
const newStatus = stageToBerthStatus(spec);
if (!newStatus) continue;
const berthId = berthRows[spec.berthIdx]!.id;
await tx.update(berths).set({ status: newStatus }).where(eq(berths.id, berthId));
@@ -584,20 +596,33 @@ export async function seedSyntheticPortData(
for (const spec of PIPELINE_CLIENTS) {
if (!spec.stage) continue;
const clientId = idByTag.get(spec.tag)!;
// Derive deal age from the (stage, doc-sub-status) pair so a
// contract+signed+won record looks older than a brand-new enquiry.
const stageDaysAgoMap: Record<PipelineStage, number> = {
open: 1,
details_sent: 5,
in_communication: 10,
eoi_sent: 20,
eoi_signed: 35,
deposit_10pct: 60,
contract_sent: 80,
contract_signed: 110,
completed: spec.outcome === 'won' ? 200 : 60,
enquiry: 5,
qualified: 10,
nurturing: 30,
eoi: spec.eoiDocStatus === 'signed' ? 35 : 20,
reservation: 50,
deposit_paid: 60,
contract: spec.outcome === 'won' ? 200 : spec.contractDocStatus === 'signed' ? 110 : 80,
};
const ageDays = stageDaysAgoMap[spec.stage];
const yachtId = spec.tag === 'completed-won' ? charterYachtRow[0]!.id : null;
// Stage-progression flags so the date_* timestamps cascade correctly.
// Anything past "eoi+sent" implies the EOI was at least sent.
const eoiReached =
spec.stage === 'eoi' ||
spec.stage === 'reservation' ||
spec.stage === 'deposit_paid' ||
spec.stage === 'contract';
const eoiSigned =
(spec.stage === 'eoi' && spec.eoiDocStatus === 'signed') ||
spec.stage === 'reservation' ||
spec.stage === 'deposit_paid' ||
spec.stage === 'contract';
const [intRow] = await tx
.insert(interests)
.values({
@@ -605,40 +630,24 @@ export async function seedSyntheticPortData(
clientId,
yachtId,
pipelineStage: spec.stage,
eoiDocStatus: spec.eoiDocStatus ?? (eoiSigned ? 'signed' : null),
reservationDocStatus: spec.reservationDocStatus ?? null,
contractDocStatus: spec.contractDocStatus ?? null,
leadCategory:
spec.stage === 'open'
spec.stage === 'enquiry'
? 'general_interest'
: spec.stage === 'details_sent' || spec.stage === 'in_communication'
: spec.stage === 'qualified' || spec.stage === 'nurturing'
? 'specific_qualified'
: 'hot_lead',
source: 'manual' as const,
dateFirstContact: daysAgo(ageDays),
dateLastContact: daysAgo(Math.max(0, ageDays - 2)),
dateEoiSent:
spec.stage === 'eoi_sent' ||
spec.stage === 'eoi_signed' ||
spec.stage === 'deposit_10pct' ||
spec.stage === 'contract_sent' ||
spec.stage === 'contract_signed' ||
spec.stage === 'completed'
? daysAgo(Math.max(0, ageDays - 5))
: null,
dateEoiSigned:
spec.stage === 'eoi_signed' ||
spec.stage === 'deposit_10pct' ||
spec.stage === 'contract_sent' ||
spec.stage === 'contract_signed' ||
spec.stage === 'completed'
? daysAgo(Math.max(0, ageDays - 10))
: null,
dateEoiSent: eoiReached ? daysAgo(Math.max(0, ageDays - 5)) : null,
dateEoiSigned: eoiSigned ? daysAgo(Math.max(0, ageDays - 10)) : null,
eoiStatus:
spec.stage === 'eoi_sent'
spec.stage === 'eoi' && spec.eoiDocStatus === 'sent'
? 'waiting_for_signatures'
: spec.stage === 'eoi_signed' ||
spec.stage === 'deposit_10pct' ||
spec.stage === 'contract_sent' ||
spec.stage === 'contract_signed' ||
spec.stage === 'completed'
: eoiSigned
? 'signed'
: null,
outcome: spec.outcome ?? null,
@@ -656,7 +665,7 @@ export async function seedSyntheticPortData(
berthId,
isPrimary: true,
isSpecificInterest: true,
isInEoiBundle: spec.stage !== 'open' && spec.stage !== 'details_sent',
isInEoiBundle: spec.stage !== 'enquiry' && spec.stage !== 'qualified',
addedBy: SUPER_ADMIN_USER_ID,
});
}
@@ -672,7 +681,7 @@ export async function seedSyntheticPortData(
portId,
clientId: carlaId,
yachtId: null,
pipelineStage: 'open',
pipelineStage: 'enquiry',
leadCategory: 'general_interest',
source: 'website' as const,
dateFirstContact: daysAgo(2),