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

173
src/lib/data-context.tsx Normal file
View File

@@ -0,0 +1,173 @@
'use client';
import React, { createContext, useContext, useState, useMemo, useCallback, ReactNode } from 'react';
import { DashboardData, DashboardConfig, PipelineRecord, AccountRecord, ActivityRecord, TargetRecord } from '@/types/data';
import { startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfQuarter, endOfQuarter, subDays, parseISO, isWithinInterval } from 'date-fns';
export type DatePreset = 'this_week' | 'last_7' | 'this_month' | 'this_quarter' | 'last_quarter' | 'custom';
interface FilterState {
districts: string[];
datePreset: DatePreset;
dateRange: { start: string; end: string };
crossFilter: { key: string; value: string } | null;
}
interface DataContextValue {
raw: DashboardData;
config: DashboardConfig;
filters: FilterState;
setDistricts: (d: string[]) => void;
setDatePreset: (p: DatePreset) => void;
setDateRange: (start: string, end: string) => void;
setCrossFilter: (key: string, value: string) => void;
clearCrossFilter: () => void;
clearAllFilters: () => void;
filtered: {
pipeline: PipelineRecord[];
accounts: AccountRecord[];
activities: ActivityRecord[];
targets: TargetRecord[];
};
isLoading: boolean;
lastRefreshed: string;
refresh: () => Promise<void>;
}
const DataContext = createContext<DataContextValue | null>(null);
function getDateRange(preset: DatePreset, custom?: { start: string; end: string }): { start: Date; end: Date } {
const now = new Date();
switch (preset) {
case 'this_week':
return { start: startOfWeek(now, { weekStartsOn: 1 }), end: endOfWeek(now, { weekStartsOn: 1 }) };
case 'last_7':
return { start: subDays(now, 7), end: now };
case 'this_month':
return { start: startOfMonth(now), end: endOfMonth(now) };
case 'this_quarter':
return { start: startOfQuarter(now), end: endOfQuarter(now) };
case 'last_quarter': {
const lastQ = new Date(now);
lastQ.setMonth(lastQ.getMonth() - 3);
return { start: startOfQuarter(lastQ), end: endOfQuarter(lastQ) };
}
case 'custom':
if (custom) return { start: parseISO(custom.start), end: parseISO(custom.end) };
return { start: subDays(now, 30), end: now };
}
}
export function DataProvider({ children, initialData, config }: { children: ReactNode; initialData: DashboardData; config: DashboardConfig }) {
const [data, setData] = useState<DashboardData>(initialData);
const [isLoading, setIsLoading] = useState(false);
const [filters, setFilters] = useState<FilterState>({
districts: [],
datePreset: 'this_quarter',
dateRange: { start: '', end: '' },
crossFilter: null,
});
const setDistricts = useCallback((d: string[]) => {
setFilters(f => ({ ...f, districts: d }));
}, []);
const setDatePreset = useCallback((p: DatePreset) => {
setFilters(f => ({ ...f, datePreset: p }));
}, []);
const setDateRange = useCallback((start: string, end: string) => {
setFilters(f => ({ ...f, datePreset: 'custom', dateRange: { start, end } }));
}, []);
const setCrossFilter = useCallback((key: string, value: string) => {
setFilters(f => ({ ...f, crossFilter: { key, value } }));
}, []);
const clearCrossFilter = useCallback(() => {
setFilters(f => ({ ...f, crossFilter: null }));
}, []);
const clearAllFilters = useCallback(() => {
setFilters({ districts: [], datePreset: 'this_quarter', dateRange: { start: '', end: '' }, crossFilter: null });
}, []);
const refresh = useCallback(async () => {
setIsLoading(true);
try {
const res = await fetch('/api/sheets?bust=' + Date.now());
if (res.ok) {
const newData = await res.json();
setData(newData);
}
} finally {
setIsLoading(false);
}
}, []);
const filtered = useMemo(() => {
const { districts: distFilter, datePreset, dateRange, crossFilter } = filters;
const range = getDateRange(datePreset, dateRange);
let pipeline = data.pipeline;
let accounts = data.accounts;
let activities = data.activities;
let targets = data.targets;
if (distFilter.length > 0) {
pipeline = pipeline.filter(p => distFilter.includes(p.District_Name));
accounts = accounts.filter(a => distFilter.includes(a.District_Name));
activities = activities.filter(a => distFilter.includes(a.District_Name));
}
activities = activities.filter(a => {
try {
return isWithinInterval(parseISO(a.Activity_Date), range);
} catch {
return true;
}
});
if (crossFilter) {
const { key, value } = crossFilter;
const match = (obj: object) => String((obj as never)[key as never]) === value;
pipeline = pipeline.filter(match);
accounts = accounts.filter(match);
activities = activities.filter(match);
targets = targets.filter(match);
}
if (distFilter.length > 0) {
const accountNames = new Set(accounts.map(a => a.Account_Name));
targets = targets.filter(t => accountNames.has(t.Account_Name));
}
return { pipeline, accounts, activities, targets };
}, [data, filters]);
return (
<DataContext.Provider value={{
raw: data,
config,
filters,
setDistricts,
setDatePreset,
setDateRange,
setCrossFilter,
clearCrossFilter,
clearAllFilters,
filtered,
isLoading,
lastRefreshed: data.lastRefreshed,
refresh,
}}>
{children}
</DataContext.Provider>
);
}
export function useData() {
const ctx = useContext(DataContext);
if (!ctx) throw new Error('useData must be used within DataProvider');
return ctx;
}

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,
});
}

92
src/lib/formatters.ts Normal file
View File

@@ -0,0 +1,92 @@
export function formatCurrency(value: number, compact = false): string {
if (compact && Math.abs(value) >= 1_000_000) {
return '$' + (value / 1_000_000).toFixed(1) + 'M';
}
if (compact && Math.abs(value) >= 1_000) {
return '$' + (value / 1_000).toFixed(0) + 'K';
}
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value);
}
export function formatNumber(value: number): string {
return new Intl.NumberFormat('en-US').format(value);
}
export function formatPercent(value: number, decimals = 0): string {
return value.toFixed(decimals) + '%';
}
export function formatRatio(value: number): string {
return value.toFixed(1) + 'x';
}
export const CHART_COLORS = {
navy: '#005C8A',
azure: '#0098C7',
aqua: '#007B8C',
green: '#61A60E',
purple: '#6C4B94',
lightBlue: '#007DA3',
darkGreen: '#23800A',
brightBlue: '#0088EF',
};
export const CHART_PALETTE = [
CHART_COLORS.navy,
CHART_COLORS.azure,
CHART_COLORS.aqua,
CHART_COLORS.green,
CHART_COLORS.purple,
CHART_COLORS.lightBlue,
CHART_COLORS.darkGreen,
CHART_COLORS.brightBlue,
];
export const STATUS_COLORS: Record<string, string> = {
'Not Touched': '#94A3B8',
'10% - Prospect': CHART_COLORS.azure,
'20% - Research': CHART_COLORS.aqua,
'30% - Engaged': CHART_COLORS.green,
'40% - Active Opp': CHART_COLORS.navy,
'50% - Implement': CHART_COLORS.purple,
Aware: CHART_COLORS.azure,
Engaged: CHART_COLORS.green,
};
export const FORECAST_COLORS: Record<string, string> = {
Commit: CHART_COLORS.navy,
'Best Case': CHART_COLORS.azure,
Pipeline: CHART_COLORS.aqua,
Closed: CHART_COLORS.green,
Omit: '#94A3B8',
};
export const STAGE_COLORS: Record<string, string> = {
'01-Qualified': CHART_COLORS.lightBlue,
'02-Discovery': CHART_COLORS.azure,
'03-Evaluation': CHART_COLORS.aqua,
'04-Business Case': CHART_COLORS.navy,
'05-Negotiation': CHART_COLORS.purple,
'06-Closed Won': CHART_COLORS.green,
'07-Closed Lost': '#94A3B8',
};
export const TIER_COLORS: Record<string, string> = {
'Tier 1': CHART_COLORS.navy,
'Tier 2': CHART_COLORS.azure,
'Tier 3': CHART_COLORS.aqua,
'Tier 4': '#94A3B8',
};
export const HEALTH_COLORS: Record<string, string> = {
Green: '#61A60E',
Yellow: '#F59E0B',
Red: '#EF4444',
};
export const DISTRICT_SHORT: Record<string, string> = {
'SE-SUNSHINE': 'Sunshine',
'SE-PEACHTREE': 'Peachtree',
'SE-MISS-VALLEY': 'Miss Valley',
'SE-MID-ATL': 'Mid-Atl',
};

134
src/lib/google-sheets.ts Normal file
View File

@@ -0,0 +1,134 @@
import { google } from 'googleapis';
import {
PipelineRecord,
AccountRecord,
ActivityRecord,
TargetRecord,
DashboardData,
} from '@/types/data';
const SPREADSHEET_ID = process.env.GOOGLE_SHEETS_SPREADSHEET_ID;
function getAuth() {
return new google.auth.JWT({
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
});
}
async function fetchTab(sheets: ReturnType<typeof google.sheets>, tab: string): Promise<string[][]> {
const res = await sheets.spreadsheets.values.get({
spreadsheetId: SPREADSHEET_ID,
range: `${tab}!A:Z`,
});
return (res.data.values as string[][]) || [];
}
function rowsToObjects<T>(rows: string[][]): T[] {
if (rows.length < 2) return [];
const headers = rows[0];
return rows.slice(1).map(row => {
const obj: Record<string, string | null> = {};
headers.forEach((h, i) => {
obj[h] = row[i] ?? null;
});
return obj as unknown as T;
});
}
function parsePipeline(raw: Record<string, string | null>[]): PipelineRecord[] {
return raw.map(r => ({
Opportunity_ID: r.Opportunity_ID || '',
Account_Name: r.Account_Name || '',
District_Name: r.District_Name || '',
Stage: (r.Stage || 'Discovery') as PipelineRecord['Stage'],
Forecast_Category: (r.Forecast_Category || 'Pipeline') as PipelineRecord['Forecast_Category'],
Amount_USD: parseFloat(r.Amount_USD || '0') || 0,
Closed_Amount_USD: r.Closed_Amount_USD ? parseFloat(r.Closed_Amount_USD) : null,
Probability_Pct: parseInt(r.Probability_Pct || '0') || 0,
Created_Date: r.Created_Date || '',
Expected_Close_Date: r.Expected_Close_Date || '',
Closed_Date: r.Closed_Date || null,
Champion_Name: r.Champion_Name || null,
Economic_Buyer: r.Economic_Buyer || null,
Next_Step: r.Next_Step || null,
Next_Step_Date: r.Next_Step_Date || null,
Primary_Objection: r.Primary_Objection || null,
Competitor: r.Competitor || null,
Source_Play: r.Source_Play || null,
Product: r.Product || 'AgentMinder',
}));
}
function parseAccounts(raw: Record<string, string | null>[]): AccountRecord[] {
return raw.map(r => ({
Account_Name: r.Account_Name || '',
District_Name: r.District_Name || '',
Tier: (r.Tier || '3') as AccountRecord['Tier'],
Priority: (r.Priority || 'Low') as AccountRecord['Priority'],
AgentMinder_Status: (r.AgentMinder_Status || 'Not Touched') as AccountRecord['AgentMinder_Status'],
Current_ARR_USD: r.Current_ARR_USD ? parseFloat(r.Current_ARR_USD) : null,
Touch_Count: r.Touch_Count ? parseInt(r.Touch_Count) : null,
Date_First_Touched: r.Date_First_Touched || null,
Date_Last_Touched: r.Date_Last_Touched || null,
MAP_In_Place_YN: (r.MAP_In_Place_YN as AccountRecord['MAP_In_Place_YN']) || null,
}));
}
function parseActivities(raw: Record<string, string | null>[]): ActivityRecord[] {
return raw.map(r => ({
Activity_Date: r.Activity_Date || '',
Activity_Type: r.Activity_Type || '',
Account_Name: r.Account_Name || '',
District_Name: r.District_Name || '',
Contact_Name: r.Contact_Name || null,
Notes: r.Notes || null,
}));
}
function parseTargets(raw: Record<string, string | null>[]): TargetRecord[] {
return raw.map(r => ({
Account_Name: r.Account_Name || '',
Implementation_Stage: (r.Implementation_Stage || 'Contract Signed') as TargetRecord['Implementation_Stage'],
Go_Live_Date: r.Go_Live_Date || null,
Health_Status: (r.Health_Status as TargetRecord['Health_Status']) || null,
Notes: r.Notes || null,
}));
}
export async function fetchSheetData(): Promise<DashboardData> {
const auth = getAuth();
const sheets = google.sheets({ version: 'v4', auth });
const [pipelineRows, accountRows, activityRows, targetRows] = await Promise.all([
fetchTab(sheets, 'Pipeline'),
fetchTab(sheets, 'Accounts'),
fetchTab(sheets, 'Activity_Log'),
fetchTab(sheets, 'Targets'),
]);
const requiredHeaders: Record<string, string[]> = {
Pipeline: ['Opportunity_ID', 'Account_Name', 'District_Name', 'Stage', 'Amount_USD'],
Accounts: ['Account_Name', 'District_Name', 'Tier', 'Priority', 'AgentMinder_Status'],
Activity_Log: ['Activity_Date', 'Activity_Type', 'Account_Name', 'District_Name'],
Targets: ['Account_Name', 'Implementation_Stage'],
};
for (const [tab, required] of Object.entries(requiredHeaders)) {
const rows = { Pipeline: pipelineRows, Accounts: accountRows, Activity_Log: activityRows, Targets: targetRows }[tab]!;
const headers = rows[0] || [];
const missing = required.filter(h => !headers.includes(h));
if (missing.length > 0) {
console.warn(`[Sheets] Tab "${tab}" missing columns: ${missing.join(', ')}`);
}
}
return {
pipeline: parsePipeline(rowsToObjects(pipelineRows)),
accounts: parseAccounts(rowsToObjects(accountRows)),
activities: parseActivities(rowsToObjects(activityRows)),
targets: parseTargets(rowsToObjects(targetRows)),
lastRefreshed: new Date().toISOString(),
};
}

251
src/lib/mock-data.ts Normal file
View File

@@ -0,0 +1,251 @@
import {
PipelineRecord,
AccountRecord,
ActivityRecord,
TargetRecord,
ImplementationRecord,
MetricTarget,
DashboardData,
DashboardConfig,
} from '@/types/data';
const districts = ['SE-SUNSHINE', 'SE-PEACHTREE', 'SE-MISS-VALLEY', 'SE-MID-ATL'] as const;
const tiers = ['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'] as const;
const priorities = ['High', 'Medium', 'Low'] as const;
const statuses = ['10% - Prospect', '20% - Research', 'Not Touched'] as const;
const stages = ['02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation', '06-Closed Won', '07-Closed Lost'] as const;
const forecastCats = ['Commit', 'Best Case', 'Pipeline', 'Closed', 'Omit'] as const;
const activityTypes = ['Launch Briefing', 'Discovery', 'QBR Attach', 'Exec Meeting', 'Demo', 'Workshop', 'Email', 'Call'] as const;
const competitors = ['Competitor A', 'Competitor B', 'Competitor C', 'Incumbent', null];
const sourcePlays = ['Attach', 'Renewal-Trigger', 'Champion Referral', 'Cold Outbound', 'Event Follow-Up'];
const implStages = ['Not Started', 'In Progress', 'Complete', 'Stalled'] as const;
function randomDate(start: Date, end: Date): string {
const d = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
return d.toISOString().split('T')[0];
}
function pick<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
function weightedPick<T>(arr: readonly T[], weights: number[]): T {
const total = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * total;
for (let i = 0; i < arr.length; i++) {
r -= weights[i];
if (r <= 0) return arr[i];
}
return arr[arr.length - 1];
}
const accountNames = [
'Baptist Health System', 'Emory Healthcare', 'Piedmont Healthcare', 'Grady Health', 'WellStar Health',
'Southeast Regional Medical', 'Atrium Health', 'Prisma Health', 'HCA Florida', 'AdventHealth Orlando',
'UF Health Jacksonville', 'Tampa General Hospital', 'Moffitt Cancer Center', 'Mayo Clinic Jacksonville',
'Nemours Children\'s', 'Duke Energy', 'Southern Company', 'Florida Power & Light', 'Coca-Cola Enterprises',
'Home Depot Regional', 'Delta Air Lines', 'UPS Southeast Hub', 'Norfolk Southern', 'AutoNation Southeast',
'Publix Super Markets', 'SunTrust / Truist', 'Regions Financial', 'Synovus Bank', 'Raymond James',
'Jacksonville Jaguars', 'Georgia Tech Research', 'University of Florida', 'Vanderbilt University',
'Clemson University', 'Georgia-Pacific', 'Mohawk Industries', 'Interface Inc', 'Aflac',
'TSYS / Global Payments', 'NCR Corporation', 'Gartner SE Office', 'Genuine Parts Co', 'Rollins Inc',
'Flowers Foods', 'Americold Realty', 'BlueLinx Holdings', 'Chart Industries', 'EzFill Holdings',
'MiMedx Group', 'Cardlytics Inc', 'GreenSky', 'Kabbage / Amex', 'Calendly HQ', 'Mailchimp / Intuit',
'SalesLoft', 'Greenlight Financial', 'OneTrust', 'Ionic Security', 'Pindrop Security', 'CallRail',
'FullStory', 'Rubicon Global', 'InComm Payments', 'EasySend SE', 'Datacert Legal', 'Aptean SE',
'Manhattan Associates', 'ControlScan', 'Oversight Systems', 'BitPay', 'Bakkt Holdings',
'Intercontinental Exchange', 'Invesco SE', 'Jackson Hewitt SE', 'Aaron\'s Holdings', 'FleetCor Technologies',
'Worldpay SE', 'Fiserv SE', 'Equifax', 'TransUnion SE', 'LexisNexis Risk', 'Verint SE',
'Encompass Health', 'Community Health Systems', 'Tenet Healthcare SE', 'LifePoint Health',
'Kindred Healthcare SE', 'Amedisys', 'Brookdale Senior Living', 'Surgery Partners',
];
function generateAccounts(): AccountRecord[] {
return accountNames.slice(0, 90).map((name, i) => {
const tier = i < 10 ? 'Tier 1' : i < 25 ? 'Tier 2' : i < 50 ? 'Tier 3' : 'Tier 4';
const district = districts[i % 4];
const status = weightedPick(statuses, [10, 5, 85]);
const priority = tier === 'Tier 1' ? 'High' : tier === 'Tier 2' ? weightedPick(priorities, [40, 50, 10]) : weightedPick(priorities, [10, 40, 50]);
const touchCount = status === 'Not Touched' ? 0 : Math.floor(Math.random() * 15) + 1;
const hasTouch = touchCount > 0;
return {
Account_Name: name,
District_Name: district,
Tier: tier,
Priority: priority,
AgentMinder_Status: status,
Current_ARR_USD: Math.random() > 0.3 ? Math.floor(Math.random() * 500000) + 10000 : null,
Touch_Count: touchCount,
Date_First_Touched: hasTouch ? randomDate(new Date('2026-01-15'), new Date('2026-06-01')) : null,
Date_Last_Touched: hasTouch ? randomDate(new Date('2026-06-01'), new Date('2026-08-25')) : null,
MAP_In_Place_YN: hasTouch && Math.random() > 0.5 ? 'Y' : 'N',
Company_URL: null,
Area_Sales_Leader: null,
DM: null,
AD: null,
IMS_BA: null,
Logo_URL: null,
Next_Renewal_Date: null,
Next_Renewal_EAR: null,
Anchor_Contract_Date: null,
Anchor_Contract_EAR: null,
};
});
}
function generatePipeline(accounts: AccountRecord[]): PipelineRecord[] {
const touchedAccounts = accounts.filter(a => a.AgentMinder_Status !== 'Not Touched');
const opps: PipelineRecord[] = [];
let oppId = 1;
for (const account of touchedAccounts) {
const numOpps = account.Tier === 'Tier 1' ? Math.ceil(Math.random() * 2) : Math.random() > 0.5 ? 1 : 0;
for (let j = 0; j < numOpps; j++) {
const stage = weightedPick(stages, [25, 15, 15, 10, 25, 10]);
const isClosed = stage === '06-Closed Won' || stage === '07-Closed Lost';
const amount = account.Tier === 'Tier 1'
? Math.floor(Math.random() * 400000) + 100000
: account.Tier === 'Tier 2'
? Math.floor(Math.random() * 150000) + 25000
: Math.floor(Math.random() * 50000) + 5000;
const createdDate = randomDate(new Date('2026-02-01'), new Date('2026-07-15'));
const expectedClose = randomDate(new Date('2026-08-01'), new Date('2026-12-31'));
const prob = stage === '06-Closed Won' ? 100 : stage === '07-Closed Lost' ? 0
: stage === '05-Negotiation' ? 60 + Math.floor(Math.random() * 20)
: stage === '04-Business Case' ? 40 + Math.floor(Math.random() * 15)
: stage === '03-Evaluation' ? 20 + Math.floor(Math.random() * 15)
: 10 + Math.floor(Math.random() * 10);
const forecast =
stage === '06-Closed Won' ? 'Closed'
: stage === '07-Closed Lost' ? 'Omit'
: stage === '05-Negotiation' ? weightedPick(['Commit', 'Best Case', 'Pipeline'] as const, [50, 35, 15])
: stage === '04-Business Case' ? weightedPick(['Best Case', 'Pipeline'] as const, [40, 60])
: 'Pipeline';
opps.push({
Opportunity_ID: `OPP-${String(oppId++).padStart(4, '0')}`,
Account_Name: account.Account_Name,
District_Name: account.District_Name,
Stage: stage,
Forecast_Category: forecast,
Amount_USD: amount,
Closed_Amount_USD: stage === '06-Closed Won' ? amount : null,
Probability_Pct: prob,
Created_Date: createdDate,
Expected_Close_Date: isClosed ? createdDate : expectedClose,
Closed_Date: isClosed ? randomDate(new Date(createdDate), new Date('2026-08-25')) : null,
Champion_Name: Math.random() > 0.3 ? `Contact ${oppId}` : null,
Economic_Buyer: Math.random() > 0.4 ? `VP ${pick(['Operations', 'IT', 'Finance', 'Clinical'])}` : null,
Next_Step: isClosed ? null : pick(['Schedule executive briefing', 'Send proposal', 'Negotiate terms', 'Technical validation', 'Reference call', 'POC planning']),
Next_Step_Date: isClosed ? null : randomDate(new Date('2026-08-25'), new Date('2026-10-01')),
Primary_Objection: Math.random() > 0.5 ? pick(['Budget timing', 'Competing priority', 'Need more references', 'Integration concerns', 'Contract terms']) : null,
Competitor: pick(competitors),
Source_Play: pick(sourcePlays),
Product: 'AgentMinder',
Deal_Type: null,
Status: null,
Stage_Entered_Date: null,
Tier: account.Tier,
MAP_In_Place_YN: account.MAP_In_Place_YN,
});
}
}
return opps;
}
function generateActivities(accounts: AccountRecord[]): ActivityRecord[] {
const activities: ActivityRecord[] = [];
const touchedAccounts = accounts.filter(a => a.AgentMinder_Status !== 'Not Touched');
for (const account of touchedAccounts) {
const numActivities = Math.floor(Math.random() * 8) + 1;
for (let j = 0; j < numActivities; j++) {
activities.push({
Activity_Date: randomDate(new Date('2026-03-01'), new Date('2026-08-27')),
Activity_Type: pick(activityTypes),
Account_Name: account.Account_Name,
District_Name: account.District_Name,
Contact_Name: Math.random() > 0.3 ? `${pick(['Dr.', 'Mr.', 'Ms.'])} ${pick(['Smith', 'Johnson', 'Williams', 'Brown', 'Davis', 'Garcia', 'Miller', 'Wilson', 'Anderson', 'Thomas'])}` : null,
Notes: Math.random() > 0.4 ? pick([
'Positive meeting - moving forward with evaluation',
'Discussed ROI framework and implementation timeline',
'Champion is engaged, need exec sponsor alignment',
'Competitor mentioned - need to differentiate on security',
'Budget approved for Q3 - accelerating timeline',
'Technical team needs integration documentation',
'Follow-up scheduled for next week',
'QBR went well - expansion opportunity identified',
'Need to address compliance requirements before proceeding',
'Demo completed - strong interest from clinical team',
]) : null,
Play: null,
Channel: null,
Persona: null,
Outcome: null,
Logged_By: null,
});
}
}
return activities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date));
}
function generateTargets(pipeline: PipelineRecord[]): TargetRecord[] {
const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won');
return closedWon.map(opp => {
const implStage = weightedPick(implStages, [15, 30, 35, 20]);
return {
Account_Name: opp.Account_Name,
Implementation_Stage: implStage,
Go_Live_Date: implStage === 'Complete'
? randomDate(new Date('2026-05-01'), new Date('2026-08-20'))
: implStage === 'Stalled' ? null
: randomDate(new Date('2026-09-01'), new Date('2026-11-30')),
Health_Status: implStage === 'Stalled' ? 'Red'
: implStage === 'Complete' ? 'Green'
: weightedPick(['Green', 'Yellow', 'Red'] as const, [50, 35, 15]),
Notes: pick([
'On track for target go-live',
'Awaiting IT resource allocation',
'Configuration in progress - 60% complete',
'Successfully live - collecting reference feedback',
'Blocked on SSO integration',
'Training sessions scheduled',
null,
]),
};
});
}
let cachedData: DashboardData | null = null;
export function getMockData(): DashboardData {
if (cachedData) return cachedData;
const accounts = generateAccounts();
const pipeline = generatePipeline(accounts);
const activities = generateActivities(accounts);
const targets = generateTargets(pipeline);
cachedData = {
pipeline,
accounts,
activities,
targets,
implementations: [],
metricTargets: [],
lastRefreshed: new Date().toISOString(),
};
return cachedData;
}
export function getMockConfig(): DashboardConfig {
return {
quotaTarget: 5000000,
weeklyActivityTarget: 25,
};
}