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:
320
src/lib/db.ts
320
src/lib/db.ts
@@ -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()}`;
|
||||
|
||||
@@ -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
198
src/lib/scoring.ts
Normal 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',
|
||||
};
|
||||
Reference in New Issue
Block a user