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:
173
src/lib/data-context.tsx
Normal file
173
src/lib/data-context.tsx
Normal 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
484
src/lib/db.ts
Normal 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
92
src/lib/formatters.ts
Normal 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
134
src/lib/google-sheets.ts
Normal 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
251
src/lib/mock-data.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user