Files
campaign-command-center/src/lib/db.ts
Chris Olson 1a1cf7e652 Add planned activities and follow-ups workflow
Activities with future dates are automatically tagged as Planned. Once
the date passes they become Pending Follow-Ups requiring resolution.

- Added Status column to activities (Planned/Completed/Did Not Occur)
- Built Follow-Ups subview with expandable cards, outcome capture,
  and Create Next Step workflow for scheduling follow-on activities
- Upcoming Planned table shows scheduled future activities
- Executive Overview gains Meetings Scheduled and Follow-Ups Pending KPIs
- Planned/Follow-Up badges appear in the activity detail table
- Admin page includes Status field for activities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-01 10:22:20 -04:00

857 lines
40 KiB
TypeScript

import Database from 'better-sqlite3';
import path from 'path';
import {
PipelineRecord,
AccountRecord,
ActivityRecord,
TargetRecord,
ImplementationRecord,
MetricTarget,
DashboardData,
AccountNote,
Contact,
Snapshot,
PipelineSnapshot,
Playbook,
PlaybookProgress,
DistrictTarget,
} from '@/types/data';
const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db');
let _db: Database.Database | null = null;
function getDb(): Database.Database {
if (!_db) {
const fs = require('fs');
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
_db = new Database(DB_PATH);
_db.pragma('journal_mode = WAL');
initSchema(_db);
migrateSchema(_db);
seedDefaultPlaybooks();
seedCPMValues();
}
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");
}
if (!colNames.has('CPM')) {
db.exec("ALTER TABLE accounts ADD COLUMN CPM TEXT DEFAULT NULL");
}
const actCols = db.prepare("PRAGMA table_info(activities)").all() as { name: string }[];
const actColNames = new Set(actCols.map(c => c.name));
if (!actColNames.has('Status')) {
db.exec("ALTER TABLE activities ADD COLUMN Status TEXT DEFAULT NULL");
}
}
function initSchema(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS accounts (
Account_Name TEXT PRIMARY KEY,
District_Name TEXT NOT NULL DEFAULT '',
Tier TEXT NOT NULL DEFAULT '',
Priority TEXT NOT NULL DEFAULT '',
AgentMinder_Status TEXT NOT NULL DEFAULT 'Not Touched',
Current_ARR_USD REAL,
Touch_Count INTEGER DEFAULT 0,
Date_First_Touched TEXT,
Date_Last_Touched TEXT,
MAP_In_Place_YN TEXT,
Company_URL TEXT,
Area_Sales_Leader TEXT,
DM TEXT,
AD TEXT,
IMS_BA TEXT,
CPM TEXT,
Logo_URL TEXT,
Next_Renewal_Date TEXT,
Next_Renewal_EAR REAL,
Anchor_Contract_Date TEXT,
Anchor_Contract_EAR REAL
);
CREATE TABLE IF NOT EXISTS pipeline (
Opportunity_ID TEXT PRIMARY KEY,
Account_Name TEXT NOT NULL,
District_Name TEXT NOT NULL DEFAULT '',
Stage TEXT NOT NULL DEFAULT '01-Qualified',
Forecast_Category TEXT NOT NULL DEFAULT 'Pipeline',
Amount_USD REAL NOT NULL DEFAULT 0,
Closed_Amount_USD REAL,
Probability_Pct INTEGER NOT NULL DEFAULT 0,
Created_Date TEXT NOT NULL,
Expected_Close_Date TEXT NOT NULL DEFAULT '',
Closed_Date TEXT,
Champion_Name TEXT,
Economic_Buyer TEXT,
Next_Step TEXT,
Next_Step_Date TEXT,
Primary_Objection TEXT,
Competitor TEXT,
Source_Play TEXT,
Product TEXT NOT NULL DEFAULT 'AgentMinder',
Deal_Type TEXT,
Status TEXT DEFAULT 'Open',
Stage_Entered_Date TEXT,
Tier TEXT,
MAP_In_Place_YN TEXT
);
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Activity_ID TEXT,
Activity_Date TEXT NOT NULL,
Activity_Type TEXT NOT NULL,
Account_Name TEXT NOT NULL,
District_Name TEXT NOT NULL DEFAULT '',
Contact_Name TEXT,
Notes TEXT,
Play TEXT,
Channel TEXT,
Persona TEXT,
Outcome TEXT,
Logged_By TEXT,
Status TEXT
);
CREATE TABLE IF NOT EXISTS implementations (
Implementation_ID TEXT PRIMARY KEY,
Opportunity_ID TEXT,
Account_Name TEXT NOT NULL,
District_Name TEXT,
Tier TEXT,
Closed_Date TEXT,
Closed_Amount_USD REAL,
Kickoff_Date TEXT,
Go_Live_Target TEXT,
Go_Live_Actual TEXT,
Deployment_Status TEXT NOT NULL DEFAULT 'Not Started',
Health_RYG TEXT,
Milestones_Complete INTEGER DEFAULT 0,
Milestones_Total INTEGER DEFAULT 5,
Onboarding_Owner TEXT,
Risk_Notes TEXT
);
CREATE TABLE IF NOT EXISTS metric_targets (
Target_ID TEXT PRIMARY KEY,
Period_Type TEXT NOT NULL,
Period_Label TEXT NOT NULL DEFAULT '',
District_Name TEXT NOT NULL DEFAULT '',
Metric TEXT NOT NULL,
Target_Value REAL NOT NULL DEFAULT 0,
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);
`);
}
export function getDashboardData(): DashboardData {
const db = getDb();
return {
pipeline: db.prepare('SELECT * FROM pipeline').all() as PipelineRecord[],
accounts: db.prepare('SELECT * FROM accounts').all() as AccountRecord[],
activities: db.prepare('SELECT id, Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By, Status FROM activities ORDER BY Activity_Date DESC').all() as (ActivityRecord & { id: number })[],
targets: buildTargetsFromImplementations(db),
implementations: db.prepare('SELECT * FROM implementations WHERE Account_Name IS NOT NULL').all() as ImplementationRecord[],
metricTargets: db.prepare('SELECT * FROM metric_targets').all() as MetricTarget[],
lastRefreshed: new Date().toISOString(),
};
}
function buildTargetsFromImplementations(db: Database.Database): TargetRecord[] {
const impls = db.prepare('SELECT * FROM implementations WHERE Account_Name IS NOT NULL').all() as ImplementationRecord[];
return impls.map(impl => ({
Account_Name: impl.Account_Name,
Implementation_Stage: impl.Deployment_Status || 'Not Started',
Go_Live_Date: impl.Go_Live_Target || impl.Go_Live_Actual || null,
Health_Status: impl.Health_RYG || null,
Notes: impl.Risk_Notes || null,
}));
}
export function getDbStats() {
const db = getDb();
return {
accounts: (db.prepare('SELECT COUNT(*) as c FROM accounts').get() as { c: number }).c,
pipeline: (db.prepare('SELECT COUNT(*) as c FROM pipeline').get() as { c: number }).c,
activities: (db.prepare('SELECT COUNT(*) as c FROM activities').get() as { c: number }).c,
targets: (db.prepare('SELECT COUNT(*) as c FROM implementations').get() as { c: number }).c,
metricTargets: (db.prepare('SELECT COUNT(*) as c FROM metric_targets').get() as { c: number }).c,
};
}
export function updateLastTouched(accountName: string, date: string) {
const db = getDb();
const acct = db.prepare('SELECT Date_First_Touched, Touch_Count FROM accounts WHERE Account_Name = ?').get(accountName) as { Date_First_Touched: string | null; Touch_Count: number | null } | undefined;
if (!acct) return;
const updates: Record<string, unknown> = {
Account_Name: accountName,
Date_Last_Touched: date,
Touch_Count: (acct.Touch_Count || 0) + 1,
};
if (!acct.Date_First_Touched) {
updates.Date_First_Touched = date;
}
db.prepare(`
UPDATE accounts SET
Date_Last_Touched = @Date_Last_Touched,
Touch_Count = @Touch_Count
${!acct.Date_First_Touched ? ', Date_First_Touched = @Date_First_Touched' : ''}
WHERE Account_Name = @Account_Name
`).run(updates);
}
export function recalcAccountTouchData() {
const db = getDb();
const activities = db.prepare(`
SELECT Account_Name, MIN(Activity_Date) as first_touch, MAX(Activity_Date) as last_touch, COUNT(*) as touch_count
FROM activities
GROUP BY Account_Name
`).all() as { Account_Name: string; first_touch: string; last_touch: string; touch_count: number }[];
const update = db.prepare(`
UPDATE accounts SET
Date_First_Touched = @first_touch,
Date_Last_Touched = @last_touch,
Touch_Count = @touch_count
WHERE Account_Name = @Account_Name
`);
const tx = db.transaction(() => {
for (const a of activities) {
update.run(a);
}
});
tx();
}
export function recalcAccountStatus() {
const db = getDb();
db.exec(`
UPDATE accounts SET AgentMinder_Status = CASE
WHEN Account_Name IN (SELECT DISTINCT Account_Name FROM pipeline WHERE Status = 'Open') THEN '20% - Research'
WHEN Account_Name IN (SELECT DISTINCT Account_Name FROM activities) THEN '10% - Prospect'
ELSE 'Not Touched'
END
WHERE AgentMinder_Status = 'Not Touched'
OR AgentMinder_Status IS NULL
`);
}
export function addActivity(record: {
Activity_Date: string;
Activity_Type: string;
Account_Name: string;
District_Name: string;
Contact_Name?: string | null;
Notes?: string | null;
Play?: string | null;
Channel?: string | null;
Persona?: string | null;
Outcome?: string | null;
Logged_By?: string | null;
Status?: string | null;
}) {
const db = getDb();
const today = new Date().toISOString().split('T')[0];
const status = record.Status || (record.Activity_Date > today ? 'Planned' : null);
db.prepare(`
INSERT INTO activities (Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By, Status)
VALUES (@Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By, @Status)
`).run({
Activity_Date: record.Activity_Date,
Activity_Type: record.Activity_Type,
Account_Name: record.Account_Name,
District_Name: record.District_Name,
Contact_Name: record.Contact_Name || null,
Notes: record.Notes || null,
Play: record.Play || null,
Channel: record.Channel || null,
Persona: record.Persona || null,
Outcome: record.Outcome || null,
Logged_By: record.Logged_By || null,
Status: status,
});
if (!status || status !== 'Planned') {
updateLastTouched(record.Account_Name, record.Activity_Date);
}
}
export function updateActivityStatus(id: number, updates: { Status: string; Outcome?: string | null; Notes?: string | null }) {
const db = getDb();
const params: Record<string, unknown> = { id, Status: updates.Status };
const sets = ['Status = @Status'];
if (updates.Outcome !== undefined) { sets.push('Outcome = @Outcome'); params.Outcome = updates.Outcome ?? null; }
if (updates.Notes !== undefined) { sets.push('Notes = @Notes'); params.Notes = updates.Notes ?? null; }
db.prepare(`UPDATE activities SET ${sets.join(', ')} WHERE id = @id`).run(params);
if (updates.Status === 'Completed') {
const act = db.prepare('SELECT Account_Name, Activity_Date FROM activities WHERE id = ?').get(id) as { Account_Name: string; Activity_Date: string } | undefined;
if (act) updateLastTouched(act.Account_Name, act.Activity_Date);
}
}
export function importData(data: {
accounts?: Record<string, string | null>[];
pipeline?: Record<string, string | null>[];
activities?: Record<string, string | null>[];
implementations?: Record<string, string | null>[];
metricTargets?: Record<string, string | null>[];
}): Record<string, number> {
const db = getDb();
const counts: Record<string, number> = {};
const tx = db.transaction(() => {
if (data.accounts?.length) {
const stmt = db.prepare(`
INSERT INTO accounts (Account_Name, District_Name, Tier, Priority, AgentMinder_Status, Current_ARR_USD, Touch_Count, Date_First_Touched, Date_Last_Touched, MAP_In_Place_YN, Company_URL, Area_Sales_Leader, DM, AD, Logo_URL, Next_Renewal_Date, Next_Renewal_EAR, Anchor_Contract_Date, Anchor_Contract_EAR)
VALUES (@Account_Name, @District_Name, @Tier, @Priority, @AgentMinder_Status, @Current_ARR_USD, @Touch_Count, @Date_First_Touched, @Date_Last_Touched, @MAP_In_Place_YN, @Company_URL, @Area_Sales_Leader, @DM, @AD, @Logo_URL, @Next_Renewal_Date, @Next_Renewal_EAR, @Anchor_Contract_Date, @Anchor_Contract_EAR)
ON CONFLICT(Account_Name) DO UPDATE SET
District_Name=excluded.District_Name, Tier=excluded.Tier, Priority=excluded.Priority,
AgentMinder_Status=excluded.AgentMinder_Status, Current_ARR_USD=excluded.Current_ARR_USD,
Touch_Count=excluded.Touch_Count, Date_First_Touched=excluded.Date_First_Touched,
Date_Last_Touched=excluded.Date_Last_Touched, MAP_In_Place_YN=excluded.MAP_In_Place_YN,
Company_URL=excluded.Company_URL, Area_Sales_Leader=excluded.Area_Sales_Leader,
DM=excluded.DM, AD=excluded.AD, Logo_URL=excluded.Logo_URL,
Next_Renewal_Date=excluded.Next_Renewal_Date, Next_Renewal_EAR=excluded.Next_Renewal_EAR,
Anchor_Contract_Date=excluded.Anchor_Contract_Date, Anchor_Contract_EAR=excluded.Anchor_Contract_EAR
`);
let c = 0;
for (const r of data.accounts) {
if (!r.Account_Name) continue;
stmt.run(r);
c++;
}
counts.accounts = c;
}
if (data.pipeline?.length) {
const stmt = db.prepare(`
INSERT INTO pipeline (Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Closed_Amount_USD, Probability_Pct, Created_Date, Expected_Close_Date, Closed_Date, Champion_Name, Economic_Buyer, Next_Step, Next_Step_Date, Primary_Objection, Competitor, Source_Play, Product, Deal_Type, Status, Stage_Entered_Date, Tier, MAP_In_Place_YN)
VALUES (@Opportunity_ID, @Account_Name, @District_Name, @Stage, @Forecast_Category, @Amount_USD, @Closed_Amount_USD, @Probability_Pct, @Created_Date, @Expected_Close_Date, @Closed_Date, @Champion_Name, @Economic_Buyer, @Next_Step, @Next_Step_Date, @Primary_Objection, @Competitor, @Source_Play, @Product, @Deal_Type, @Status, @Stage_Entered_Date, @Tier, @MAP_In_Place_YN)
ON CONFLICT(Opportunity_ID) DO UPDATE SET
Account_Name=excluded.Account_Name, District_Name=excluded.District_Name, Stage=excluded.Stage,
Forecast_Category=excluded.Forecast_Category, Amount_USD=excluded.Amount_USD,
Closed_Amount_USD=excluded.Closed_Amount_USD, Probability_Pct=excluded.Probability_Pct,
Created_Date=excluded.Created_Date, Expected_Close_Date=excluded.Expected_Close_Date,
Closed_Date=excluded.Closed_Date, Champion_Name=excluded.Champion_Name,
Economic_Buyer=excluded.Economic_Buyer, Next_Step=excluded.Next_Step,
Next_Step_Date=excluded.Next_Step_Date, Primary_Objection=excluded.Primary_Objection,
Competitor=excluded.Competitor, Source_Play=excluded.Source_Play, Product=excluded.Product,
Deal_Type=excluded.Deal_Type, Status=excluded.Status, Stage_Entered_Date=excluded.Stage_Entered_Date,
Tier=excluded.Tier, MAP_In_Place_YN=excluded.MAP_In_Place_YN
`);
let c = 0;
for (const r of data.pipeline) {
if (!r.Opportunity_ID) continue;
stmt.run(r);
c++;
}
counts.pipeline = c;
}
if (data.activities?.length) {
db.prepare('DELETE FROM activities').run();
const stmt = db.prepare(`
INSERT INTO activities (Activity_ID, Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By)
VALUES (@Activity_ID, @Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By)
`);
let c = 0;
for (const r of data.activities) {
if (!r.Account_Name || !r.Activity_Date) continue;
stmt.run(r);
c++;
}
counts.activities = c;
}
if (data.implementations?.length) {
const stmt = db.prepare(`
INSERT INTO implementations (Implementation_ID, Opportunity_ID, Account_Name, District_Name, Tier, Closed_Date, Closed_Amount_USD, Kickoff_Date, Go_Live_Target, Go_Live_Actual, Deployment_Status, Health_RYG, Milestones_Complete, Milestones_Total, Onboarding_Owner, Risk_Notes)
VALUES (@Implementation_ID, @Opportunity_ID, @Account_Name, @District_Name, @Tier, @Closed_Date, @Closed_Amount_USD, @Kickoff_Date, @Go_Live_Target, @Go_Live_Actual, @Deployment_Status, @Health_RYG, @Milestones_Complete, @Milestones_Total, @Onboarding_Owner, @Risk_Notes)
ON CONFLICT(Implementation_ID) DO UPDATE SET
Opportunity_ID=excluded.Opportunity_ID, Account_Name=excluded.Account_Name,
District_Name=excluded.District_Name, Tier=excluded.Tier,
Closed_Date=excluded.Closed_Date, Closed_Amount_USD=excluded.Closed_Amount_USD,
Kickoff_Date=excluded.Kickoff_Date, Go_Live_Target=excluded.Go_Live_Target,
Go_Live_Actual=excluded.Go_Live_Actual, Deployment_Status=excluded.Deployment_Status,
Health_RYG=excluded.Health_RYG, Milestones_Complete=excluded.Milestones_Complete,
Milestones_Total=excluded.Milestones_Total, Onboarding_Owner=excluded.Onboarding_Owner,
Risk_Notes=excluded.Risk_Notes
`);
let c = 0;
for (const r of data.implementations) {
if (!r.Implementation_ID || !r.Account_Name) continue;
stmt.run(r);
c++;
}
counts.implementations = c;
}
if (data.metricTargets?.length) {
const stmt = db.prepare(`
INSERT INTO metric_targets (Target_ID, Period_Type, Period_Label, District_Name, Metric, Target_Value, Notes)
VALUES (@Target_ID, @Period_Type, @Period_Label, @District_Name, @Metric, @Target_Value, @Notes)
ON CONFLICT(Target_ID) DO UPDATE SET
Period_Type=excluded.Period_Type, Period_Label=excluded.Period_Label,
District_Name=excluded.District_Name, Metric=excluded.Metric,
Target_Value=excluded.Target_Value, Notes=excluded.Notes
`);
let c = 0;
for (const r of data.metricTargets) {
if (!r.Target_ID) continue;
stmt.run(r);
c++;
}
counts.metricTargets = c;
}
});
tx();
return counts;
}
export function deleteAccount(name: string) {
const db = getDb();
db.prepare('DELETE FROM accounts WHERE Account_Name = ?').run(name);
}
export function deletePipeline(id: string) {
const db = getDb();
db.prepare('DELETE FROM pipeline WHERE Opportunity_ID = ?').run(id);
}
export function deleteActivity(id: number) {
const db = getDb();
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
}
export function deleteTarget(accountName: string) {
const db = getDb();
db.prepare('DELETE FROM implementations WHERE Account_Name = ?').run(accountName);
}
export function upsertAccount(record: Record<string, unknown>) {
const db = getDb();
db.prepare(`INSERT INTO accounts (Account_Name, District_Name, Tier, Priority, AgentMinder_Status, Current_ARR_USD, MAP_In_Place_YN, Company_URL, Area_Sales_Leader, DM, AD, CPM)
VALUES (@Account_Name, @District_Name, @Tier, @Priority, @AgentMinder_Status, @Current_ARR_USD, @MAP_In_Place_YN, @Company_URL, @Area_Sales_Leader, @DM, @AD, @CPM)
ON CONFLICT(Account_Name) DO UPDATE SET
District_Name=excluded.District_Name, Tier=excluded.Tier, Priority=excluded.Priority,
AgentMinder_Status=excluded.AgentMinder_Status, Current_ARR_USD=excluded.Current_ARR_USD,
MAP_In_Place_YN=excluded.MAP_In_Place_YN, Company_URL=excluded.Company_URL,
Area_Sales_Leader=excluded.Area_Sales_Leader, DM=excluded.DM, AD=excluded.AD, CPM=excluded.CPM
`).run({
Account_Name: record.Account_Name || '',
District_Name: record.District_Name || '',
Tier: record.Tier || '',
Priority: record.Priority || '',
AgentMinder_Status: record.AgentMinder_Status || 'Not Touched',
Current_ARR_USD: record.Current_ARR_USD ?? null,
MAP_In_Place_YN: record.MAP_In_Place_YN ?? null,
Company_URL: record.Company_URL ?? null,
Area_Sales_Leader: record.Area_Sales_Leader ?? null,
DM: record.DM ?? null,
AD: record.AD ?? null,
CPM: record.CPM ?? null,
});
}
export function upsertPipeline(record: Record<string, unknown>) {
const db = getDb();
db.prepare(`INSERT INTO pipeline (Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Closed_Amount_USD, Probability_Pct, Created_Date, Expected_Close_Date, Closed_Date, Champion_Name, Economic_Buyer, Next_Step, Next_Step_Date, Primary_Objection, Competitor, Source_Play, Product)
VALUES (@Opportunity_ID, @Account_Name, @District_Name, @Stage, @Forecast_Category, @Amount_USD, @Closed_Amount_USD, @Probability_Pct, @Created_Date, @Expected_Close_Date, @Closed_Date, @Champion_Name, @Economic_Buyer, @Next_Step, @Next_Step_Date, @Primary_Objection, @Competitor, @Source_Play, @Product)
ON CONFLICT(Opportunity_ID) DO UPDATE SET
Account_Name=excluded.Account_Name, District_Name=excluded.District_Name, Stage=excluded.Stage,
Forecast_Category=excluded.Forecast_Category, Amount_USD=excluded.Amount_USD,
Closed_Amount_USD=excluded.Closed_Amount_USD, Probability_Pct=excluded.Probability_Pct,
Expected_Close_Date=excluded.Expected_Close_Date, Closed_Date=excluded.Closed_Date,
Champion_Name=excluded.Champion_Name, Economic_Buyer=excluded.Economic_Buyer,
Next_Step=excluded.Next_Step, Next_Step_Date=excluded.Next_Step_Date,
Primary_Objection=excluded.Primary_Objection, Competitor=excluded.Competitor,
Source_Play=excluded.Source_Play
`).run({
Opportunity_ID: record.Opportunity_ID || '',
Account_Name: record.Account_Name || '',
District_Name: record.District_Name || '',
Stage: record.Stage || '01-Qualified',
Forecast_Category: record.Forecast_Category || 'Pipeline',
Amount_USD: record.Amount_USD ?? 0,
Closed_Amount_USD: record.Closed_Amount_USD ?? null,
Probability_Pct: record.Probability_Pct ?? 0,
Created_Date: record.Created_Date || new Date().toISOString().split('T')[0],
Expected_Close_Date: record.Expected_Close_Date || '',
Closed_Date: record.Closed_Date ?? null,
Champion_Name: record.Champion_Name ?? null,
Economic_Buyer: record.Economic_Buyer ?? null,
Next_Step: record.Next_Step ?? null,
Next_Step_Date: record.Next_Step_Date ?? null,
Primary_Objection: record.Primary_Objection ?? null,
Competitor: record.Competitor ?? null,
Source_Play: record.Source_Play ?? null,
Product: record.Product || 'AgentMinder',
});
}
// --- 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()}`;
db.prepare(`INSERT INTO implementations (Implementation_ID, Account_Name, District_Name, Deployment_Status, Health_RYG, Go_Live_Target, Go_Live_Actual, Risk_Notes)
VALUES (@Implementation_ID, @Account_Name, @District_Name, @Deployment_Status, @Health_RYG, @Go_Live_Target, @Go_Live_Actual, @Risk_Notes)
ON CONFLICT(Implementation_ID) DO UPDATE SET
Account_Name=excluded.Account_Name, District_Name=excluded.District_Name,
Deployment_Status=excluded.Deployment_Status, Health_RYG=excluded.Health_RYG,
Go_Live_Target=excluded.Go_Live_Target, Go_Live_Actual=excluded.Go_Live_Actual,
Risk_Notes=excluded.Risk_Notes
`).run({
Implementation_ID: id,
Account_Name: record.Account_Name || '',
District_Name: record.District_Name ?? null,
Deployment_Status: record.Deployment_Status || record.Implementation_Stage || 'Not Started',
Health_RYG: record.Health_RYG || record.Health_Status || null,
Go_Live_Target: record.Go_Live_Target || record.Go_Live_Date || null,
Go_Live_Actual: record.Go_Live_Actual ?? null,
Risk_Notes: record.Risk_Notes || record.Notes || null,
});
}
function seedCPMValues() {
const db = getDb();
const cpmAccounts = [
'ADT', 'ADVENTHEALTH', 'ARBITRATION FORUMS', 'CARLYLE GROUP', 'CARNIVAL',
'ENTERGY', 'Geographic Solutions', 'GUIDEWELL BLUE CROSS BLUE SHIELD OF FLORIDA',
'JABIL CIRCUIT', 'KASEYA', 'LEE MEMORIAL HEALTH SYSTEM', 'LUMEN',
'NEXTERA ENERGY', 'NORWEGIAN CRUISE LINE', 'OCHSNER HEALTH SYSTEM',
'ORLANDO HEALTH', 'PAYSPAN', 'PUBLIX SUPERMARKETS', 'RAYMOND JAMES FINANCIAL',
'TECH DATA',
];
const stmt = db.prepare("UPDATE accounts SET CPM = 'David Stefan' WHERE Account_Name = ? AND (CPM IS NULL OR CPM = '')");
for (const name of cpmAccounts) {
stmt.run(name);
}
}