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 };
|
|||
|
|
}
|