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
322 lines
11 KiB
TypeScript
322 lines
11 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;
|
||
/** 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 };
|
||
}
|