Files
pn-new-crm/src/lib/services/deal-health.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

322 lines
11 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;
/** Phase 2 - risk signals captured in deal-pulse-trigger-audit.md.
* Any of these populated → strong negative signal pushed onto the
* chip so reps can triage cooling deals at a glance. All optional;
* callers populate from existing schema (document_events for
* declines, reservations.cancelled_at, interest_berths conflict). */
dateDocumentDeclined?: string | Date | null;
dateReservationCancelled?: string | Date | null;
dateBerthSoldToOther?: string | Date | null;
/** Optional per-port config that lets admins disable individual
* signals or rename their tier labels. When omitted, defaults
* apply - current callers stay byte-identical without changes. */
config?: DealHealthConfig | null;
}
export interface DealHealthConfig {
enabled?: boolean;
signals?: Partial<Record<string, boolean>>;
labels?: {
hot?: string;
warm?: string;
cold?: string;
};
}
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);
}
function signalEnabled(input: DealHealthInput, signalId: string): boolean {
const flag = input.config?.signals?.[signalId];
return flag !== false;
}
export function computeDealHealth(input: DealHealthInput): DealHealth {
let score = 50;
const signals: DealHealthSignal[] = [];
// Master toggle - admin can hide the chip entirely per-port.
// Returning the neutral shape keeps callers happy; the chip uses
// a separate "visible" prop derived from config.enabled before
// calling compute. We still return real data so reports can read it.
// 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.`,
});
}
// Phase 2 - positive momentum signals.
// EOI sent recently: forward motion that the original score didn't
// surface (the awaiting penalty only fires after 14d). Brightens the
// chip for fresh-EOI deals so reps see progress.
const eoiSentDaysPos = daysSince(input.dateEoiSent);
if (eoiSentDaysPos !== null && eoiSentDaysPos <= 14 && signalEnabled(input, 'eoi_sent_recent')) {
score += 5;
signals.push({
id: 'eoi_sent_recent',
delta: +5,
detail: `EOI sent ${eoiSentDaysPos}d ago - awaiting signature.`,
});
}
// Deposit received: near-commit signal. Was previously invisible on
// the chip even though it's one of the strongest forward signals.
const depositDays = daysSince(input.dateDepositReceived);
if (depositDays !== null && signalEnabled(input, 'deposit_received')) {
score += 15;
signals.push({
id: 'deposit_received',
delta: +15,
detail: `Deposit received ${depositDays}d ago.`,
});
}
// Contract signed: closed-loop reinforcement. The interest's outcome
// flips to 'won' shortly after, but until that happens the contract
// signature is a strong positive signal.
const contractSignedDays = daysSince(input.dateContractSigned);
if (contractSignedDays !== null && signalEnabled(input, 'contract_signed')) {
score += 10;
signals.push({
id: 'contract_signed',
delta: +10,
detail: `Contract signed ${contractSignedDays}d ago.`,
});
}
// Phase 2 - risk signals. These are the strongest cooling indicators
// and previously didn't move the chip at all, leaving reps to discover
// them by clicking into the detail page.
// Document declined (EOI/contract/reservation rejected by the client).
const declinedDays = daysSince(input.dateDocumentDeclined);
if (declinedDays !== null && signalEnabled(input, 'document_declined')) {
score -= 25;
signals.push({
id: 'document_declined',
delta: -25,
detail: `Client declined a document ${declinedDays}d ago - intervene.`,
});
}
// Reservation cancelled: booked-then-cancelled is a high-value warning.
const reservationCancelledDays = daysSince(input.dateReservationCancelled);
if (reservationCancelledDays !== null && signalEnabled(input, 'reservation_cancelled')) {
score -= 20;
signals.push({
id: 'reservation_cancelled',
delta: -20,
detail: `Reservation cancelled ${reservationCancelledDays}d ago.`,
});
}
// Berth resold to a different deal - this interest is effectively dead
// (the asset they wanted is gone). Sharp drop so the chip turns cold.
const berthSoldDays = daysSince(input.dateBerthSoldToOther);
if (berthSoldDays !== null && signalEnabled(input, 'berth_sold_to_other')) {
score -= 30;
signals.push({
id: 'berth_sold_to_other',
delta: -30,
detail: `Primary berth was sold to a different deal ${berthSoldDays}d ago.`,
});
}
// 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 };
}