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>
213 lines
7.2 KiB
TypeScript
213 lines
7.2 KiB
TypeScript
/**
|
||
* Rule-based deal-health scoring. NO LLMs — every output traces back to a
|
||
* dated/structured input the rep can see and contest. The chip displayed
|
||
* on the interest header exposes the per-signal breakdown via tooltip so
|
||
* an anti-AI stakeholder reading the screen never sees a black box.
|
||
*
|
||
* Inputs (all already available on getInterestById):
|
||
* - pipelineStage + per-doc sub-status (eoiDocStatus, etc.)
|
||
* - dateLastContact, dateFirstContact, dateEoiSent, dateEoiSigned,
|
||
* dateReservationSigned, dateContractSent, dateContractSigned,
|
||
* dateDepositReceived
|
||
* - depositExpectedAmount (numeric string)
|
||
*
|
||
* Scoring rubric (0–100, higher is healthier):
|
||
* Base 50.
|
||
* +5 if any activity log entry landed in the last 7 days (active engagement).
|
||
* +20 if the rep has logged contact in the last 7 days.
|
||
* +10 if contact within 14 days (and the 7d bonus didn't fire).
|
||
* -15 if no contact logged in 30+ days.
|
||
* -10 if the deal is older than 30d and still in 'enquiry' or 'qualified'.
|
||
* +10 for each stage past enquiry the deal has reached (capped at +30).
|
||
* -10 if EOI was sent more than 14d ago and isn't signed yet.
|
||
* -10 if reservation was signed but no deposit recorded in 21d.
|
||
* -10 if contract was sent more than 14d ago and isn't signed yet.
|
||
* +5 if outcome is 'won' (sanity bump, though won deals don't show this).
|
||
*
|
||
* Score buckets:
|
||
* ≥70 → 'hot' (green; rep is on top of it)
|
||
* 40-69 → 'warm' (amber; needs attention)
|
||
* <40 → 'cold' (rose; at risk)
|
||
*
|
||
* The full signals[] array is surfaced to the UI so the tooltip can render
|
||
* "+20 contacted 3 days ago", "-10 EOI awaiting signature 19d" etc.
|
||
*/
|
||
|
||
import type { PipelineStage } from '@/lib/constants';
|
||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||
|
||
export interface DealHealthInput {
|
||
pipelineStage: string;
|
||
outcome?: string | null;
|
||
archivedAt?: string | null;
|
||
dateFirstContact?: string | Date | null;
|
||
dateLastContact?: string | Date | null;
|
||
dateEoiSent?: string | Date | null;
|
||
dateEoiSigned?: string | Date | null;
|
||
dateReservationSigned?: string | Date | null;
|
||
dateContractSent?: string | Date | null;
|
||
dateContractSigned?: string | Date | null;
|
||
dateDepositReceived?: string | Date | null;
|
||
eoiDocStatus?: string | null;
|
||
reservationDocStatus?: string | null;
|
||
contractDocStatus?: string | null;
|
||
/** Optional: count of contact_log entries in the last 7 days. Drives the
|
||
* +5 "active engagement" signal. When omitted the signal is skipped — keep
|
||
* the scoring function pure / synchronous so the chip can render without a
|
||
* separate fetch on every interest list row. */
|
||
recentActivityCount?: number | null;
|
||
}
|
||
|
||
export interface DealHealthSignal {
|
||
/** Stable id useful for keying the tooltip rows. */
|
||
id: string;
|
||
/** +N or -N (signed integer for explicit math). */
|
||
delta: number;
|
||
/** Plain-English explanation surfaced in the tooltip. */
|
||
detail: string;
|
||
}
|
||
|
||
export interface DealHealth {
|
||
score: number;
|
||
pulse: 'cold' | 'warm' | 'hot';
|
||
signals: DealHealthSignal[];
|
||
}
|
||
|
||
function daysSince(iso: string | Date | null | undefined): number | null {
|
||
if (!iso) return null;
|
||
const t = iso instanceof Date ? iso.getTime() : new Date(iso).getTime();
|
||
if (Number.isNaN(t)) return null;
|
||
return Math.floor((Date.now() - t) / 86_400_000);
|
||
}
|
||
|
||
export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||
let score = 50;
|
||
const signals: DealHealthSignal[] = [];
|
||
|
||
// Closed / archived deals don't get a pulse score — UI hides the chip
|
||
// anyway, but compute a neutral score so callers using this in reports
|
||
// don't crash on undefined.
|
||
if (input.archivedAt || input.outcome) {
|
||
return { score: 50, pulse: 'warm', signals: [] };
|
||
}
|
||
|
||
// Active engagement: counts every distinct activity-log entry in the last
|
||
// 7 days. Surfaces "the rep is actively working this deal" separately from
|
||
// the coarse dateLastContact bump (which only moves on the most-recent
|
||
// entry's date). A 5-call week scores +5 once; we don't double-count.
|
||
if (input.recentActivityCount !== null && input.recentActivityCount !== undefined) {
|
||
if (input.recentActivityCount >= 1) {
|
||
score += 5;
|
||
signals.push({
|
||
id: 'active_engagement',
|
||
delta: +5,
|
||
detail: `${input.recentActivityCount} activity log entr${
|
||
input.recentActivityCount === 1 ? 'y' : 'ies'
|
||
} in the last 7d — rep is engaged.`,
|
||
});
|
||
}
|
||
}
|
||
|
||
const contactDays = daysSince(input.dateLastContact);
|
||
if (contactDays !== null) {
|
||
if (contactDays <= 7) {
|
||
score += 20;
|
||
signals.push({
|
||
id: 'contact_recent',
|
||
delta: +20,
|
||
detail: `Contact logged ${contactDays}d ago — fresh.`,
|
||
});
|
||
} else if (contactDays <= 14) {
|
||
score += 10;
|
||
signals.push({
|
||
id: 'contact_warm',
|
||
delta: +10,
|
||
detail: `Contact logged ${contactDays}d ago — still warm.`,
|
||
});
|
||
} else if (contactDays >= 30) {
|
||
score -= 15;
|
||
signals.push({
|
||
id: 'contact_stale',
|
||
delta: -15,
|
||
detail: `No contact logged in ${contactDays}d — going cold.`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Stage progress: every step past enquiry signals momentum.
|
||
const stageIdx = PIPELINE_STAGES.indexOf(input.pipelineStage as PipelineStage);
|
||
if (stageIdx > 0) {
|
||
const bonus = Math.min(30, stageIdx * 10);
|
||
score += bonus;
|
||
signals.push({
|
||
id: 'stage_progress',
|
||
delta: +bonus,
|
||
detail: `Reached ${input.pipelineStage.replace(/_/g, ' ')} stage.`,
|
||
});
|
||
}
|
||
|
||
// Age penalty for stuck top-of-funnel leads.
|
||
const firstDays = daysSince(input.dateFirstContact);
|
||
if (
|
||
firstDays !== null &&
|
||
firstDays >= 30 &&
|
||
(input.pipelineStage === 'enquiry' || input.pipelineStage === 'qualified')
|
||
) {
|
||
score -= 10;
|
||
signals.push({
|
||
id: 'stuck_top_funnel',
|
||
delta: -10,
|
||
detail: `Deal opened ${firstDays}d ago and still pre-EOI.`,
|
||
});
|
||
}
|
||
|
||
// EOI in-flight too long.
|
||
const eoiSentDays = daysSince(input.dateEoiSent);
|
||
if (
|
||
eoiSentDays !== null &&
|
||
eoiSentDays >= 14 &&
|
||
input.eoiDocStatus !== 'signed' &&
|
||
!input.dateEoiSigned
|
||
) {
|
||
score -= 10;
|
||
signals.push({
|
||
id: 'eoi_awaiting',
|
||
delta: -10,
|
||
detail: `EOI awaiting signature for ${eoiSentDays}d.`,
|
||
});
|
||
}
|
||
|
||
// Reservation signed but deposit not received.
|
||
const reservationDays = daysSince(input.dateReservationSigned);
|
||
if (reservationDays !== null && reservationDays >= 21 && !input.dateDepositReceived) {
|
||
score -= 10;
|
||
signals.push({
|
||
id: 'deposit_pending',
|
||
delta: -10,
|
||
detail: `Reservation signed ${reservationDays}d ago but deposit not recorded.`,
|
||
});
|
||
}
|
||
|
||
// Contract awaiting signature.
|
||
const contractSentDays = daysSince(input.dateContractSent);
|
||
if (
|
||
contractSentDays !== null &&
|
||
contractSentDays >= 14 &&
|
||
input.contractDocStatus !== 'signed' &&
|
||
!input.dateContractSigned
|
||
) {
|
||
score -= 10;
|
||
signals.push({
|
||
id: 'contract_awaiting',
|
||
delta: -10,
|
||
detail: `Contract awaiting signature for ${contractSentDays}d.`,
|
||
});
|
||
}
|
||
|
||
// Clamp to [0, 100].
|
||
score = Math.max(0, Math.min(100, score));
|
||
|
||
const pulse: DealHealth['pulse'] = score >= 70 ? 'hot' : score >= 40 ? 'warm' : 'cold';
|
||
return { score, pulse, signals };
|
||
}
|