Build AgentMinder Campaign Command Center - Phase 1 complete

Full-featured sales campaign dashboard replacing Looker Studio with
responsive Next.js app. Includes Executive Overview, Account Explorer
with priority algorithm, Activities with detail overlay, Pipeline/Opps
with deal panels, Implementation tracking, and Data Admin with CSV
import and CRUD operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 13:55:01 -04:00
parent fce80c3d45
commit 3c8c8ee594
33 changed files with 6289 additions and 123 deletions

484
src/lib/db.ts Normal file
View File

@@ -0,0 +1,484 @@
import Database from 'better-sqlite3';
import path from 'path';
import {
PipelineRecord,
AccountRecord,
ActivityRecord,
TargetRecord,
ImplementationRecord,
MetricTarget,
DashboardData,
} 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);
}
return _db;
}
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 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);
`);
}
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<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;
}) {
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<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)
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<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',
});
}
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,
});
}