Files
pn-new-crm/src/lib/services/deal-health.ts
Matt 6b28459c45 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>
2026-05-14 03:39:21 +02:00

213 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (0100, 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 };
}