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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user