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(); } 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 ( 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, 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 ); 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 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 = { 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; }) { const db = getDb(); db.prepare(` INSERT INTO activities (Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By) VALUES (@Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By) `).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, }); updateLastTouched(record.Account_Name, record.Activity_Date); } export function importData(data: { accounts?: Record[]; pipeline?: Record[]; activities?: Record[]; implementations?: Record[]; metricTargets?: Record[]; }): Record { const db = getDb(); const counts: Record = {}; 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) { 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) VALUES (@Account_Name, @District_Name, @Tier, @Priority, @AgentMinder_Status, @Current_ARR_USD, @MAP_In_Place_YN, @Company_URL, @Area_Sales_Leader, @DM, @AD) 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 `).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, }); } export function upsertPipeline(record: Record) { 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 = {}; accounts.forEach(a => { priorityCounts[a.Priority || 'Unset'] = (priorityCounts[a.Priority || 'Unset'] || 0) + 1; }); const stageCounts: Record = {}; 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 = {}; 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) { 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, }); }