Implement Phase 2 features for Campaign Command Center

Adds 14 new features across the dashboard:
- Deal Risk Scoring (0-100) with risk factors on pipeline deals
- Account Health Score with multi-factor analysis on account cards
- Next Best Action Engine on Executive Overview
- Account Notes with pin/edit/delete in activity overlay and account detail
- Stakeholder Contact Map with role, sentiment, email, LinkedIn
- Competitive Intelligence Tracker with multi-select competitors and filtering
- CSV/Excel Export on accounts, activities, and pipeline pages
- Data Quality Dashboard in admin with completeness scoring
- Campaign Playbook Templates with 5 default plays and account progress tracking
- Goals & District Targets with quarterly tracking and progress bars
- Weekly Status Report (printable) and Executive Summary (printable)
- Activity Effectiveness Scoring showing pipeline conversion by activity type
- Mobile Activity FAB for quick activity logging on mobile
- Multi-User Auth (NextAuth + Google OAuth, @broadcom.com domain, role-based)

Also adds Phase 2 API route, scoring utilities, sidebar navigation updates,
and database schema extensions for notes, contacts, snapshots, playbooks,
and district targets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 18:07:46 -04:00
parent 3c8c8ee594
commit d2d5632086
28 changed files with 3541 additions and 25 deletions

View File

@@ -8,6 +8,13 @@ import {
ImplementationRecord,
MetricTarget,
DashboardData,
AccountNote,
Contact,
Snapshot,
PipelineSnapshot,
Playbook,
PlaybookProgress,
DistrictTarget,
} from '@/types/data';
const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db');
@@ -23,10 +30,26 @@ function getDb(): Database.Database {
_db = new Database(DB_PATH);
_db.pragma('journal_mode = WAL');
initSchema(_db);
migrateSchema(_db);
seedDefaultPlaybooks();
}
return _db;
}
function migrateSchema(db: Database.Database) {
const cols = db.prepare("PRAGMA table_info(accounts)").all() as { name: string }[];
const colNames = new Set(cols.map(c => c.name));
if (!colNames.has('Competitors')) {
db.exec("ALTER TABLE accounts ADD COLUMN Competitors TEXT DEFAULT NULL");
}
if (!colNames.has('Google_Drive_URL')) {
db.exec("ALTER TABLE accounts ADD COLUMN Google_Drive_URL TEXT DEFAULT NULL");
}
if (!colNames.has('Campaign_Artifacts_URL')) {
db.exec("ALTER TABLE accounts ADD COLUMN Campaign_Artifacts_URL TEXT DEFAULT NULL");
}
}
function initSchema(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS accounts (
@@ -123,11 +146,89 @@ function initSchema(db: Database.Database) {
Notes TEXT
);
CREATE TABLE IF NOT EXISTS account_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Account_Name TEXT NOT NULL,
note_text TEXT NOT NULL,
next_action TEXT,
is_pinned INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
created_by TEXT
);
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Account_Name TEXT NOT NULL,
Contact_Name TEXT NOT NULL,
Role TEXT,
Sentiment TEXT,
Email TEXT,
LinkedIn_URL TEXT
);
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
period_type TEXT NOT NULL,
total_accounts INTEGER DEFAULT 0,
accounts_touched INTEGER DEFAULT 0,
total_pipeline REAL DEFAULT 0,
open_opps INTEGER DEFAULT 0,
closed_won_amount REAL DEFAULT 0,
total_activities INTEGER DEFAULT 0,
accounts_by_priority_json TEXT,
pipeline_by_stage_json TEXT,
district_data_json TEXT
);
CREATE TABLE IF NOT EXISTS pipeline_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
Opportunity_ID TEXT NOT NULL,
Account_Name TEXT NOT NULL,
Stage TEXT NOT NULL,
Amount_USD REAL NOT NULL DEFAULT 0,
Forecast_Category TEXT
);
CREATE TABLE IF NOT EXISTS playbooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
play_name TEXT NOT NULL UNIQUE,
description TEXT,
steps_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS playbook_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Account_Name TEXT NOT NULL,
playbook_id INTEGER NOT NULL,
current_step INTEGER DEFAULT 0,
status TEXT DEFAULT 'In Progress',
started_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (playbook_id) REFERENCES playbooks(id)
);
CREATE TABLE IF NOT EXISTS district_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
District_Name TEXT NOT NULL,
quarter TEXT NOT NULL,
activities_per_week REAL DEFAULT 0,
accounts_touched INTEGER DEFAULT 0,
pipeline_generated REAL DEFAULT 0,
UNIQUE(District_Name, quarter)
);
CREATE INDEX IF NOT EXISTS idx_pipeline_account ON pipeline(Account_Name);
CREATE INDEX IF NOT EXISTS idx_pipeline_district ON pipeline(District_Name);
CREATE INDEX IF NOT EXISTS idx_activities_account ON activities(Account_Name);
CREATE INDEX IF NOT EXISTS idx_activities_date ON activities(Activity_Date);
CREATE INDEX IF NOT EXISTS idx_impl_account ON implementations(Account_Name);
CREATE INDEX IF NOT EXISTS idx_notes_account ON account_notes(Account_Name);
CREATE INDEX IF NOT EXISTS idx_contacts_account ON contacts(Account_Name);
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON snapshots(snapshot_date);
CREATE INDEX IF NOT EXISTS idx_pipeline_snap_date ON pipeline_snapshots(snapshot_date);
CREATE INDEX IF NOT EXISTS idx_playbook_progress_account ON playbook_progress(Account_Name);
`);
}
@@ -461,6 +562,225 @@ export function upsertPipeline(record: Record<string, unknown>) {
});
}
// --- Account Notes ---
export function getAccountNotes(accountName: string): AccountNote[] {
const db = getDb();
return db.prepare('SELECT * FROM account_notes WHERE Account_Name = ? ORDER BY is_pinned DESC, created_at DESC').all(accountName) as AccountNote[];
}
export function addAccountNote(note: { Account_Name: string; note_text: string; next_action?: string | null; is_pinned?: number; created_by?: string | null }): AccountNote {
const db = getDb();
const result = db.prepare(`INSERT INTO account_notes (Account_Name, note_text, next_action, is_pinned, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)`).run(
note.Account_Name, note.note_text, note.next_action || null, note.is_pinned || 0, new Date().toISOString(), note.created_by || null
);
return db.prepare('SELECT * FROM account_notes WHERE id = ?').get(result.lastInsertRowid) as AccountNote;
}
export function deleteAccountNote(id: number) {
getDb().prepare('DELETE FROM account_notes WHERE id = ?').run(id);
}
export function updateAccountNote(id: number, updates: { note_text?: string; next_action?: string | null; is_pinned?: number }) {
const db = getDb();
const sets: string[] = [];
const vals: unknown[] = [];
if (updates.note_text !== undefined) { sets.push('note_text = ?'); vals.push(updates.note_text); }
if (updates.next_action !== undefined) { sets.push('next_action = ?'); vals.push(updates.next_action); }
if (updates.is_pinned !== undefined) { sets.push('is_pinned = ?'); vals.push(updates.is_pinned); }
if (sets.length === 0) return;
vals.push(id);
db.prepare(`UPDATE account_notes SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
}
// --- Contacts ---
export function getContacts(accountName: string): Contact[] {
const db = getDb();
return db.prepare('SELECT * FROM contacts WHERE Account_Name = ? ORDER BY Contact_Name').all(accountName) as Contact[];
}
export function getAllContacts(): Contact[] {
return getDb().prepare('SELECT * FROM contacts ORDER BY Account_Name, Contact_Name').all() as Contact[];
}
export function upsertContact(contact: { id?: number; Account_Name: string; Contact_Name: string; Role?: string | null; Sentiment?: string | null; Email?: string | null; LinkedIn_URL?: string | null }): Contact {
const db = getDb();
if (contact.id) {
db.prepare(`UPDATE contacts SET Contact_Name=?, Role=?, Sentiment=?, Email=?, LinkedIn_URL=? WHERE id=?`).run(
contact.Contact_Name, contact.Role || null, contact.Sentiment || null, contact.Email || null, contact.LinkedIn_URL || null, contact.id
);
return db.prepare('SELECT * FROM contacts WHERE id = ?').get(contact.id) as Contact;
}
const result = db.prepare(`INSERT INTO contacts (Account_Name, Contact_Name, Role, Sentiment, Email, LinkedIn_URL) VALUES (?, ?, ?, ?, ?, ?)`).run(
contact.Account_Name, contact.Contact_Name, contact.Role || null, contact.Sentiment || null, contact.Email || null, contact.LinkedIn_URL || null
);
return db.prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid) as Contact;
}
export function deleteContact(id: number) {
getDb().prepare('DELETE FROM contacts WHERE id = ?').run(id);
}
// --- Snapshots ---
export function getSnapshots(periodType?: string): Snapshot[] {
const db = getDb();
if (periodType) return db.prepare('SELECT * FROM snapshots WHERE period_type = ? ORDER BY snapshot_date DESC').all(periodType) as Snapshot[];
return db.prepare('SELECT * FROM snapshots ORDER BY snapshot_date DESC').all() as Snapshot[];
}
export function captureSnapshot(periodType: 'weekly' | 'monthly' | 'quarterly') {
const db = getDb();
const today = new Date().toISOString().split('T')[0];
const existing = db.prepare('SELECT id FROM snapshots WHERE snapshot_date = ? AND period_type = ?').get(today, periodType);
if (existing) return existing;
const accounts = db.prepare('SELECT * FROM accounts').all() as AccountRecord[];
const pipeline = db.prepare("SELECT * FROM pipeline WHERE Status != 'Closed Lost'").all() as PipelineRecord[];
const activities = db.prepare('SELECT * FROM activities').all() as ActivityRecord[];
const openPipeline = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won');
const priorityCounts: Record<string, number> = {};
accounts.forEach(a => { priorityCounts[a.Priority || 'Unset'] = (priorityCounts[a.Priority || 'Unset'] || 0) + 1; });
const stageCounts: Record<string, { count: number; amount: number }> = {};
openPipeline.forEach(p => {
if (!stageCounts[p.Stage]) stageCounts[p.Stage] = { count: 0, amount: 0 };
stageCounts[p.Stage].count++;
stageCounts[p.Stage].amount += p.Amount_USD;
});
const districtData: Record<string, { activities: number; pipeline: number; touched: number }> = {};
activities.forEach(a => {
if (!districtData[a.District_Name]) districtData[a.District_Name] = { activities: 0, pipeline: 0, touched: 0 };
districtData[a.District_Name].activities++;
});
openPipeline.forEach(p => {
if (!districtData[p.District_Name]) districtData[p.District_Name] = { activities: 0, pipeline: 0, touched: 0 };
districtData[p.District_Name].pipeline += p.Amount_USD;
});
const touchedAccounts = new Set(activities.map(a => a.Account_Name));
const result = db.prepare(`INSERT INTO snapshots (snapshot_date, period_type, total_accounts, accounts_touched, total_pipeline, open_opps, closed_won_amount, total_activities, accounts_by_priority_json, pipeline_by_stage_json, district_data_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
today, periodType, accounts.length, touchedAccounts.size,
openPipeline.reduce((s, p) => s + p.Amount_USD, 0), openPipeline.length,
closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || p.Amount_USD), 0), activities.length,
JSON.stringify(priorityCounts), JSON.stringify(stageCounts), JSON.stringify(districtData)
);
// Also snapshot individual pipeline records for waterfall tracking
const pipeSnap = db.prepare(`INSERT INTO pipeline_snapshots (snapshot_date, Opportunity_ID, Account_Name, Stage, Amount_USD, Forecast_Category) VALUES (?, ?, ?, ?, ?, ?)`);
const tx = db.transaction(() => {
for (const p of pipeline) {
pipeSnap.run(today, p.Opportunity_ID, p.Account_Name, p.Stage, p.Amount_USD, p.Forecast_Category);
}
});
tx();
return { id: result.lastInsertRowid };
}
// --- Pipeline Snapshots (for waterfall) ---
export function getPipelineSnapshots(date?: string): PipelineSnapshot[] {
const db = getDb();
if (date) return db.prepare('SELECT * FROM pipeline_snapshots WHERE snapshot_date = ?').all(date) as PipelineSnapshot[];
return db.prepare('SELECT DISTINCT snapshot_date FROM pipeline_snapshots ORDER BY snapshot_date DESC').all() as PipelineSnapshot[];
}
// --- Playbooks ---
export function getPlaybooks(): Playbook[] {
return getDb().prepare('SELECT * FROM playbooks ORDER BY play_name').all() as Playbook[];
}
export function upsertPlaybook(playbook: { id?: number; play_name: string; description?: string | null; steps_json: string }) {
const db = getDb();
if (playbook.id) {
db.prepare('UPDATE playbooks SET play_name=?, description=?, steps_json=? WHERE id=?').run(playbook.play_name, playbook.description || null, playbook.steps_json, playbook.id);
} else {
db.prepare('INSERT INTO playbooks (play_name, description, steps_json) VALUES (?, ?, ?) ON CONFLICT(play_name) DO UPDATE SET description=excluded.description, steps_json=excluded.steps_json').run(playbook.play_name, playbook.description || null, playbook.steps_json);
}
}
export function getPlaybookProgress(accountName?: string): PlaybookProgress[] {
const db = getDb();
if (accountName) return db.prepare('SELECT * FROM playbook_progress WHERE Account_Name = ?').all(accountName) as PlaybookProgress[];
return db.prepare('SELECT * FROM playbook_progress ORDER BY updated_at DESC').all() as PlaybookProgress[];
}
export function upsertPlaybookProgress(progress: { id?: number; Account_Name: string; playbook_id: number; current_step: number; status: string }) {
const db = getDb();
const now = new Date().toISOString();
if (progress.id) {
db.prepare('UPDATE playbook_progress SET current_step=?, status=?, updated_at=? WHERE id=?').run(progress.current_step, progress.status, now, progress.id);
} else {
db.prepare('INSERT INTO playbook_progress (Account_Name, playbook_id, current_step, status, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(
progress.Account_Name, progress.playbook_id, progress.current_step, progress.status, now, now
);
}
}
export function seedDefaultPlaybooks() {
const db = getDb();
const existing = db.prepare('SELECT COUNT(*) as c FROM playbooks').get() as { c: number };
if (existing.c > 0) return;
const playbooks = [
{ play_name: 'New Logo Acquisition', description: 'Full sales cycle for net-new accounts', steps_json: JSON.stringify(['Research & Targeting', 'Initial Outreach', 'Discovery Meeting', 'Demo / Workshop', 'Executive Alignment', 'Proposal & Negotiation', 'Close']) },
{ play_name: 'Attach / Cross-Sell', description: 'Expand within existing accounts', steps_json: JSON.stringify(['Account Review & Whitespace ID', 'Champion Alignment', 'Discovery / Needs Assessment', 'Demo / POC', 'Business Case', 'Close']) },
{ play_name: 'Renewal Defense', description: 'Protect existing contracts approaching renewal', steps_json: JSON.stringify(['Renewal Assessment', 'Stakeholder Re-engagement', 'Value Realization Review', 'QBR / Executive Briefing', 'Renewal Negotiation', 'Contract Execution']) },
{ play_name: 'Competitive Displacement', description: 'Displace an incumbent competitor', steps_json: JSON.stringify(['Competitive Intel Gathering', 'Pain Point Discovery', 'Differentiation Demo', 'Executive Sponsor Alignment', 'POC / Bake-off', 'Proposal & Close']) },
{ play_name: 'Inbound Response', description: 'Respond to inbound interest or RFP', steps_json: JSON.stringify(['Qualify Inbound', 'Discovery Call', 'Technical Deep Dive', 'Proposal / RFP Response', 'Negotiation', 'Close']) },
];
const stmt = db.prepare('INSERT INTO playbooks (play_name, description, steps_json) VALUES (?, ?, ?)');
const tx = db.transaction(() => { for (const p of playbooks) stmt.run(p.play_name, p.description, p.steps_json); });
tx();
}
// --- District Targets ---
export function getDistrictTargets(quarter?: string): DistrictTarget[] {
const db = getDb();
if (quarter) return db.prepare('SELECT * FROM district_targets WHERE quarter = ? ORDER BY District_Name').all(quarter) as DistrictTarget[];
return db.prepare('SELECT * FROM district_targets ORDER BY quarter DESC, District_Name').all() as DistrictTarget[];
}
export function upsertDistrictTarget(target: { District_Name: string; quarter: string; activities_per_week: number; accounts_touched: number; pipeline_generated: number }) {
const db = getDb();
db.prepare(`INSERT INTO district_targets (District_Name, quarter, activities_per_week, accounts_touched, pipeline_generated) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(District_Name, quarter) DO UPDATE SET activities_per_week=excluded.activities_per_week, accounts_touched=excluded.accounts_touched, pipeline_generated=excluded.pipeline_generated`).run(
target.District_Name, target.quarter, target.activities_per_week, target.accounts_touched, target.pipeline_generated
);
}
// --- Data Quality ---
export function getDataQualityReport() {
const db = getDb();
const accounts = db.prepare('SELECT * FROM accounts').all() as AccountRecord[];
const pipeline = db.prepare("SELECT * FROM pipeline WHERE Status != 'Closed Lost' AND Stage NOT IN ('06-Closed Won', '07-Closed Lost')").all() as PipelineRecord[];
const issues: { type: string; severity: string; message: string; table: string; record_id: string }[] = [];
for (const a of accounts) {
if (!a.Priority || a.Priority === '') issues.push({ type: 'missing_field', severity: 'medium', message: `Missing Priority`, table: 'accounts', record_id: a.Account_Name });
if (!a.Tier || a.Tier === '') issues.push({ type: 'missing_field', severity: 'medium', message: `Missing Tier`, table: 'accounts', record_id: a.Account_Name });
if (!a.AD) issues.push({ type: 'missing_field', severity: 'low', message: `Missing AD assignment`, table: 'accounts', record_id: a.Account_Name });
if (!a.Next_Renewal_Date) issues.push({ type: 'missing_field', severity: 'low', message: `Missing Renewal Date`, table: 'accounts', record_id: a.Account_Name });
}
for (const p of pipeline) {
if (!p.Next_Step) issues.push({ type: 'missing_field', severity: 'high', message: `No Next Step defined`, table: 'pipeline', record_id: p.Opportunity_ID });
if (p.Expected_Close_Date && p.Expected_Close_Date < new Date().toISOString().split('T')[0]) issues.push({ type: 'stale_data', severity: 'high', message: `Close date in the past`, table: 'pipeline', record_id: p.Opportunity_ID });
if (!p.Champion_Name) issues.push({ type: 'missing_field', severity: 'medium', message: `No Champion identified`, table: 'pipeline', record_id: p.Opportunity_ID });
}
const totalFields = accounts.length * 5 + pipeline.length * 3;
const missingFields = issues.filter(i => i.type === 'missing_field').length;
const completeness = totalFields > 0 ? Math.round(((totalFields - missingFields) / totalFields) * 100) : 100;
return { issues, completeness, totalAccounts: accounts.length, totalPipeline: pipeline.length };
}
export function upsertTarget(record: Record<string, unknown>) {
const db = getDb();
const id = (record.Implementation_ID as string) || `IMPL-${Date.now()}`;

View File

@@ -90,6 +90,9 @@ function generateAccounts(): AccountRecord[] {
Next_Renewal_EAR: null,
Anchor_Contract_Date: null,
Anchor_Contract_EAR: null,
Google_Drive_URL: null,
Campaign_Artifacts_URL: null,
Competitors: null,
};
});
}

198
src/lib/scoring.ts Normal file
View File

@@ -0,0 +1,198 @@
import { PipelineRecord, AccountRecord, ActivityRecord } from '@/types/data';
import { differenceInDays, parseISO } from 'date-fns';
// --- Deal Risk Scoring ---
export interface DealRisk {
Opportunity_ID: string;
Account_Name: string;
risk_score: number; // 0-100, higher = more risky
risk_level: 'Low' | 'Medium' | 'High' | 'Critical';
risk_factors: string[];
}
export function scoreDealRisk(
deal: PipelineRecord,
activities: ActivityRecord[],
allDeals: PipelineRecord[]
): DealRisk {
const factors: string[] = [];
let score = 0;
const now = new Date();
// Skip closed deals
if (deal.Stage === '06-Closed Won' || deal.Stage === '07-Closed Lost') {
return { Opportunity_ID: deal.Opportunity_ID, Account_Name: deal.Account_Name, risk_score: 0, risk_level: 'Low', risk_factors: [] };
}
// Factor 1: Close date in the past
if (deal.Expected_Close_Date) {
const closeDate = parseISO(deal.Expected_Close_Date);
const daysOverdue = differenceInDays(now, closeDate);
if (daysOverdue > 30) {
score += 30;
factors.push(`Close date ${daysOverdue} days overdue`);
} else if (daysOverdue > 0) {
score += 20;
factors.push(`Close date ${daysOverdue} days past`);
}
}
// Factor 2: No next step defined
if (!deal.Next_Step || deal.Next_Step.trim() === '') {
score += 20;
factors.push('No next step defined');
}
// Factor 3: Days in stage (aging)
if (deal.Stage_Entered_Date || deal.Created_Date) {
const stageDate = parseISO(deal.Stage_Entered_Date || deal.Created_Date);
const daysInStage = differenceInDays(now, stageDate);
// Calculate median days in stage for comparison
const sameStageDays = allDeals
.filter(d => d.Stage === deal.Stage && d.Opportunity_ID !== deal.Opportunity_ID)
.map(d => differenceInDays(now, parseISO(d.Stage_Entered_Date || d.Created_Date)));
const medianDays = sameStageDays.length > 0
? sameStageDays.sort((a, b) => a - b)[Math.floor(sameStageDays.length / 2)]
: 30;
if (daysInStage > medianDays * 2) {
score += 25;
factors.push(`${daysInStage} days in stage (2x median)`);
} else if (daysInStage > medianDays * 1.5) {
score += 15;
factors.push(`${daysInStage} days in stage (above median)`);
}
}
// Factor 4: No recent activity on the account
const accountActivities = activities.filter(a => a.Account_Name === deal.Account_Name);
if (accountActivities.length === 0) {
score += 20;
factors.push('No activities logged for account');
} else {
const lastActivity = accountActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
const daysSinceActivity = differenceInDays(now, parseISO(lastActivity.Activity_Date));
if (daysSinceActivity > 30) {
score += 15;
factors.push(`No activity in ${daysSinceActivity} days`);
}
}
// Factor 5: No champion identified
if (!deal.Champion_Name) {
score += 10;
factors.push('No champion identified');
}
const risk_level = score >= 60 ? 'Critical' : score >= 40 ? 'High' : score >= 20 ? 'Medium' : 'Low';
return {
Opportunity_ID: deal.Opportunity_ID,
Account_Name: deal.Account_Name,
risk_score: Math.min(score, 100),
risk_level,
risk_factors: factors,
};
}
// --- Account Health Score ---
export interface AccountHealth {
Account_Name: string;
health_score: number; // 0-100, higher = healthier
health_level: 'Excellent' | 'Good' | 'Fair' | 'Poor' | 'Critical';
factors: { label: string; score: number; max: number }[];
}
export function scoreAccountHealth(
account: AccountRecord,
activities: ActivityRecord[],
pipeline: PipelineRecord[]
): AccountHealth {
const now = new Date();
const factors: { label: string; score: number; max: number }[] = [];
// Factor 1: Activity Recency (0-25 points)
const acctActivities = activities.filter(a => a.Account_Name === account.Account_Name);
let recencyScore = 0;
if (acctActivities.length > 0) {
const lastTouch = acctActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
const daysSince = differenceInDays(now, parseISO(lastTouch.Activity_Date));
if (daysSince <= 7) recencyScore = 25;
else if (daysSince <= 14) recencyScore = 20;
else if (daysSince <= 30) recencyScore = 15;
else if (daysSince <= 60) recencyScore = 8;
else recencyScore = 0;
}
factors.push({ label: 'Activity Recency', score: recencyScore, max: 25 });
// Factor 2: Touch Frequency (0-20 points)
let frequencyScore = 0;
const touchCount = account.Touch_Count || 0;
if (touchCount >= 5) frequencyScore = 20;
else if (touchCount >= 3) frequencyScore = 15;
else if (touchCount >= 2) frequencyScore = 10;
else if (touchCount >= 1) frequencyScore = 5;
factors.push({ label: 'Touch Frequency', score: frequencyScore, max: 20 });
// Factor 3: Pipeline Presence (0-20 points)
const acctPipeline = pipeline.filter(p => p.Account_Name === account.Account_Name && p.Stage !== '07-Closed Lost');
const openPipeline = acctPipeline.filter(p => p.Stage !== '06-Closed Won');
const closedWon = acctPipeline.filter(p => p.Stage === '06-Closed Won');
let pipelineScore = 0;
if (closedWon.length > 0) pipelineScore = 20;
else if (openPipeline.length > 0) pipelineScore = 12;
else pipelineScore = 0;
factors.push({ label: 'Pipeline Presence', score: pipelineScore, max: 20 });
// Factor 4: Renewal Risk (0-20 points — inverted: closer renewal = lower score unless engaged)
let renewalScore = 10; // default if no renewal
if (account.Next_Renewal_Date) {
const daysToRenewal = differenceInDays(parseISO(account.Next_Renewal_Date), now);
if (daysToRenewal <= 90) {
renewalScore = recencyScore >= 15 ? 20 : 0; // close renewal + recent engagement = good; close + no engagement = bad
} else if (daysToRenewal <= 180) {
renewalScore = recencyScore >= 10 ? 15 : 5;
} else {
renewalScore = 10;
}
}
factors.push({ label: 'Renewal Position', score: renewalScore, max: 20 });
// Factor 5: Engagement Quality (0-15 points)
let qualityScore = 0;
const activityTypes = new Set(acctActivities.map(a => a.Activity_Type));
if (activityTypes.has('Exec Meeting')) qualityScore += 5;
if (activityTypes.has('Demo') || activityTypes.has('Workshop')) qualityScore += 5;
if (activityTypes.has('Discovery')) qualityScore += 3;
if (activityTypes.size >= 3) qualityScore += 2;
qualityScore = Math.min(qualityScore, 15);
factors.push({ label: 'Engagement Quality', score: qualityScore, max: 15 });
const total = factors.reduce((s, f) => s + f.score, 0);
const health_level = total >= 80 ? 'Excellent' : total >= 60 ? 'Good' : total >= 40 ? 'Fair' : total >= 20 ? 'Poor' : 'Critical';
return {
Account_Name: account.Account_Name,
health_score: total,
health_level,
factors,
};
}
export const HEALTH_LEVEL_COLORS: Record<string, string> = {
'Excellent': '#16A34A',
'Good': '#61A60E',
'Fair': '#F59E0B',
'Poor': '#EA580C',
'Critical': '#DC2626',
};
export const RISK_LEVEL_COLORS: Record<string, string> = {
'Low': '#61A60E',
'Medium': '#F59E0B',
'High': '#EA580C',
'Critical': '#DC2626',
};