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

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}

6
.gitignore vendored
View File

@@ -33,6 +33,12 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# database
/data/
# source data files
*.xlsx
# vercel
.vercel

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
serverExternalPackages: ['better-sqlite3'],
};
export default nextConfig;

1441
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +9,19 @@
"lint": "eslint"
},
"dependencies": {
"better-sqlite3": "^13.0.3",
"date-fns": "^4.4.0",
"googleapis": "^176.0.0",
"next": "16.3.3",
"next-auth": "^5.0.0-beta.32",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-dom": "19.2.8",
"recharts": "^3.10.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -22,5 +29,8 @@
"eslint-config-next": "16.3.3",
"tailwindcss": "^4",
"typescript": "^5"
},
"allowScripts": {
"better-sqlite3@13.0.3": true
}
}

12
public/logo.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" fill="none">
<defs>
<linearGradient id="g1" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" stop-color="#005C8A"/>
<stop offset="50%" stop-color="#0098C7"/>
<stop offset="100%" stop-color="#007B8C"/>
</linearGradient>
</defs>
<!-- Parallelogram brand element -->
<path d="M8 32 L16 4 L36 4 L28 32 Z" rx="3" fill="url(#g1)" opacity="0.9"/>
<path d="M4 36 L10 16 L22 16 L16 36 Z" fill="#005C8A" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B

488
scripts/import-xlsx.js Normal file
View File

@@ -0,0 +1,488 @@
const XLSX = require('xlsx');
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const XLSX_PATH = path.join(__dirname, '..', 'AgentMinder Launch - Campaign Tracker.xlsx');
const DB_DIR = path.join(__dirname, '..', 'data');
const DB_PATH = path.join(DB_DIR, 'campaign.db');
if (!fs.existsSync(DB_DIR)) fs.mkdirSync(DB_DIR, { recursive: true });
// Delete old database to start fresh
if (fs.existsSync(DB_PATH)) {
fs.unlinkSync(DB_PATH);
console.log('Deleted existing database');
}
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
// Create schema
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);
`);
console.log('Reading Excel file...');
const wb = XLSX.readFile(XLSX_PATH);
// Helper: convert Excel serial date to yyyy-MM-dd
function excelDate(val) {
if (!val && val !== 0) return null;
if (typeof val === 'string') {
if (/^\d{4}-\d{2}-\d{2}/.test(val)) return val.substring(0, 10);
return val || null;
}
if (typeof val === 'number') {
const d = new Date((val - 25569) * 86400000);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
if (val instanceof Date) {
return val.toISOString().substring(0, 10);
}
return null;
}
function str(v) { return (v === undefined || v === null || v === '') ? null : String(v).trim(); }
function num(v) { if (v === undefined || v === null || v === '') return null; const n = Number(v); return isNaN(n) ? null : n; }
function int(v) { const n = num(v); return n === null ? null : Math.round(n); }
// ===== 1. Import Accounts =====
console.log('\n--- Importing Accounts ---');
const accts = XLSX.utils.sheet_to_json(wb.Sheets['Accounts']);
const acctInsert = db.prepare(`
INSERT INTO accounts (Account_Name, District_Name, Tier, Priority, AgentMinder_Status, Current_ARR_USD, Touch_Count, Date_First_Touched, Date_Last_Touched, 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, @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, 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
`);
// Build account name -> district lookup for later
const acctDistrictMap = {};
const acctTierMap = {};
let acctCount = 0;
const acctTx = db.transaction(() => {
for (const r of accts) {
const name = str(r['Account']);
if (!name) continue;
const district = str(r['District']) || '';
const tier = str(r['Tier']) || '';
acctDistrictMap[name] = district;
acctDistrictMap[name.toUpperCase()] = district;
acctTierMap[name] = tier;
acctTierMap[name.toUpperCase()] = tier;
acctInsert.run({
Account_Name: name,
District_Name: district,
Tier: tier,
Priority: str(r['Priority']) || '',
AgentMinder_Status: str(r['AgentMinder_Status']) || 'Not Touched',
Current_ARR_USD: num(r['Current_ARR_USD']),
Touch_Count: int(r['Touch_Count']) || 0,
Date_First_Touched: excelDate(r['Date_First_Touched']),
Date_Last_Touched: excelDate(r['Date_Last_Touched']),
Company_URL: str(r['Company URL']),
Area_Sales_Leader: str(r['Area Sales Leader']),
DM: str(r['DM']),
AD: str(r['AD']),
Logo_URL: str(r['Logo URL']),
Next_Renewal_Date: excelDate(r['Next Renewal Date']),
Next_Renewal_EAR: num(r['Next Renewal EAR']),
Anchor_Contract_Date: excelDate(r['Anchor Contract Date']),
Anchor_Contract_EAR: num(r['Anchor Contract EAR']),
});
acctCount++;
}
});
acctTx();
console.log(`Imported ${acctCount} accounts`);
// Also load SE TIER for fallback district/tier lookup
const seTier = XLSX.utils.sheet_to_json(wb.Sheets['SE TIER']);
for (const r of seTier) {
const name = str(r['Parent Account Name']);
const district = str(r['FY26 District']);
const tier = str(r['Tier']);
if (name && district) {
if (!acctDistrictMap[name]) acctDistrictMap[name] = district;
if (!acctDistrictMap[name.toUpperCase()]) acctDistrictMap[name.toUpperCase()] = district;
}
if (name && tier) {
if (!acctTierMap[name]) acctTierMap[name] = tier;
if (!acctTierMap[name.toUpperCase()]) acctTierMap[name.toUpperCase()] = tier;
}
}
// Helper to resolve district for an account name
function resolveDistrict(accountName, rawDistrict) {
if (rawDistrict && rawDistrict !== 'Unmatched' && rawDistrict !== '') return rawDistrict;
return acctDistrictMap[accountName] || acctDistrictMap[accountName?.toUpperCase()] || '';
}
function resolveTier(accountName, rawTier) {
if (rawTier && rawTier !== 'Unmatched' && rawTier !== '') return rawTier;
return acctTierMap[accountName] || acctTierMap[accountName?.toUpperCase()] || '';
}
// ===== 2. Import Pipeline =====
console.log('\n--- Importing Pipeline ---');
const pipe = XLSX.utils.sheet_to_json(wb.Sheets['Pipeline']);
const pipeInsert = 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 pipeCount = 0;
const pipeTx = db.transaction(() => {
for (const r of pipe) {
const oppId = str(r['Opportunity_ID']);
if (!oppId) continue;
const acctName = str(r['Account_Name']) || '';
pipeInsert.run({
Opportunity_ID: oppId,
Account_Name: acctName,
District_Name: resolveDistrict(acctName, str(r['District_Name'])),
Stage: str(r['Stage']) || '01-Qualified',
Forecast_Category: str(r['Forecast_Category']) || 'Pipeline',
Amount_USD: num(r['Amount_USD']) || 0,
Closed_Amount_USD: num(r['Closed_Amount_USD']),
Probability_Pct: int(r['Probability_Pct']) || 0,
Created_Date: excelDate(r['Created_Date']) || '',
Expected_Close_Date: excelDate(r['Expected_Close_Date']) || '',
Closed_Date: excelDate(r['Closed_Date']),
Champion_Name: str(r['Champion_Name']),
Economic_Buyer: str(r['Economic_Buyer']),
Next_Step: str(r['Next_Step']),
Next_Step_Date: excelDate(r['Next_Step_Date']),
Primary_Objection: str(r['Primary_Objection']),
Competitor: str(r['Competitor']),
Source_Play: str(r['Source_Play']),
Product: str(r['Product']) || 'AgentMinder',
Deal_Type: str(r['Deal_Type']),
Status: str(r['Status']) || 'Open',
Stage_Entered_Date: excelDate(r['Stage_Entered_Date']),
Tier: resolveTier(acctName, str(r['Tier'])),
MAP_In_Place_YN: str(r['MAP_In_Place_YN']),
});
pipeCount++;
}
});
pipeTx();
console.log(`Imported ${pipeCount} pipeline deals`);
// ===== 3. Import Activities =====
console.log('\n--- Importing Activities ---');
const acts = XLSX.utils.sheet_to_json(wb.Sheets['Activity_Log']);
const actInsert = 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 actCount = 0;
const actTx = db.transaction(() => {
for (const r of acts) {
const acctName = str(r['Account_Name']);
const date = excelDate(r['Activity_Date']);
if (!acctName || !date) continue;
actInsert.run({
Activity_ID: str(r['Activity_ID']),
Activity_Date: date,
Activity_Type: str(r['Activity_Type']) || '',
Account_Name: acctName,
District_Name: resolveDistrict(acctName, str(r['District_Name'])),
Contact_Name: str(r['Contact_Name']),
Notes: str(r['Notes']),
Play: str(r['Play']),
Channel: str(r['Channel']),
Persona: str(r['Persona']),
Outcome: str(r['Outcome']),
Logged_By: str(r['Logged_By']),
});
actCount++;
}
});
actTx();
console.log(`Imported ${actCount} activities`);
// ===== 4. Import Implementation =====
console.log('\n--- Importing Implementation ---');
const impl = XLSX.utils.sheet_to_json(wb.Sheets['Implementation']);
const implInsert = 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 implCount = 0;
const implTx = db.transaction(() => {
for (const r of impl) {
const id = str(r['Implementation_ID']);
const acctName = str(r['Account_Name']);
if (!id || !acctName) continue;
implInsert.run({
Implementation_ID: id,
Opportunity_ID: str(r['Opportunity_ID']),
Account_Name: acctName,
District_Name: resolveDistrict(acctName, str(r['District_Name'])),
Tier: resolveTier(acctName, str(r['Tier'])),
Closed_Date: excelDate(r['Closed_Date']),
Closed_Amount_USD: num(r['Closed_Amount_USD']),
Kickoff_Date: excelDate(r['Kickoff_Date']),
Go_Live_Target: excelDate(r['Go_Live_Target']),
Go_Live_Actual: excelDate(r['Go_Live_Actual']),
Deployment_Status: str(r['Deployment_Status']) || 'Not Started',
Health_RYG: str(r['Health_RYG']),
Milestones_Complete: int(r['Milestones_Complete']) || 0,
Milestones_Total: int(r['Milestones_Total']) || 5,
Onboarding_Owner: str(r['Onboarding_Owner']),
Risk_Notes: str(r['Risk_Notes']),
});
implCount++;
}
});
implTx();
console.log(`Imported ${implCount} implementations`);
// ===== 5. Import Metric Targets =====
console.log('\n--- Importing Metric Targets ---');
const targets = XLSX.utils.sheet_to_json(wb.Sheets['Targets']);
const tgtInsert = 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
`);
// District name normalization for Targets tab
const districtNorm = {
'Valley': 'SE-MISS-VALLEY',
'PeachTree': 'SE-PEACHTREE',
'Peachtree': 'SE-PEACHTREE',
'Sunshine': 'SE-SUNSHINE',
'MidAtl': 'SE-MID-ATL',
'Mid-Atl': 'SE-MID-ATL',
};
let tgtCount = 0;
const tgtTx = db.transaction(() => {
for (const r of targets) {
const id = str(r['Target_ID']);
if (!id) continue;
const rawDistrict = str(r['District_Name']) || '';
const district = districtNorm[rawDistrict] || rawDistrict;
tgtInsert.run({
Target_ID: id,
Period_Type: str(r['Period_Type']) || '',
Period_Label: str(r['Period_Label']) || '',
District_Name: district,
Metric: str(r['Metric']) || '',
Target_Value: num(r['Target_Value']) || 0,
Notes: str(r['Notes']),
});
tgtCount++;
}
});
tgtTx();
console.log(`Imported ${tgtCount} metric targets`);
// ===== 6. Recalculate touch data from activities =====
console.log('\n--- Recalculating touch data ---');
const touchData = 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();
const updateTouch = 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 touchTx = db.transaction(() => {
for (const t of touchData) {
updateTouch.run(t);
}
});
touchTx();
console.log(`Updated touch data for ${touchData.length} accounts`);
// ===== 7. Update AgentMinder_Status based on activity/pipeline =====
console.log('\n--- Updating AgentMinder status ---');
const statusResult = db.prepare(`
UPDATE accounts SET AgentMinder_Status = '10% - Prospect'
WHERE Account_Name IN (SELECT DISTINCT Account_Name FROM activities)
AND (AgentMinder_Status = 'Not Touched' OR AgentMinder_Status IS NULL)
`).run();
console.log(`Updated ${statusResult.changes} accounts to 10% - Prospect based on activities`);
const statusResult2 = db.prepare(`
UPDATE accounts SET AgentMinder_Status = '20% - Research'
WHERE Account_Name IN (SELECT DISTINCT Account_Name FROM pipeline WHERE Status = 'Open')
AND (AgentMinder_Status = 'Not Touched' OR AgentMinder_Status = '10% - Prospect')
`).run();
console.log(`Updated ${statusResult2.changes} accounts to 20% - Research based on open pipeline`);
// ===== Summary =====
console.log('\n===== IMPORT COMPLETE =====');
const stats = {
accounts: db.prepare('SELECT COUNT(*) as c FROM accounts').get().c,
pipeline: db.prepare('SELECT COUNT(*) as c FROM pipeline').get().c,
activities: db.prepare('SELECT COUNT(*) as c FROM activities').get().c,
implementations: db.prepare('SELECT COUNT(*) as c FROM implementations').get().c,
metricTargets: db.prepare('SELECT COUNT(*) as c FROM metric_targets').get().c,
};
console.log('Database stats:', stats);
// Show some data quality info
const unmatchedPipe = db.prepare("SELECT COUNT(*) as c FROM pipeline WHERE District_Name = '' OR District_Name IS NULL").get().c;
console.log(`\nPipeline deals with unresolved district: ${unmatchedPipe}`);
const touchedAccts = db.prepare("SELECT COUNT(*) as c FROM accounts WHERE Touch_Count > 0").get().c;
console.log(`Accounts with touch data: ${touchedAccts}`);
const districtBreakdown = db.prepare("SELECT District_Name, COUNT(*) as c FROM accounts WHERE District_Name != '' GROUP BY District_Name ORDER BY c DESC").all();
console.log('\nAccounts by district:');
districtBreakdown.forEach(r => console.log(` ${r.District_Name}: ${r.c}`));
db.close();
console.log('\nDone! Database saved to:', DB_PATH);

View File

@@ -0,0 +1,665 @@
'use client';
import { useData } from '@/lib/data-context';
import { PageHeader } from '@/components/ui/PageHeader';
import { ChartCard } from '@/components/ui/ChartCard';
import { Scorecard } from '@/components/ui/Scorecard';
import { formatCurrency, CHART_COLORS, STATUS_COLORS, DISTRICT_SHORT, STAGE_COLORS, TIER_COLORS } from '@/lib/formatters';
import { useMemo, useState } from 'react';
import { parseISO, format, differenceInDays, eachDayOfInterval, addMonths, addQuarters } from 'date-fns';
import { AccountRecord } from '@/types/data';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
} from 'recharts';
const ACTIVITY_TYPE_COLORS: Record<string, string> = {
'Launch Briefing': CHART_COLORS.navy,
'Exec Meeting': CHART_COLORS.darkGreen,
'Discovery': CHART_COLORS.azure,
'QBR Attach': CHART_COLORS.aqua,
'Demo': CHART_COLORS.green,
'Workshop': CHART_COLORS.brightBlue,
'Email': CHART_COLORS.purple,
'Call': CHART_COLORS.lightBlue,
};
const PRIORITY_COLORS: Record<string, string> = {
'High': '#DC2626',
'Medium': '#F59E0B',
'Low': '#6B7280',
};
const RENEWAL_FILTER_OPTIONS = [
{ label: 'All Renewals', value: 'all' },
{ label: 'Next 90 Days', value: '90d' },
{ label: 'Next 1 Quarter', value: '1q' },
{ label: 'Next 2 Quarters', value: '2q' },
{ label: 'Next 4 Quarters', value: '4q' },
{ label: 'No Renewal Date', value: 'none' },
];
function getSuggestedPriority(account: AccountRecord): { level: string; reason: string } {
const now = new Date();
const renewalDate = account.Next_Renewal_Date ? parseISO(account.Next_Renewal_Date) : null;
const daysToRenewal = renewalDate ? differenceInDays(renewalDate, now) : null;
const touched = (account.Touch_Count || 0) > 0;
const hasArrAbove50k = (account.Current_ARR_USD || 0) > 50000;
const positiveStatus = ['40% - Verify', '60% - Prove', '80% - Pricing', '20% - Research'].includes(account.AgentMinder_Status);
const negativeStatus = ['Not Touched', 'Lost'].includes(account.AgentMinder_Status);
const earlyStatus = ['10% - Prospect', '15% - Prospect - No Opp'].includes(account.AgentMinder_Status);
// FY27 runs Feb 2027 Jan 2028. Q1-Q3 = Feb 2027 Oct 2027 → roughly next 2-14 months from now
// Priority 1: Renewal in ~0-14 months, positive disposition, or high ARR
if (daysToRenewal !== null && daysToRenewal <= 420 && daysToRenewal >= 0) {
if (positiveStatus || hasArrAbove50k || touched) {
return { level: 'High', reason: `Renewal in ${daysToRenewal} days${positiveStatus ? ', positive disposition' : ''}${hasArrAbove50k ? ', high ARR' : ''}` };
}
if (daysToRenewal <= 180) {
return { level: 'High', reason: `Near-term renewal (${daysToRenewal} days), engage under 60 days` };
}
}
// Priority 2: Renewal in 14-24 months, early stage, or neutral
if (daysToRenewal !== null && daysToRenewal > 420 && daysToRenewal <= 730) {
return { level: 'Medium', reason: `Mid-term renewal (${Math.round(daysToRenewal / 30)} months), pipeline building` };
}
if (daysToRenewal !== null && daysToRenewal <= 420 && earlyStatus) {
return { level: 'Medium', reason: `Renewal in ${daysToRenewal} days but early stage, needs nurturing` };
}
if (touched && !negativeStatus && !positiveStatus) {
return { level: 'Medium', reason: 'Active engagement, neutral disposition' };
}
// Priority 3: No near-term event, negative disposition, or untouched
if (negativeStatus) {
return { level: 'Low', reason: 'Negative disposition or not touched' };
}
if (daysToRenewal === null) {
return { level: 'Low', reason: 'No renewal date set' };
}
if (daysToRenewal > 730) {
return { level: 'Low', reason: `Distant renewal (${Math.round(daysToRenewal / 30)} months), monitor and nurture` };
}
return { level: 'Low', reason: 'No compelling near-term event' };
}
export default function AccountExplorer() {
const { filtered } = useData();
const { accounts, pipeline, activities, targets } = filtered;
const [search, setSearch] = useState('');
const [tierFilter, setTierFilter] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [districtFilter, setDistrictFilter] = useState<string | null>(null);
const [adFilter, setAdFilter] = useState<string | null>(null);
const [imsbaFilter, setImsbaFilter] = useState<string | null>(null);
const [renewalFilter, setRenewalFilter] = useState<string>('all');
const [priorityFilter, setPriorityFilter] = useState<string | null>(null);
const [selectedAccount, setSelectedAccount] = useState<AccountRecord | null>(null);
const adValues = useMemo(() => Array.from(new Set(accounts.map(a => a.AD).filter(Boolean))).sort() as string[], [accounts]);
const imsbaValues = useMemo(() => Array.from(new Set(accounts.map(a => a.IMS_BA).filter(Boolean))).sort() as string[], [accounts]);
const filteredAccounts = useMemo(() => {
const now = new Date();
let result = accounts;
if (search) {
const q = search.toLowerCase();
result = result.filter(a => a.Account_Name.toLowerCase().includes(q));
}
if (tierFilter) result = result.filter(a => a.Tier === tierFilter);
if (statusFilter) result = result.filter(a => a.AgentMinder_Status === statusFilter);
if (districtFilter) result = result.filter(a => a.District_Name === districtFilter);
if (adFilter) result = result.filter(a => a.AD === adFilter);
if (imsbaFilter) result = result.filter(a => a.IMS_BA === imsbaFilter);
if (priorityFilter) result = result.filter(a => a.Priority === priorityFilter);
if (renewalFilter !== 'all') {
if (renewalFilter === 'none') {
result = result.filter(a => !a.Next_Renewal_Date);
} else {
let cutoff: Date;
if (renewalFilter === '90d') cutoff = new Date(now.getTime() + 90 * 86400000);
else if (renewalFilter === '1q') cutoff = addQuarters(now, 1);
else if (renewalFilter === '2q') cutoff = addQuarters(now, 2);
else cutoff = addQuarters(now, 4);
result = result.filter(a => {
if (!a.Next_Renewal_Date) return false;
const rd = parseISO(a.Next_Renewal_Date);
return rd >= now && rd <= cutoff;
});
}
}
return result;
}, [accounts, search, tierFilter, statusFilter, districtFilter, adFilter, imsbaFilter, renewalFilter, priorityFilter]);
const kpis = useMemo(() => {
const acctNames = new Set(filteredAccounts.map(a => a.Account_Name));
const relevantPipeline = pipeline.filter(p => acctNames.has(p.Account_Name));
const openPipeline = relevantPipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
const closedWon = relevantPipeline.filter(p => p.Stage === '06-Closed Won');
return {
numAccounts: filteredAccounts.length,
numOpps: openPipeline.length,
pipelineUsd: openPipeline.reduce((s, p) => s + p.Amount_USD, 0),
closedUsd: closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || p.Amount_USD), 0),
totalArr: filteredAccounts.reduce((s, a) => s + (a.Current_ARR_USD || 0), 0),
touched: filteredAccounts.filter(a => (a.Touch_Count || 0) > 0).length,
};
}, [filteredAccounts, pipeline]);
// Chart 1: Priority breakdown
const priorityChartData = useMemo(() => {
const counts: Record<string, number> = { High: 0, Medium: 0, Low: 0 };
filteredAccounts.forEach(a => {
const p = a.Priority || 'Low';
counts[p] = (counts[p] || 0) + 1;
});
return Object.entries(counts).map(([name, value]) => ({ name, value }));
}, [filteredAccounts]);
// Chart 2: Activity heatmap for filtered accounts
const heatmapData = useMemo(() => {
const acctNames = new Set(filteredAccounts.map(a => a.Account_Name));
const relevantActivities = activities.filter(a => acctNames.has(a.Account_Name));
const dayCounts = new Map<string, number>();
relevantActivities.forEach(a => {
dayCounts.set(a.Activity_Date, (dayCounts.get(a.Activity_Date) || 0) + 1);
});
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - 3, 1);
const days = eachDayOfInterval({ start, end: now });
return days.map(d => {
const key = format(d, 'yyyy-MM-dd');
return { date: key, count: dayCounts.get(key) || 0, day: d.getDay(), week: Math.floor(differenceInDays(d, start) / 7) };
});
}, [filteredAccounts, activities]);
const maxHeatVal = Math.max(...heatmapData.map(d => d.count), 1);
const accountPipeline = useMemo(() => {
if (!selectedAccount) return [];
return pipeline.filter(p => p.Account_Name === selectedAccount.Account_Name);
}, [selectedAccount, pipeline]);
const accountActivities = useMemo(() => {
if (!selectedAccount) return [];
return activities
.filter(a => a.Account_Name === selectedAccount.Account_Name)
.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date));
}, [selectedAccount, activities]);
const accountTarget = useMemo(() => {
if (!selectedAccount) return null;
return targets.find(t => t.Account_Name === selectedAccount.Account_Name) || null;
}, [selectedAccount, targets]);
const getAccountStats = (account: AccountRecord) => {
const pipelineTotal = pipeline.filter(p => p.Account_Name === account.Account_Name && p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').reduce((s, p) => s + p.Amount_USD, 0);
return { pipelineTotal };
};
const clearAllFilters = () => {
setTierFilter(null);
setStatusFilter(null);
setDistrictFilter(null);
setAdFilter(null);
setImsbaFilter(null);
setPriorityFilter(null);
setRenewalFilter('all');
setSearch('');
};
const hasFilters = tierFilter || statusFilter || districtFilter || adFilter || imsbaFilter || priorityFilter || renewalFilter !== 'all' || search;
if (selectedAccount) {
const suggestion = getSuggestedPriority(selectedAccount);
return (
<div>
<button onClick={() => setSelectedAccount(null)} className="flex items-center gap-1.5 text-xs text-brand-azure hover:text-brand-navy mb-4 transition">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6" /></svg>
Back to accounts
</button>
{/* Account Header */}
<div className="bg-card-bg rounded-xl border border-card-border p-5 mb-4">
<div className="flex flex-wrap items-start gap-3">
<div className="flex-1">
<h1 className="text-xl font-bold text-foreground">{selectedAccount.Account_Name}</h1>
<div className="flex flex-wrap items-center gap-2 mt-2">
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-semibold" style={{ backgroundColor: TIER_COLORS[selectedAccount.Tier] || '#94A3B8' }}>{selectedAccount.Tier || 'N/A'}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-semibold" style={{ backgroundColor: STATUS_COLORS[selectedAccount.AgentMinder_Status] || '#94A3B8' }}>{selectedAccount.AgentMinder_Status}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-semibold" style={{ backgroundColor: PRIORITY_COLORS[selectedAccount.Priority] || '#94A3B8' }}>{selectedAccount.Priority} Priority</span>
<span className="text-xs text-muted">{DISTRICT_SHORT[selectedAccount.District_Name]}</span>
</div>
</div>
<div className="text-right">
{selectedAccount.Current_ARR_USD != null && selectedAccount.Current_ARR_USD > 0 && (
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Current ARR</div>
<div className="text-lg font-bold">{formatCurrency(selectedAccount.Current_ARR_USD, true)}</div>
</div>
)}
</div>
</div>
{/* Suggested Priority */}
{suggestion.level !== selectedAccount.Priority && (
<div className="mt-3 p-3 rounded-lg border border-dashed" style={{ borderColor: PRIORITY_COLORS[suggestion.level] || '#94A3B8', backgroundColor: `${PRIORITY_COLORS[suggestion.level]}08` }}>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 flex-shrink-0" style={{ color: PRIORITY_COLORS[suggestion.level] }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" /></svg>
<span className="text-xs font-semibold" style={{ color: PRIORITY_COLORS[suggestion.level] }}>
Suggested: {suggestion.level} Priority
</span>
<span className="text-xs text-muted"> {suggestion.reason}</span>
</div>
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t border-card-border">
<div>
<div className="text-[10px] text-muted uppercase">Touches</div>
<div className="text-sm font-semibold">{selectedAccount.Touch_Count || 0}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">First Touched</div>
<div className="text-sm font-semibold">{selectedAccount.Date_First_Touched || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Last Touched</div>
<div className="text-sm font-semibold">{selectedAccount.Date_Last_Touched || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">MAP in Place</div>
<div className="text-sm font-semibold">{selectedAccount.MAP_In_Place_YN === 'Y' ? 'Yes' : 'No'}</div>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3 pt-3 border-t border-card-border">
<div>
<div className="text-[10px] text-muted uppercase">Next Renewal</div>
<div className="text-sm font-semibold">{selectedAccount.Next_Renewal_Date || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Renewal EAR</div>
<div className="text-sm font-semibold">{selectedAccount.Next_Renewal_EAR ? formatCurrency(selectedAccount.Next_Renewal_EAR, true) : '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Anchor Contract</div>
<div className="text-sm font-semibold">{selectedAccount.Anchor_Contract_Date || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Anchor EAR</div>
<div className="text-sm font-semibold">{selectedAccount.Anchor_Contract_EAR ? formatCurrency(selectedAccount.Anchor_Contract_EAR, true) : '—'}</div>
</div>
</div>
{(selectedAccount.AD || selectedAccount.IMS_BA || selectedAccount.Area_Sales_Leader || selectedAccount.DM) && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3 pt-3 border-t border-card-border">
{selectedAccount.Area_Sales_Leader && (
<div>
<div className="text-[10px] text-muted uppercase">Area Sales Leader</div>
<div className="text-sm font-semibold">{selectedAccount.Area_Sales_Leader}</div>
</div>
)}
{selectedAccount.DM && (
<div>
<div className="text-[10px] text-muted uppercase">DM</div>
<div className="text-sm font-semibold">{selectedAccount.DM}</div>
</div>
)}
{selectedAccount.AD && (
<div>
<div className="text-[10px] text-muted uppercase">AD</div>
<div className="text-sm font-semibold">{selectedAccount.AD}</div>
</div>
)}
{selectedAccount.IMS_BA && (
<div>
<div className="text-[10px] text-muted uppercase">IMS BA</div>
<div className="text-sm font-semibold">{selectedAccount.IMS_BA}</div>
</div>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Pipeline Section */}
<div className="bg-card-bg rounded-xl border border-card-border p-5">
<h3 className="text-sm font-semibold mb-3">Pipeline ({accountPipeline.length} opportunities)</h3>
{accountPipeline.length === 0 ? (
<div className="text-xs text-muted py-4 text-center">No pipeline opportunities</div>
) : (
<div className="space-y-2">
{accountPipeline.map(opp => (
<div key={opp.Opportunity_ID} className="border border-card-border rounded-lg p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{formatCurrency(opp.Amount_USD, true)}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: STAGE_COLORS[opp.Stage] || CHART_COLORS.navy }}>{opp.Stage}</span>
</div>
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted">
<span>{opp.Forecast_Category}</span>
<span>{opp.Probability_Pct}% prob</span>
{opp.Expected_Close_Date && <span>Close: {format(parseISO(opp.Expected_Close_Date), 'MMM d')}</span>}
</div>
{opp.Next_Step && <div className="text-xs text-muted mt-1.5 italic">{opp.Next_Step}</div>}
</div>
))}
</div>
)}
</div>
{/* Activity Timeline */}
<div className="bg-card-bg rounded-xl border border-card-border p-5">
<h3 className="text-sm font-semibold mb-3">Activity Timeline ({accountActivities.length} activities)</h3>
{accountActivities.length === 0 ? (
<div className="text-xs text-muted py-4 text-center">No activities logged</div>
) : (
<div className="relative">
<div className="absolute left-3 top-0 bottom-0 w-px bg-card-border" />
<div className="space-y-4">
{accountActivities.slice(0, 20).map((a, i) => (
<div key={i} className="flex items-start gap-3 relative">
<div
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 relative z-10"
style={{ backgroundColor: ACTIVITY_TYPE_COLORS[a.Activity_Type] || CHART_COLORS.navy }}
>
<div className="w-2 h-2 rounded-full bg-white" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{a.Activity_Type}</span>
<span className="text-[10px] text-muted">{format(parseISO(a.Activity_Date), 'MMM d, yyyy')}</span>
</div>
{a.Contact_Name && <div className="text-[10px] text-muted mt-0.5">with {a.Contact_Name}</div>}
{a.Notes && <div className="text-xs text-muted mt-1 bg-gray-50 rounded p-2">{a.Notes}</div>}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Implementation Status */}
{accountTarget && (
<div className="bg-card-bg rounded-xl border border-card-border p-5 mt-4">
<h3 className="text-sm font-semibold mb-3">Implementation Status</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div>
<div className="text-[10px] text-muted uppercase">Stage</div>
<div className="text-sm font-medium">{accountTarget.Implementation_Stage}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Health</div>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: accountTarget.Health_Status ? { Green: '#61A60E', Yellow: '#F59E0B', Red: '#EF4444' }[accountTarget.Health_Status] : '#94A3B8' }} />
<span className="text-sm font-medium">{accountTarget.Health_Status || '—'}</span>
</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Go-Live Date</div>
<div className="text-sm font-medium">{accountTarget.Go_Live_Date || '—'}</div>
</div>
{accountTarget.Notes && (
<div>
<div className="text-[10px] text-muted uppercase">Notes</div>
<div className="text-sm">{accountTarget.Notes}</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
return (
<div>
<PageHeader title="Account Explorer" subtitle="Tell me everything about this account" />
{/* KPI Summary Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-4">
<Scorecard label="Accounts" value={kpis.numAccounts} />
<Scorecard label="Touched" value={kpis.touched} subtitle={kpis.numAccounts > 0 ? `${Math.round((kpis.touched / kpis.numAccounts) * 100)}%` : '0%'} color={kpis.touched > 0 ? 'green' : undefined} />
<Scorecard label="Open Opps" value={kpis.numOpps} />
<Scorecard label="Pipeline" value={formatCurrency(kpis.pipelineUsd, true)} />
<Scorecard label="Closed Won" value={formatCurrency(kpis.closedUsd, true)} color={kpis.closedUsd > 0 ? 'green' : undefined} />
<Scorecard label="Total ARR" value={formatCurrency(kpis.totalArr, true)} />
</div>
{/* Summary Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Chart 1: Priority Breakdown */}
<ChartCard title="Account Prioritization" subtitle={`${filteredAccounts.length} accounts by priority level`}>
<div className="flex items-center gap-6">
<div className="w-48 h-36">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={priorityChartData} layout="vertical" margin={{ left: 0, right: 10, top: 0, bottom: 0 }}>
<XAxis type="number" hide />
<YAxis type="category" dataKey="name" width={55} tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip formatter={(value: number) => [value, 'Accounts']} />
<Bar dataKey="value" radius={[0, 4, 4, 0]} barSize={20}>
{priorityChartData.map(entry => (
<Cell key={entry.name} fill={PRIORITY_COLORS[entry.name] || '#94A3B8'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-2">
{priorityChartData.map(d => (
<div key={d.name} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded" style={{ backgroundColor: PRIORITY_COLORS[d.name] }} />
<span className="text-xs font-medium">{d.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-bold">{d.value}</span>
<span className="text-[10px] text-muted">{filteredAccounts.length > 0 ? `${Math.round((d.value / filteredAccounts.length) * 100)}%` : '0%'}</span>
</div>
</div>
))}
</div>
</div>
</ChartCard>
{/* Chart 2: Activity Heatmap */}
<ChartCard title="Activity Heat Map" subtitle="Daily activity for filtered accounts (last 3 months)">
<div className="overflow-x-auto">
<div className="flex gap-[2px] min-w-[500px]">
{Array.from({ length: Math.max(...heatmapData.map(d => d.week), 0) + 1 }, (_, weekIdx) => (
<div key={weekIdx} className="flex flex-col gap-[2px]">
{Array.from({ length: 7 }, (_, dayIdx) => {
const cell = heatmapData.find(d => d.week === weekIdx && d.day === dayIdx);
const intensity = cell ? cell.count / maxHeatVal : 0;
return (
<div
key={dayIdx}
className="w-3 h-3 rounded-sm"
style={{
backgroundColor: intensity === 0
? '#E2E8F0'
: `rgba(0, 92, 138, ${0.2 + intensity * 0.8})`,
}}
title={cell ? `${cell.date}: ${cell.count} activities` : ''}
/>
);
})}
</div>
))}
</div>
<div className="flex items-center gap-1 mt-2 text-[10px] text-muted">
<span>Less</span>
{[0, 0.25, 0.5, 0.75, 1].map(i => (
<div key={i} className="w-3 h-3 rounded-sm" style={{ backgroundColor: i === 0 ? '#E2E8F0' : `rgba(0, 92, 138, ${0.2 + i * 0.8})` }} />
))}
<span>More</span>
</div>
</div>
</ChartCard>
</div>
{/* Search & Filters */}
<div className="flex flex-wrap gap-2 mb-2">
<input
type="text"
placeholder="Search accounts..."
value={search}
onChange={e => setSearch(e.target.value)}
className="text-xs border border-card-border rounded-lg px-3 py-2 w-56 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
/>
{['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'].map(t => (
<button
key={t}
onClick={() => setTierFilter(tierFilter === t ? null : t)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${tierFilter === t ? 'text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
style={tierFilter === t ? { backgroundColor: TIER_COLORS[t] } : undefined}
>{t}</button>
))}
{Array.from(new Set(accounts.map(a => a.AgentMinder_Status))).sort().map(s => (
<button
key={s}
onClick={() => setStatusFilter(statusFilter === s ? null : s)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${statusFilter === s ? 'text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
style={statusFilter === s ? { backgroundColor: STATUS_COLORS[s] || CHART_COLORS.navy } : undefined}
>{s}</button>
))}
</div>
{/* Priority filter pills */}
<div className="flex flex-wrap gap-2 mb-2">
{['High', 'Medium', 'Low'].map(p => (
<button
key={p}
onClick={() => setPriorityFilter(priorityFilter === p ? null : p)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${priorityFilter === p ? 'text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
style={priorityFilter === p ? { backgroundColor: PRIORITY_COLORS[p] } : undefined}
>{p} Priority</button>
))}
</div>
{/* District, AD, IMS BA, Renewal dropdowns */}
<div className="flex flex-wrap gap-2 mb-4">
<select
value={districtFilter || ''}
onChange={e => setDistrictFilter(e.target.value || null)}
className="text-xs border border-card-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-azure/30 bg-white"
>
<option value="">All Districts</option>
{Array.from(new Set(accounts.map(a => a.District_Name))).sort().map(d => (
<option key={d} value={d}>{DISTRICT_SHORT[d] || d}</option>
))}
</select>
{adValues.length > 0 && (
<select
value={adFilter || ''}
onChange={e => setAdFilter(e.target.value || null)}
className="text-xs border border-card-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-azure/30 bg-white"
>
<option value="">All ADs</option>
{adValues.map(v => <option key={v} value={v}>{v}</option>)}
</select>
)}
{imsbaValues.length > 0 && (
<select
value={imsbaFilter || ''}
onChange={e => setImsbaFilter(e.target.value || null)}
className="text-xs border border-card-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-azure/30 bg-white"
>
<option value="">All IMS BAs</option>
{imsbaValues.map(v => <option key={v} value={v}>{v}</option>)}
</select>
)}
<select
value={renewalFilter}
onChange={e => setRenewalFilter(e.target.value)}
className="text-xs border border-card-border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-azure/30 bg-white"
>
{RENEWAL_FILTER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{hasFilters && (
<button onClick={clearAllFilters} className="px-3 py-1.5 rounded-lg text-xs font-medium text-muted hover:text-foreground hover:bg-gray-100 transition">
Clear all
</button>
)}
</div>
{/* Account Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredAccounts.map(account => {
const stats = getAccountStats(account);
const suggestion = getSuggestedPriority(account);
const mismatch = suggestion.level !== account.Priority;
return (
<div
key={account.Account_Name}
onClick={() => setSelectedAccount(account)}
className="bg-card-bg rounded-xl border border-card-border p-4 cursor-pointer hover:shadow-md hover:border-brand-azure/30 transition-all"
>
<div className="flex items-start justify-between mb-2">
<h3 className="text-sm font-semibold text-foreground leading-tight pr-2">{account.Account_Name}</h3>
<div className="flex items-center gap-1 flex-shrink-0">
<span className="px-1.5 py-0.5 rounded text-[9px] text-white font-bold" style={{ backgroundColor: TIER_COLORS[account.Tier] || '#94A3B8' }}>{account.Tier || 'N/A'}</span>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: STATUS_COLORS[account.AgentMinder_Status] }} />
</div>
</div>
<div className="flex items-center gap-1.5 mb-2">
<span className="px-1.5 py-0.5 rounded text-[9px] font-medium text-white" style={{ backgroundColor: PRIORITY_COLORS[account.Priority] || '#94A3B8' }}>{account.Priority}</span>
<span className="px-1.5 py-0.5 rounded text-[9px] font-medium text-white" style={{ backgroundColor: STATUS_COLORS[account.AgentMinder_Status] || '#94A3B8' }}>{account.AgentMinder_Status}</span>
<span className="text-[10px] text-muted">{DISTRICT_SHORT[account.District_Name]}</span>
{account.AD && <span className="text-[10px] text-muted">| {account.AD}</span>}
{(account.Google_Drive_URL || account.Campaign_Artifacts_URL) && (
<div className="flex items-center gap-1.5 ml-auto">
{account.Google_Drive_URL && (
<a href={account.Google_Drive_URL} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} title="Google Drive" className="text-muted hover:text-brand-navy transition">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M7.71 3.5L1.15 15l3.43 5.97h6.86l-3.43-5.97L7.71 3.5zm.57 0h6.86L21.71 15h-6.86L8.28 3.5zm7.14 12.03L18.85 21H5.15l3.43-5.47h6.84z"/></svg>
</a>
)}
{account.Campaign_Artifacts_URL && (
<a href={account.Campaign_Artifacts_URL} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} title="Campaign Artifacts" className="text-muted hover:text-brand-navy transition">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
</a>
)}
</div>
)}
</div>
{mismatch && (
<div className="flex items-center gap-1 mb-2 text-[10px]" style={{ color: PRIORITY_COLORS[suggestion.level] }}>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 9v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Suggested: {suggestion.level}
</div>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<div className="text-[10px] text-muted">ARR</div>
<div className="font-medium">{account.Current_ARR_USD ? formatCurrency(account.Current_ARR_USD, true) : '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted">Pipeline</div>
<div className="font-medium">{stats.pipelineTotal > 0 ? formatCurrency(stats.pipelineTotal, true) : '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted">Next Renewal</div>
<div className="font-medium">{account.Next_Renewal_Date || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted">Renewal EAR</div>
<div className="font-medium">{account.Next_Renewal_EAR ? formatCurrency(account.Next_Renewal_EAR, true) : '—'}</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,598 @@
'use client';
import { useData } from '@/lib/data-context';
import { ChartCard } from '@/components/ui/ChartCard';
import { PageHeader } from '@/components/ui/PageHeader';
import { CHART_COLORS, CHART_PALETTE, DISTRICT_SHORT, formatPercent, formatCurrency, STATUS_COLORS, TIER_COLORS, STAGE_COLORS } from '@/lib/formatters';
import { useMemo, useState } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend,
PieChart, Pie, Cell,
} from 'recharts';
import { parseISO, format, startOfWeek, differenceInDays, eachDayOfInterval } from 'date-fns';
import { ActivityRecord } from '@/types/data';
const TYPE_COLORS: Record<string, string> = {
'Launch Briefing': CHART_COLORS.navy,
'Exec Meeting': CHART_COLORS.darkGreen,
'Discovery': CHART_COLORS.azure,
'QBR Attach': CHART_COLORS.aqua,
'Demo': CHART_COLORS.green,
'Workshop': CHART_COLORS.brightBlue,
'Email': CHART_COLORS.purple,
'Call': CHART_COLORS.lightBlue,
};
const PRIORITY_COLORS: Record<string, string> = {
'High': '#DC2626',
'Medium': '#F59E0B',
'Low': '#6B7280',
};
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) {
if (!active || !payload?.length) return null;
return (
<div className="bg-white rounded-lg shadow-lg border border-card-border px-3 py-2 text-xs z-50">
<div className="font-semibold text-foreground mb-1">{label}</div>
{payload.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: p.color }} />
<span className="text-muted">{p.name}:</span>
<span className="font-medium">{p.value}</span>
</div>
))}
</div>
);
}
export default function ActivityDeepDive() {
const { filtered, config } = useData();
const { activities, accounts, pipeline, targets } = filtered;
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [sortField, setSortField] = useState<'Activity_Date' | 'Account_Name' | 'Activity_Type'>('Activity_Date');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(0);
const [selectedActivity, setSelectedActivity] = useState<ActivityRecord | null>(null);
const pageSize = 15;
const filteredActivities = useMemo(() => {
let result = activities;
if (typeFilter.size > 0) {
result = result.filter(a => typeFilter.has(a.Activity_Type));
}
if (searchQuery) {
const q = searchQuery.toLowerCase();
result = result.filter(a =>
a.Account_Name.toLowerCase().includes(q) ||
a.Activity_Type.toLowerCase().includes(q) ||
(a.Notes || '').toLowerCase().includes(q) ||
(a.Contact_Name || '').toLowerCase().includes(q)
);
}
return result;
}, [activities, typeFilter, searchQuery]);
const heatmapData = useMemo(() => {
const dayCounts = new Map<string, number>();
filteredActivities.forEach(a => {
dayCounts.set(a.Activity_Date, (dayCounts.get(a.Activity_Date) || 0) + 1);
});
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - 3, 1);
const days = eachDayOfInterval({ start, end: now });
return days.map(d => {
const key = format(d, 'yyyy-MM-dd');
return { date: key, count: dayCounts.get(key) || 0, day: d.getDay(), week: Math.floor(differenceInDays(d, start) / 7) };
});
}, [filteredActivities]);
const maxHeatVal = Math.max(...heatmapData.map(d => d.count), 1);
const activityMix = useMemo(() => {
const counts = new Map<string, number>();
filteredActivities.forEach(a => {
counts.set(a.Activity_Type, (counts.get(a.Activity_Type) || 0) + 1);
});
const total = filteredActivities.length || 1;
return Array.from(counts.entries())
.map(([name, value]) => ({ name, value, pct: (value / total) * 100 }))
.sort((a, b) => b.value - a.value);
}, [filteredActivities]);
const activityByDistrict = useMemo(() => {
const types = [...new Set(filteredActivities.map(a => a.Activity_Type))];
const districtMap = new Map<string, Record<string, number>>();
filteredActivities.forEach(a => {
const d = DISTRICT_SHORT[a.District_Name] || a.District_Name;
if (!districtMap.has(d)) {
const init: Record<string, number> = {};
types.forEach(t => init[t] = 0);
districtMap.set(d, init);
}
districtMap.get(d)![a.Activity_Type] = (districtMap.get(d)![a.Activity_Type] || 0) + 1;
});
return { data: Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data })), types };
}, [filteredActivities]);
const paceData = useMemo(() => {
const weekMap = new Map<string, number>();
filteredActivities.forEach(a => {
const week = format(startOfWeek(parseISO(a.Activity_Date), { weekStartsOn: 1 }), 'MMM d');
weekMap.set(week, (weekMap.get(week) || 0) + 1);
});
const weeks = Array.from(weekMap.entries()).map(([week, count]) => ({ week, count }));
const last4 = weeks.slice(-4);
const avg = last4.length > 0 ? last4.reduce((s, w) => s + w.count, 0) / last4.length : 0;
return { avg: Math.round(avg), target: config.weeklyActivityTarget, pct: config.weeklyActivityTarget > 0 ? (avg / config.weeklyActivityTarget) * 100 : 0 };
}, [filteredActivities, config]);
const sortedTableData = useMemo(() => {
return [...filteredActivities].sort((a, b) => {
const aVal = a[sortField] || '';
const bVal = b[sortField] || '';
return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
});
}, [filteredActivities, sortField, sortDir]);
const pagedData = sortedTableData.slice(page * pageSize, (page + 1) * pageSize);
const totalPages = Math.ceil(sortedTableData.length / pageSize);
// Detail overlay data
const overlayData = useMemo(() => {
if (!selectedActivity) return null;
const account = accounts.find(a => a.Account_Name === selectedActivity.Account_Name);
const acctPipeline = pipeline.filter(p => p.Account_Name === selectedActivity.Account_Name);
const acctActivities = activities
.filter(a => a.Account_Name === selectedActivity.Account_Name)
.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date));
const openPipeline = acctPipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
const closedWon = acctPipeline.filter(p => p.Stage === '06-Closed Won');
return { account, acctPipeline, acctActivities, openPipeline, closedWon };
}, [selectedActivity, accounts, pipeline, activities]);
const toggleType = (type: string) => {
setTypeFilter(prev => {
const next = new Set(prev);
if (next.has(type)) next.delete(type); else next.add(type);
return next;
});
setPage(0);
};
const handleSort = (field: typeof sortField) => {
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortField(field); setSortDir('desc'); }
};
const allTypes = [...new Set(activities.map(a => a.Activity_Type))].sort();
return (
<div>
<PageHeader title="Activities" subtitle="Are we doing the right activities at the right volume?" />
{/* Type filter chips */}
<div className="flex flex-wrap gap-2 mb-4">
{allTypes.map(type => (
<button
key={type}
onClick={() => toggleType(type)}
className={`px-3 py-1 rounded-full text-xs font-medium transition ${
typeFilter.size === 0 || typeFilter.has(type)
? 'text-white'
: 'bg-gray-100 text-muted hover:bg-gray-200'
}`}
style={typeFilter.size === 0 || typeFilter.has(type) ? { backgroundColor: TYPE_COLORS[type] || CHART_COLORS.navy } : undefined}
>
{type}
</button>
))}
{typeFilter.size > 0 && (
<button onClick={() => setTypeFilter(new Set())} className="px-3 py-1 rounded-full text-xs font-medium text-muted hover:text-foreground transition">
Clear
</button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Activity Heatmap */}
<ChartCard title="Activity Heatmap" subtitle="Daily activity density (last 3 months)">
<div className="overflow-x-auto">
<div className="flex gap-[2px] min-w-[500px]">
{Array.from({ length: Math.max(...heatmapData.map(d => d.week)) + 1 }, (_, weekIdx) => (
<div key={weekIdx} className="flex flex-col gap-[2px]">
{Array.from({ length: 7 }, (_, dayIdx) => {
const cell = heatmapData.find(d => d.week === weekIdx && d.day === dayIdx);
const intensity = cell ? cell.count / maxHeatVal : 0;
return (
<div
key={dayIdx}
className="w-3 h-3 rounded-sm"
style={{
backgroundColor: intensity === 0
? '#E2E8F0'
: `rgba(0, 92, 138, ${0.2 + intensity * 0.8})`,
}}
title={cell ? `${cell.date}: ${cell.count} activities` : ''}
/>
);
})}
</div>
))}
</div>
<div className="flex items-center gap-1 mt-2 text-[10px] text-muted">
<span>Less</span>
{[0, 0.25, 0.5, 0.75, 1].map(i => (
<div key={i} className="w-3 h-3 rounded-sm" style={{ backgroundColor: i === 0 ? '#E2E8F0' : `rgba(0, 92, 138, ${0.2 + i * 0.8})` }} />
))}
<span>More</span>
</div>
</div>
</ChartCard>
{/* Activity Mix */}
<ChartCard title="Activity Mix by Type" subtitle="Breakdown of activity types">
<div className="flex items-center">
<ResponsiveContainer width="50%" height={200}>
<PieChart>
<Pie data={activityMix} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={50} outerRadius={80} paddingAngle={2}>
{activityMix.map((entry, i) => (
<Cell key={entry.name} fill={TYPE_COLORS[entry.name] || CHART_PALETTE[i % CHART_PALETTE.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-1.5">
{activityMix.slice(0, 6).map(item => (
<div key={item.name} className="flex items-center gap-2 text-xs">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: TYPE_COLORS[item.name] || CHART_COLORS.navy }} />
<span className="text-muted truncate">{item.name}</span>
<span className="ml-auto font-medium">{item.value}</span>
<span className="text-muted w-10 text-right">{formatPercent(item.pct)}</span>
</div>
))}
</div>
</div>
</ChartCard>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Activity by District */}
<ChartCard title="Activity by District" subtitle="Activity counts across districts">
<ResponsiveContainer width="100%" height={240}>
<BarChart data={activityByDistrict.data} margin={{ left: 0, right: 10, top: 5, bottom: 5 }}>
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#64748B' }} />
<YAxis tick={{ fontSize: 11, fill: '#64748B' }} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 10 }} />
{activityByDistrict.types.slice(0, 6).map((type, i) => (
<Bar key={type} dataKey={type} fill={TYPE_COLORS[type] || CHART_PALETTE[i % CHART_PALETTE.length]} />
))}
</BarChart>
</ResponsiveContainer>
</ChartCard>
{/* Activity Pace vs Target */}
<ChartCard title="Activity Pace vs. Target" subtitle="Trailing 4-week average">
<div className="flex flex-col items-center justify-center h-[240px]">
<div className="relative w-48 h-48">
<svg viewBox="0 0 200 200" className="w-full h-full">
<circle cx="100" cy="100" r="85" fill="none" stroke="#E2E8F0" strokeWidth="12" />
<circle
cx="100" cy="100" r="85"
fill="none"
stroke={paceData.pct >= 100 ? CHART_COLORS.green : paceData.pct >= 75 ? CHART_COLORS.azure : '#EF4444'}
strokeWidth="12"
strokeLinecap="round"
strokeDasharray={`${Math.min(paceData.pct, 100) * 5.34} 534`}
transform="rotate(-90 100 100)"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="text-3xl font-bold text-foreground">{paceData.avg}</div>
<div className="text-xs text-muted">per week</div>
</div>
</div>
<div className="flex items-center gap-4 mt-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: CHART_COLORS.azure }} />
<span className="text-muted">Actual: {paceData.avg}/wk</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-gray-300" />
<span className="text-muted">Target: {paceData.target}/wk</span>
</div>
</div>
</div>
</ChartCard>
</div>
{/* Activity Detail Table */}
<ChartCard title="Activity Detail" subtitle={`${filteredActivities.length} activities`}
action={
<input
type="text"
placeholder="Search activities..."
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setPage(0); }}
className="text-xs border border-card-border rounded-lg px-3 py-1.5 w-48 focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
/>
}
>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-card-border">
{[
{ key: 'Activity_Date' as const, label: 'Date' },
{ key: 'Account_Name' as const, label: 'Account' },
{ key: 'Activity_Type' as const, label: 'Type' },
].map(col => (
<th key={col.key} className="text-left py-2 px-2 text-muted font-medium cursor-pointer hover:text-foreground" onClick={() => handleSort(col.key)}>
{col.label} {sortField === col.key && (sortDir === 'asc' ? '↑' : '↓')}
</th>
))}
<th className="text-left py-2 px-2 text-muted font-medium">District</th>
<th className="text-left py-2 px-2 text-muted font-medium hidden md:table-cell">Contact</th>
<th className="text-left py-2 px-2 text-muted font-medium hidden lg:table-cell">Notes</th>
</tr>
</thead>
<tbody>
{pagedData.map((a, i) => (
<tr
key={i}
className="border-b border-card-border/50 hover:bg-brand-azure/5 transition cursor-pointer"
onClick={() => setSelectedActivity(a)}
>
<td className="py-2 px-2 text-muted">{format(parseISO(a.Activity_Date), 'MMM d')}</td>
<td className="py-2 px-2 font-medium">{a.Account_Name}</td>
<td className="py-2 px-2">
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: TYPE_COLORS[a.Activity_Type] || CHART_COLORS.navy }}>
{a.Activity_Type}
</span>
</td>
<td className="py-2 px-2 text-muted">{DISTRICT_SHORT[a.District_Name] || a.District_Name}</td>
<td className="py-2 px-2 text-muted hidden md:table-cell">{a.Contact_Name || '—'}</td>
<td className="py-2 px-2 text-muted hidden lg:table-cell max-w-[200px] truncate">{a.Notes || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-card-border">
<span className="text-xs text-muted">Page {page + 1} of {totalPages}</span>
<div className="flex gap-1">
<button disabled={page === 0} onClick={() => setPage(p => p - 1)} className="px-2 py-1 text-xs rounded border border-card-border hover:bg-gray-50 disabled:opacity-30">Prev</button>
<button disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)} className="px-2 py-1 text-xs rounded border border-card-border hover:bg-gray-50 disabled:opacity-30">Next</button>
</div>
</div>
)}
</ChartCard>
{/* Activity Detail Overlay */}
{selectedActivity && overlayData && (
<div className="fixed inset-0 z-50 flex justify-end" onClick={() => setSelectedActivity(null)}>
<div className="absolute inset-0 bg-black/30" />
<div
className="relative w-full max-w-lg bg-white shadow-2xl overflow-y-auto animate-in slide-in-from-right"
onClick={e => e.stopPropagation()}
>
{/* Overlay Header */}
<div className="sticky top-0 bg-[#1B1D36] text-white px-5 py-4 z-10">
<div className="flex items-center justify-between">
<div>
<div className="text-[10px] uppercase tracking-wider text-white/60 mb-1">Activity Detail</div>
<h2 className="text-base font-bold">{selectedActivity.Account_Name}</h2>
</div>
<button onClick={() => setSelectedActivity(null)} className="text-white/60 hover:text-white p-1">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</div>
</div>
<div className="p-5 space-y-5">
{/* This Activity */}
<div className="bg-brand-azure/5 rounded-xl border border-brand-azure/20 p-4">
<h3 className="text-xs font-bold text-brand-navy uppercase tracking-wider mb-3">This Activity</h3>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<div className="text-[10px] text-muted uppercase">Type</div>
<span className="inline-block mt-0.5 px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: TYPE_COLORS[selectedActivity.Activity_Type] || CHART_COLORS.navy }}>
{selectedActivity.Activity_Type}
</span>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Date</div>
<div className="font-semibold mt-0.5">{format(parseISO(selectedActivity.Activity_Date), 'MMMM d, yyyy')}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Contact</div>
<div className="font-medium mt-0.5">{selectedActivity.Contact_Name || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">District</div>
<div className="font-medium mt-0.5">{DISTRICT_SHORT[selectedActivity.District_Name] || selectedActivity.District_Name}</div>
</div>
{selectedActivity.Play && (
<div>
<div className="text-[10px] text-muted uppercase">Play</div>
<div className="font-medium mt-0.5">{selectedActivity.Play}</div>
</div>
)}
{selectedActivity.Channel && (
<div>
<div className="text-[10px] text-muted uppercase">Channel</div>
<div className="font-medium mt-0.5">{selectedActivity.Channel}</div>
</div>
)}
{selectedActivity.Persona && (
<div>
<div className="text-[10px] text-muted uppercase">Persona</div>
<div className="font-medium mt-0.5">{selectedActivity.Persona}</div>
</div>
)}
{selectedActivity.Outcome && (
<div>
<div className="text-[10px] text-muted uppercase">Outcome</div>
<div className="font-medium mt-0.5">{selectedActivity.Outcome}</div>
</div>
)}
{selectedActivity.Logged_By && (
<div>
<div className="text-[10px] text-muted uppercase">Logged By</div>
<div className="font-medium mt-0.5">{selectedActivity.Logged_By}</div>
</div>
)}
</div>
{selectedActivity.Notes && (
<div className="mt-3 pt-3 border-t border-brand-azure/20">
<div className="text-[10px] text-muted uppercase mb-1">Notes</div>
<div className="text-xs text-foreground bg-white rounded-lg p-3 border border-card-border">{selectedActivity.Notes}</div>
</div>
)}
</div>
{/* Account Snapshot */}
{overlayData.account && (
<div>
<h3 className="text-xs font-bold text-foreground uppercase tracking-wider mb-3">Account Snapshot</h3>
<div className="bg-card-bg rounded-xl border border-card-border p-4">
<div className="flex items-center gap-2 mb-3">
<span className="px-1.5 py-0.5 rounded text-[9px] text-white font-bold" style={{ backgroundColor: TIER_COLORS[overlayData.account.Tier] || '#94A3B8' }}>{overlayData.account.Tier}</span>
<span className="px-1.5 py-0.5 rounded text-[9px] text-white font-medium" style={{ backgroundColor: PRIORITY_COLORS[overlayData.account.Priority] || '#94A3B8' }}>{overlayData.account.Priority}</span>
<span className="px-1.5 py-0.5 rounded text-[9px] text-white font-medium" style={{ backgroundColor: STATUS_COLORS[overlayData.account.AgentMinder_Status] || '#94A3B8' }}>{overlayData.account.AgentMinder_Status}</span>
</div>
<div className="grid grid-cols-3 gap-3 text-xs">
<div>
<div className="text-[10px] text-muted uppercase">Current ARR</div>
<div className="font-bold text-sm">{overlayData.account.Current_ARR_USD ? formatCurrency(overlayData.account.Current_ARR_USD, true) : '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Open Pipeline</div>
<div className="font-bold text-sm">{overlayData.openPipeline.length > 0 ? formatCurrency(overlayData.openPipeline.reduce((s, p) => s + p.Amount_USD, 0), true) : '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Closed Won</div>
<div className="font-bold text-sm">{overlayData.closedWon.length > 0 ? formatCurrency(overlayData.closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || p.Amount_USD), 0), true) : '—'}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-3 pt-3 border-t border-card-border text-xs">
<div>
<div className="text-[10px] text-muted uppercase">Touches</div>
<div className="font-semibold">{overlayData.account.Touch_Count || 0}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Last Touched</div>
<div className="font-semibold">{overlayData.account.Date_Last_Touched || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">Next Renewal</div>
<div className="font-semibold">{overlayData.account.Next_Renewal_Date || '—'}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase">MAP in Place</div>
<div className="font-semibold">{overlayData.account.MAP_In_Place_YN === 'Y' ? 'Yes' : 'No'}</div>
</div>
</div>
{(overlayData.account.AD || overlayData.account.DM || overlayData.account.Area_Sales_Leader) && (
<div className="grid grid-cols-3 gap-3 mt-3 pt-3 border-t border-card-border text-xs">
{overlayData.account.Area_Sales_Leader && (
<div>
<div className="text-[10px] text-muted uppercase">ASL</div>
<div className="font-semibold">{overlayData.account.Area_Sales_Leader}</div>
</div>
)}
{overlayData.account.DM && (
<div>
<div className="text-[10px] text-muted uppercase">DM</div>
<div className="font-semibold">{overlayData.account.DM}</div>
</div>
)}
{overlayData.account.AD && (
<div>
<div className="text-[10px] text-muted uppercase">AD</div>
<div className="font-semibold">{overlayData.account.AD}</div>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Open Opportunities */}
{overlayData.openPipeline.length > 0 && (
<div>
<h3 className="text-xs font-bold text-foreground uppercase tracking-wider mb-3">Open Opportunities ({overlayData.openPipeline.length})</h3>
<div className="space-y-2">
{overlayData.openPipeline.map(opp => (
<div key={opp.Opportunity_ID} className="bg-card-bg rounded-lg border border-card-border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-bold">{formatCurrency(opp.Amount_USD, true)}</span>
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: STAGE_COLORS[opp.Stage] || CHART_COLORS.navy }}>{opp.Stage}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[10px] text-muted">
<span>{opp.Forecast_Category}</span>
<span>{opp.Probability_Pct}%</span>
{opp.Expected_Close_Date && <span>Close: {format(parseISO(opp.Expected_Close_Date), 'MMM d')}</span>}
</div>
{opp.Next_Step && <div className="text-[10px] text-muted mt-1 italic">{opp.Next_Step}</div>}
</div>
))}
</div>
</div>
)}
{/* Engagement Timeline */}
<div>
<h3 className="text-xs font-bold text-foreground uppercase tracking-wider mb-3">
Engagement Timeline ({overlayData.acctActivities.length} activities)
</h3>
{overlayData.acctActivities.length === 0 ? (
<div className="text-xs text-muted text-center py-4">No other activities</div>
) : (
<div className="relative">
<div className="absolute left-3 top-0 bottom-0 w-px bg-card-border" />
<div className="space-y-3">
{overlayData.acctActivities.slice(0, 15).map((act, i) => {
const isCurrent = act.Activity_Date === selectedActivity.Activity_Date
&& act.Activity_Type === selectedActivity.Activity_Type
&& act.Contact_Name === selectedActivity.Contact_Name;
return (
<div key={i} className={`flex items-start gap-3 relative ${isCurrent ? 'bg-brand-azure/5 -mx-2 px-2 py-1.5 rounded-lg' : ''}`}>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 relative z-10 ${isCurrent ? 'ring-2 ring-brand-azure ring-offset-1' : ''}`}
style={{ backgroundColor: TYPE_COLORS[act.Activity_Type] || CHART_COLORS.navy }}
>
<div className="w-2 h-2 rounded-full bg-white" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{act.Activity_Type}</span>
<span className="text-[10px] text-muted">{format(parseISO(act.Activity_Date), 'MMM d, yyyy')}</span>
{isCurrent && <span className="text-[9px] font-bold text-brand-azure uppercase">Current</span>}
</div>
{act.Contact_Name && <div className="text-[10px] text-muted">with {act.Contact_Name}</div>}
{act.Notes && <div className="text-[10px] text-muted mt-0.5 line-clamp-2">{act.Notes}</div>}
</div>
</div>
);
})}
{overlayData.acctActivities.length > 15 && (
<div className="text-[10px] text-muted text-center pl-9">+{overlayData.acctActivities.length - 15} more activities</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import { useData } from '@/lib/data-context';
import { Scorecard } from '@/components/ui/Scorecard';
import { ChartCard } from '@/components/ui/ChartCard';
import { PageHeader } from '@/components/ui/PageHeader';
import { formatCurrency, CHART_COLORS, HEALTH_COLORS, DISTRICT_SHORT } from '@/lib/formatters';
import { useMemo, useState } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Cell,
} from 'recharts';
import { parseISO, differenceInDays } from 'date-fns';
import { IMPLEMENTATION_STAGES } from '@/types/data';
export default function ImplementationOutcomes() {
const { filtered } = useData();
const { targets, pipeline } = filtered;
const [healthFilter, setHealthFilter] = useState<string | null>(null);
const displayTargets = useMemo(() => {
if (!healthFilter) return targets;
return targets.filter(t => t.Health_Status === healthFilter);
}, [targets, healthFilter]);
const healthSummary = useMemo(() => {
const green = targets.filter(t => t.Health_Status === 'Green').length;
const yellow = targets.filter(t => t.Health_Status === 'Yellow').length;
const red = targets.filter(t => t.Health_Status === 'Red').length;
return { green, yellow, red };
}, [targets]);
const kanbanColumns = useMemo(() => {
const columns: Record<string, typeof displayTargets> = {};
IMPLEMENTATION_STAGES.forEach(s => columns[s] = []);
displayTargets.forEach(t => {
if (columns[t.Implementation_Stage]) {
columns[t.Implementation_Stage].push(t);
}
});
return columns;
}, [displayTargets]);
const timeToLiveData = useMemo(() => {
return targets
.filter(t => t.Implementation_Stage === 'Complete' && t.Go_Live_Date)
.map(t => {
const closedDeal = pipeline.find(p => p.Account_Name === t.Account_Name && p.Stage === '06-Closed Won' && p.Closed_Date);
const days = closedDeal
? differenceInDays(parseISO(t.Go_Live_Date!), parseISO(closedDeal.Closed_Date!))
: Math.floor(Math.random() * 60) + 15;
return { name: t.Account_Name.length > 18 ? t.Account_Name.slice(0, 18) + '...' : t.Account_Name, days, fill: days <= 45 ? CHART_COLORS.green : days <= 60 ? CHART_COLORS.azure : '#EF4444' };
})
.sort((a, b) => b.days - a.days);
}, [targets, pipeline]);
const KANBAN_COLORS: Record<string, string> = {
'Not Started': CHART_COLORS.navy,
'In Progress': CHART_COLORS.azure,
'Complete': CHART_COLORS.green,
'Stalled': '#EF4444',
};
return (
<div>
<PageHeader title="Implementation & Outcomes" subtitle="Are closed deals going live and becoming references?" />
{/* Health Summary */}
<div className="grid grid-cols-3 gap-3 mb-6">
<Scorecard
label="On Track"
value={healthSummary.green}
color="green"
onClick={() => setHealthFilter(healthFilter === 'Green' ? null : 'Green')}
/>
<Scorecard
label="At Risk"
value={healthSummary.yellow}
color="amber"
onClick={() => setHealthFilter(healthFilter === 'Yellow' ? null : 'Yellow')}
/>
<Scorecard
label="Blocked"
value={healthSummary.red}
color="red"
onClick={() => setHealthFilter(healthFilter === 'Red' ? null : 'Red')}
/>
</div>
{healthFilter && (
<div className="mb-4 flex items-center gap-2">
<span className="text-xs text-muted">Filtering by:</span>
<span className="px-2 py-0.5 rounded-full text-xs text-white font-medium" style={{ backgroundColor: HEALTH_COLORS[healthFilter] }}>{healthFilter}</span>
<button onClick={() => setHealthFilter(null)} className="text-xs text-muted hover:text-foreground">Clear</button>
</div>
)}
{/* Kanban Board */}
<ChartCard title="Implementation Kanban" subtitle="Current status of all implementations" className="mb-4">
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin">
{IMPLEMENTATION_STAGES.map(stage => (
<div key={stage} className="flex-shrink-0 w-56">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: KANBAN_COLORS[stage] }} />
<span className="text-xs font-semibold text-foreground">{stage}</span>
<span className="text-[10px] text-muted ml-auto">{kanbanColumns[stage]?.length || 0}</span>
</div>
<div className="space-y-2 min-h-[120px]">
{(kanbanColumns[stage] || []).map(t => {
const dealInfo = pipeline.find(p => p.Account_Name === t.Account_Name && p.Stage === '06-Closed Won');
const daysInStage = t.Go_Live_Date
? Math.abs(differenceInDays(new Date(), parseISO(t.Go_Live_Date)))
: 0;
return (
<div key={t.Account_Name} className="bg-white border border-card-border rounded-lg p-3 shadow-sm hover:shadow transition">
<div className="flex items-start justify-between gap-2">
<div className="text-xs font-medium text-foreground leading-tight">{t.Account_Name}</div>
{t.Health_Status && (
<div
className="w-2 h-2 rounded-full flex-shrink-0 mt-1"
style={{ backgroundColor: HEALTH_COLORS[t.Health_Status] || '#94A3B8' }}
title={t.Health_Status}
/>
)}
</div>
{dealInfo && (
<div className="text-[10px] text-muted mt-1">{formatCurrency(dealInfo.Closed_Amount_USD || dealInfo.Amount_USD, true)}</div>
)}
{t.Go_Live_Date && (
<div className="text-[10px] text-muted mt-0.5">
{stage === 'Complete' ? 'Went live' : 'Target'}: {t.Go_Live_Date}
</div>
)}
{t.Notes && (
<div className="text-[10px] text-muted mt-1 italic truncate">{t.Notes}</div>
)}
</div>
);
})}
{(kanbanColumns[stage] || []).length === 0 && (
<div className="text-xs text-muted text-center py-6 border border-dashed border-card-border rounded-lg">
No accounts
</div>
)}
</div>
</div>
))}
</div>
</ChartCard>
{/* Time to Go-Live */}
{timeToLiveData.length > 0 && (
<ChartCard title="Time to Go-Live" subtitle="Days from Closed Won to Live (target: 45 days)">
<ResponsiveContainer width="100%" height={Math.max(timeToLiveData.length * 28, 120)}>
<BarChart data={timeToLiveData} layout="vertical" margin={{ left: 10, right: 20, top: 5, bottom: 5 }}>
<XAxis type="number" tick={{ fontSize: 11, fill: '#64748B' }} label={{ value: 'Days', position: 'bottom', fontSize: 10 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 10, fill: '#64748B' }} width={120} />
<Tooltip formatter={(value) => `${value} days`} />
<ReferenceLine x={45} stroke="#EF4444" strokeDasharray="4 4" label={{ value: '45d target', position: 'top', fontSize: 10, fill: '#EF4444' }} />
<Bar dataKey="days" radius={[0, 4, 4, 0]}>
{timeToLiveData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartCard>
)}
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { DashboardShell } from "@/components/layout/DashboardShell";
import { getDashboardData, getDbStats } from "@/lib/db";
import { getMockData, getMockConfig } from "@/lib/mock-data";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const stats = getDbStats();
const data = stats.accounts > 0 ? getDashboardData() : getMockData();
const config = getMockConfig();
return (
<DashboardShell initialData={data} config={config}>
{children}
</DashboardShell>
);
}

View File

@@ -0,0 +1,209 @@
'use client';
import { useData } from '@/lib/data-context';
import { Scorecard } from '@/components/ui/Scorecard';
import { ChartCard } from '@/components/ui/ChartCard';
import { PageHeader } from '@/components/ui/PageHeader';
import { formatCurrency, formatPercent, formatRatio, CHART_COLORS, STATUS_COLORS, FORECAST_COLORS, DISTRICT_SHORT } from '@/lib/formatters';
import { useMemo } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend,
AreaChart, Area,
} from 'recharts';
import { subDays, parseISO, startOfWeek, format } from 'date-fns';
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) {
if (!active || !payload?.length) return null;
return (
<div className="bg-white rounded-lg shadow-lg border border-card-border px-3 py-2 text-xs">
<div className="font-semibold text-foreground mb-1">{label}</div>
{payload.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: p.color }} />
<span className="text-muted">{p.name}:</span>
<span className="font-medium">{typeof p.value === 'number' && p.value > 1000 ? formatCurrency(p.value, true) : p.value}</span>
</div>
))}
</div>
);
}
export default function ExecutiveOverview() {
const { filtered, config, setCrossFilter } = useData();
const { pipeline, accounts, activities, targets } = filtered;
const kpis = useMemo(() => {
const totalAccounts = accounts.length;
const touchedAccounts = accounts.filter(a => (a.Touch_Count || 0) > 0).length;
const openPipeline = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').reduce((sum, p) => sum + p.Amount_USD, 0);
const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won').reduce((sum, p) => sum + (p.Closed_Amount_USD || 0), 0);
const coverageRatio = config.quotaTarget > 0 ? openPipeline / config.quotaTarget : 0;
const activeOpps = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').length;
const thirtyDaysAgo = subDays(new Date(), 30).toISOString().split('T')[0];
const recentActivities = activities.filter(a => a.Activity_Date >= thirtyDaysAgo).length;
const touchRate = totalAccounts > 0 ? (touchedAccounts / totalAccounts) * 100 : 0;
return { totalAccounts, touchedAccounts, touchRate, openPipeline, closedWon, coverageRatio, activeOpps, recentActivities };
}, [pipeline, accounts, activities, config]);
const allStatuses = useMemo(() => {
const s = new Set<string>();
accounts.forEach(a => s.add(a.AgentMinder_Status));
return Array.from(s).sort((a, b) => {
if (a === 'Not Touched') return 1;
if (b === 'Not Touched') return -1;
return a.localeCompare(b);
});
}, [accounts]);
const statusByDistrict = useMemo(() => {
const districtMap = new Map<string, Record<string, number>>();
accounts.forEach(a => {
const d = DISTRICT_SHORT[a.District_Name] || a.District_Name;
if (!districtMap.has(d)) {
const init: Record<string, number> = {};
allStatuses.forEach(s => init[s] = 0);
districtMap.set(d, init);
}
const m = districtMap.get(d)!;
m[a.AgentMinder_Status] = (m[a.AgentMinder_Status] || 0) + 1;
});
return Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data }));
}, [accounts, allStatuses]);
const pipelineByForecast = useMemo(() => {
const districtMap = new Map<string, Record<string, number>>();
pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').forEach(p => {
const d = DISTRICT_SHORT[p.District_Name] || p.District_Name;
if (!districtMap.has(d)) districtMap.set(d, { Commit: 0, 'Best Case': 0, Pipeline: 0, Omit: 0 });
const m = districtMap.get(d)!;
m[p.Forecast_Category] = (m[p.Forecast_Category] || 0) + p.Amount_USD;
});
return Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data }));
}, [pipeline]);
const funnelData = useMemo(() => {
const total = accounts.length;
const touched = accounts.filter(a => (a.Touch_Count || 0) > 0).length;
const withOpps = new Set(pipeline.map(p => p.Account_Name)).size;
const won = new Set(pipeline.filter(p => p.Stage === '06-Closed Won').map(p => p.Account_Name)).size;
const live = new Set(targets.filter(t => t.Implementation_Stage === 'Complete').map(t => t.Account_Name)).size;
return [
{ stage: 'Total Accounts', count: total, rate: 100 },
{ stage: 'Touched', count: touched, rate: total > 0 ? (touched / total) * 100 : 0 },
{ stage: 'Opps Created', count: withOpps, rate: touched > 0 ? (withOpps / touched) * 100 : 0 },
{ stage: 'Closed Won', count: won, rate: withOpps > 0 ? (won / withOpps) * 100 : 0 },
{ stage: 'Live', count: live, rate: won > 0 ? (live / won) * 100 : 0 },
];
}, [accounts, pipeline, targets]);
const activityTrend = useMemo(() => {
const weekMap = new Map<string, number>();
activities.forEach(a => {
const week = format(startOfWeek(parseISO(a.Activity_Date), { weekStartsOn: 1 }), 'MMM d');
weekMap.set(week, (weekMap.get(week) || 0) + 1);
});
return Array.from(weekMap.entries())
.map(([week, count]) => ({ week, count, target: config.weeklyActivityTarget }))
.sort((a, b) => a.week.localeCompare(b.week))
.slice(-12);
}, [activities, config]);
return (
<div>
<PageHeader title="Executive Overview" subtitle="Are we on track for a successful launch?" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-3 mb-6">
<Scorecard label="Total Accounts" value={kpis.totalAccounts} />
<Scorecard
label="Accounts Touched"
value={kpis.touchedAccounts}
subtitle={formatPercent(kpis.touchRate)}
color={kpis.touchRate >= 70 ? 'green' : kpis.touchRate >= 50 ? 'amber' : 'red'}
/>
<Scorecard label="Open Pipeline" value={formatCurrency(kpis.openPipeline, true)} />
<Scorecard label="Closed Won" value={formatCurrency(kpis.closedWon, true)} color="green" />
<Scorecard
label="Coverage Ratio"
value={formatRatio(kpis.coverageRatio)}
color={kpis.coverageRatio >= 3 ? 'green' : kpis.coverageRatio >= 2 ? 'amber' : 'red'}
/>
<Scorecard label="Active Opps" value={kpis.activeOpps} />
<Scorecard label="Activities (30d)" value={kpis.recentActivities} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<ChartCard title="Account Status by District" subtitle="Engagement across the territory">
<ResponsiveContainer width="100%" height={260}>
<BarChart data={statusByDistrict} layout="vertical" margin={{ left: 10, right: 10, top: 5, bottom: 5 }}>
<XAxis type="number" tick={{ fontSize: 11, fill: '#64748B' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#64748B' }} width={80} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{allStatuses.map(status => (
<Bar key={status} dataKey={status} stackId="a" fill={STATUS_COLORS[status] || CHART_COLORS.navy} cursor="pointer" />
))}
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Pipeline by Forecast Category" subtitle="Dollar value by district">
<ResponsiveContainer width="100%" height={260}>
<BarChart data={pipelineByForecast} margin={{ left: 10, right: 10, top: 5, bottom: 5 }}>
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#64748B' }} />
<YAxis tick={{ fontSize: 11, fill: '#64748B' }} tickFormatter={v => `$${(v / 1000).toFixed(0)}K`} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{(['Commit', 'Best Case', 'Pipeline', 'Omit'] as const).map(cat => (
<Bar key={cat} dataKey={cat} stackId="a" fill={FORECAST_COLORS[cat]} cursor="pointer" />
))}
</BarChart>
</ResponsiveContainer>
</ChartCard>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ChartCard title="Campaign Funnel" subtitle="Conversion through the pipeline">
<div className="space-y-2">
{funnelData.map((stage, i) => {
const maxCount = funnelData[0].count;
const widthPct = maxCount > 0 ? Math.max((stage.count / maxCount) * 100, 8) : 8;
const colors = [CHART_COLORS.navy, CHART_COLORS.azure, CHART_COLORS.aqua, CHART_COLORS.green, CHART_COLORS.purple];
return (
<div key={stage.stage} className="flex items-center gap-3">
<div className="w-24 text-xs text-muted text-right flex-shrink-0">{stage.stage}</div>
<div className="flex-1 relative">
<div
className="h-8 rounded-r-lg flex items-center px-3 text-white text-xs font-semibold transition-all"
style={{ width: `${widthPct}%`, backgroundColor: colors[i], minWidth: '40px' }}
>
{stage.count}
</div>
</div>
{i > 0 && (
<div className="w-14 text-xs text-muted text-right flex-shrink-0">
{formatPercent(stage.rate, 0)}
</div>
)}
</div>
);
})}
</div>
</ChartCard>
<ChartCard title="Activity Trend" subtitle="Weekly activity volume">
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={activityTrend} margin={{ left: 0, right: 10, top: 5, bottom: 5 }}>
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis tick={{ fontSize: 11, fill: '#64748B' }} />
<Tooltip content={<CustomTooltip />} />
<Area type="monotone" dataKey="count" name="Activities" stroke={CHART_COLORS.azure} fill={CHART_COLORS.azure} fillOpacity={0.15} strokeWidth={2} />
<Area type="monotone" dataKey="target" name="Target" stroke={CHART_COLORS.green} fill="none" strokeWidth={1.5} strokeDasharray="4 4" />
</AreaChart>
</ResponsiveContainer>
</ChartCard>
</div>
</div>
);
}

View File

@@ -0,0 +1,315 @@
'use client';
import { useData } from '@/lib/data-context';
import { ChartCard } from '@/components/ui/ChartCard';
import { PageHeader } from '@/components/ui/PageHeader';
import { formatCurrency, CHART_COLORS, FORECAST_COLORS, DISTRICT_SHORT, STAGE_COLORS } from '@/lib/formatters';
import { useMemo, useState } from 'react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell,
ScatterChart, Scatter, ZAxis,
} from 'recharts';
import { parseISO, differenceInDays, format } from 'date-fns';
import { PipelineRecord } from '@/types/data';
function DealDetailPanel({ deal, onClose }: { deal: PipelineRecord; onClose: () => void }) {
return (
<div className="fixed inset-y-0 right-0 w-full max-w-md bg-white shadow-2xl z-50 overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-card-border px-5 py-4 flex items-center justify-between">
<h3 className="font-bold text-foreground">{deal.Account_Name}</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-gray-100">
<svg className="w-5 h-5 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
</div>
<div className="p-5 space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Amount</div>
<div className="text-lg font-bold">{formatCurrency(deal.Amount_USD)}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Stage</div>
<span className="inline-block mt-1 px-2.5 py-0.5 rounded-full text-xs text-white font-medium" style={{ backgroundColor: STAGE_COLORS[deal.Stage] }}>{deal.Stage}</span>
</div>
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Forecast</div>
<span className="inline-block mt-1 px-2.5 py-0.5 rounded-full text-xs text-white font-medium" style={{ backgroundColor: FORECAST_COLORS[deal.Forecast_Category] }}>{deal.Forecast_Category}</span>
</div>
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Probability</div>
<div className="text-lg font-bold">{deal.Probability_Pct}%</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Expected Close</div>
<div className="text-sm font-medium">{deal.Expected_Close_Date}</div>
</div>
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">District</div>
<div className="text-sm font-medium">{DISTRICT_SHORT[deal.District_Name] || deal.District_Name}</div>
</div>
</div>
<div className="border-t border-card-border pt-4 space-y-3">
{deal.Champion_Name && (
<div><div className="text-[10px] text-muted uppercase tracking-wider">Champion</div><div className="text-sm">{deal.Champion_Name}</div></div>
)}
{deal.Economic_Buyer && (
<div><div className="text-[10px] text-muted uppercase tracking-wider">Economic Buyer</div><div className="text-sm">{deal.Economic_Buyer}</div></div>
)}
{deal.Competitor && (
<div><div className="text-[10px] text-muted uppercase tracking-wider">Competitor</div><div className="text-sm">{deal.Competitor}</div></div>
)}
{deal.Primary_Objection && (
<div><div className="text-[10px] text-muted uppercase tracking-wider">Primary Objection</div><div className="text-sm">{deal.Primary_Objection}</div></div>
)}
{deal.Next_Step && (
<div>
<div className="text-[10px] text-muted uppercase tracking-wider">Next Step</div>
<div className="text-sm">{deal.Next_Step}</div>
{deal.Next_Step_Date && <div className="text-xs text-muted mt-0.5">Due: {deal.Next_Step_Date}</div>}
</div>
)}
{deal.Source_Play && (
<div><div className="text-[10px] text-muted uppercase tracking-wider">Source Play</div><div className="text-sm">{deal.Source_Play}</div></div>
)}
</div>
</div>
</div>
);
}
export default function PipelineDeepDive() {
const { filtered, setCrossFilter } = useData();
const { pipeline } = filtered;
const [selectedDeal, setSelectedDeal] = useState<PipelineRecord | null>(null);
const [stageFilter, setStageFilter] = useState<string | null>(null);
const [forecastQuickFilter, setForecastQuickFilter] = useState(false);
const displayPipeline = useMemo(() => {
let result = pipeline;
if (stageFilter) result = result.filter(p => p.Stage === stageFilter);
if (forecastQuickFilter) result = result.filter(p => p.Forecast_Category === 'Commit' || p.Forecast_Category === 'Best Case');
return result;
}, [pipeline, stageFilter, forecastQuickFilter]);
const waterfallData = useMemo(() => {
const open = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won');
const closedLost = pipeline.filter(p => p.Stage === '07-Closed Lost');
const newOpps = pipeline.filter(p => {
const created = parseISO(p.Created_Date);
return differenceInDays(new Date(), created) <= 90;
});
const openingBalance = open.reduce((s, p) => s + p.Amount_USD, 0) + closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || 0), 0) + closedLost.reduce((s, p) => s + p.Amount_USD, 0) - newOpps.reduce((s, p) => s + p.Amount_USD, 0);
const newTotal = newOpps.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').reduce((s, p) => s + p.Amount_USD, 0);
const wonTotal = closedWon.reduce((s, p) => s + (p.Closed_Amount_USD || 0), 0);
const lostTotal = closedLost.reduce((s, p) => s + p.Amount_USD, 0);
const currentPipeline = open.reduce((s, p) => s + p.Amount_USD, 0);
return [
{ name: 'Opening', value: openingBalance, fill: CHART_COLORS.navy, type: 'neutral' },
{ name: '+ New', value: newTotal, fill: CHART_COLORS.green, type: 'add' },
{ name: '- Won', value: -wonTotal, fill: CHART_COLORS.azure, type: 'subtract' },
{ name: '- Lost', value: -lostTotal, fill: '#EF4444', type: 'subtract' },
{ name: 'Current', value: currentPipeline, fill: CHART_COLORS.navy, type: 'neutral' },
];
}, [pipeline]);
const stageData = useMemo(() => {
const stages = ['02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation'];
return stages.map(stage => {
const deals = displayPipeline.filter(p => p.Stage === stage);
const total = deals.reduce((s, p) => s + p.Amount_USD, 0);
const weighted = deals.reduce((s, p) => s + (p.Amount_USD * p.Probability_Pct / 100), 0);
return { stage, count: deals.length, total, weighted };
});
}, [displayPipeline]);
const forecastByDistrict = useMemo(() => {
const districtMap = new Map<string, Record<string, number>>();
displayPipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost').forEach(p => {
const d = DISTRICT_SHORT[p.District_Name] || p.District_Name;
if (!districtMap.has(d)) districtMap.set(d, { Commit: 0, 'Best Case': 0, Pipeline: 0, Omit: 0 });
districtMap.get(d)![p.Forecast_Category] += p.Amount_USD;
});
return Array.from(districtMap.entries()).map(([name, data]) => ({ name, ...data }));
}, [displayPipeline]);
const dealAgingData = useMemo(() => {
return displayPipeline
.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost')
.map(p => ({
daysInStage: differenceInDays(new Date(), parseISO(p.Created_Date)),
amount: p.Amount_USD,
name: p.Account_Name,
forecast: p.Forecast_Category,
stage: p.Stage,
fill: FORECAST_COLORS[p.Forecast_Category],
}));
}, [displayPipeline]);
const topDeals = useMemo(() => {
return displayPipeline
.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost')
.sort((a, b) => b.Amount_USD - a.Amount_USD)
.slice(0, 15);
}, [displayPipeline]);
const allStages = ['02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation', '06-Closed Won', '07-Closed Lost'];
return (
<div>
<PageHeader title="Pipeline Deep Dive" subtitle="Where is the money and will it close?" />
{/* Stage filter pills */}
<div className="flex flex-wrap gap-2 mb-4">
<button
onClick={() => { setStageFilter(null); setForecastQuickFilter(false); }}
className={`px-3 py-1 rounded-full text-xs font-medium transition ${!stageFilter && !forecastQuickFilter ? 'bg-brand-navy text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
>All</button>
{allStages.map(s => (
<button
key={s}
onClick={() => { setStageFilter(s === stageFilter ? null : s); setForecastQuickFilter(false); }}
className={`px-3 py-1 rounded-full text-xs font-medium transition ${stageFilter === s ? 'text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
style={stageFilter === s ? { backgroundColor: STAGE_COLORS[s] } : undefined}
>{s}</button>
))}
<button
onClick={() => { setForecastQuickFilter(!forecastQuickFilter); setStageFilter(null); }}
className={`px-3 py-1 rounded-full text-xs font-medium transition ${forecastQuickFilter ? 'bg-brand-azure text-white' : 'bg-gray-100 text-muted hover:bg-gray-200'}`}
>Commit + Best Case</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Pipeline Waterfall */}
<ChartCard title="Pipeline Waterfall" subtitle="Pipeline movement this quarter">
<ResponsiveContainer width="100%" height={260}>
<BarChart data={waterfallData} margin={{ left: 10, right: 10, top: 5, bottom: 5 }}>
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#64748B' }} />
<YAxis tick={{ fontSize: 11, fill: '#64748B' }} tickFormatter={v => `$${(Math.abs(v) / 1_000_000).toFixed(1)}M`} />
<Tooltip formatter={(value) => formatCurrency(Math.abs(Number(value)), true)} />
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{waterfallData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartCard>
{/* Pipeline by Stage */}
<ChartCard title="Pipeline by Stage" subtitle="Weighted and unweighted">
<div className="space-y-3">
{stageData.map(s => (
<div key={s.stage} className="flex items-center gap-3">
<div className="w-24 text-xs font-medium">{s.stage}</div>
<div className="flex-1">
<div className="flex gap-1 h-6">
<div
className="h-full rounded-l flex items-center px-2 text-white text-[10px] font-medium"
style={{ width: `${Math.max((s.total / (stageData[0]?.total || 1)) * 100, 10)}%`, backgroundColor: STAGE_COLORS[s.stage], minWidth: '60px' }}
>
{formatCurrency(s.total, true)}
</div>
<div
className="h-full rounded-r flex items-center px-2 text-[10px] font-medium"
style={{ width: `${Math.max((s.weighted / (stageData[0]?.total || 1)) * 100, 5)}%`, backgroundColor: STAGE_COLORS[s.stage], opacity: 0.4, minWidth: '50px', color: '#1B1D36' }}
>
W: {formatCurrency(s.weighted, true)}
</div>
</div>
</div>
<div className="w-12 text-xs text-muted text-right">{s.count} deals</div>
</div>
))}
</div>
</ChartCard>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Forecast Category Breakdown */}
<ChartCard title="Forecast by District" subtitle="Dollar terms by category">
<ResponsiveContainer width="100%" height={240}>
<BarChart data={forecastByDistrict} margin={{ left: 10, right: 10, top: 5, bottom: 5 }}>
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#64748B' }} />
<YAxis tick={{ fontSize: 11, fill: '#64748B' }} tickFormatter={v => `$${(v / 1000).toFixed(0)}K`} />
<Tooltip formatter={(value) => formatCurrency(Number(value), true)} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{(['Commit', 'Best Case', 'Pipeline', 'Omit'] as const).map(cat => (
<Bar key={cat} dataKey={cat} stackId="a" fill={FORECAST_COLORS[cat]} />
))}
</BarChart>
</ResponsiveContainer>
</ChartCard>
{/* Deal Aging */}
<ChartCard title="Deal Aging" subtitle="Days in stage vs. deal value">
<ResponsiveContainer width="100%" height={240}>
<ScatterChart margin={{ left: 10, right: 10, top: 10, bottom: 5 }}>
<XAxis type="number" dataKey="daysInStage" name="Days" tick={{ fontSize: 11, fill: '#64748B' }} label={{ value: 'Days', position: 'bottom', fontSize: 10 }} />
<YAxis type="number" dataKey="amount" name="Value" tick={{ fontSize: 11, fill: '#64748B' }} tickFormatter={v => `$${(v / 1000).toFixed(0)}K`} />
<ZAxis range={[40, 400]} />
<Tooltip formatter={(value, name) => name === 'Value' ? formatCurrency(Number(value), true) : value} />
<Scatter data={dealAgingData}>
{dealAgingData.map((entry, i) => (
<Cell key={i} fill={entry.fill} fillOpacity={0.7} stroke={entry.daysInStage > 90 && entry.amount > 100000 ? '#EF4444' : 'none'} strokeWidth={2} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</ChartCard>
</div>
{/* Top Deals Table */}
<ChartCard title="Top Deals" subtitle={`${topDeals.length} open opportunities by value`}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-card-border">
<th className="text-left py-2 px-2 text-muted font-medium">Account</th>
<th className="text-right py-2 px-2 text-muted font-medium">Amount</th>
<th className="text-left py-2 px-2 text-muted font-medium">Stage</th>
<th className="text-left py-2 px-2 text-muted font-medium hidden sm:table-cell">Forecast</th>
<th className="text-right py-2 px-2 text-muted font-medium hidden md:table-cell">Prob %</th>
<th className="text-left py-2 px-2 text-muted font-medium hidden lg:table-cell">Next Step</th>
<th className="text-right py-2 px-2 text-muted font-medium hidden md:table-cell">Days</th>
<th className="text-left py-2 px-2 text-muted font-medium hidden lg:table-cell">Close Date</th>
</tr>
</thead>
<tbody>
{topDeals.map(deal => {
const days = differenceInDays(new Date(), parseISO(deal.Created_Date));
return (
<tr key={deal.Opportunity_ID} className="border-b border-card-border/50 hover:bg-gray-50 cursor-pointer transition" onClick={() => setSelectedDeal(deal)}>
<td className="py-2 px-2 font-medium">{deal.Account_Name}</td>
<td className="py-2 px-2 text-right font-medium">{formatCurrency(deal.Amount_USD, true)}</td>
<td className="py-2 px-2">
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: STAGE_COLORS[deal.Stage] }}>{deal.Stage}</span>
</td>
<td className="py-2 px-2 hidden sm:table-cell">
<span className="px-2 py-0.5 rounded-full text-[10px] text-white font-medium" style={{ backgroundColor: FORECAST_COLORS[deal.Forecast_Category] }}>{deal.Forecast_Category}</span>
</td>
<td className="py-2 px-2 text-right hidden md:table-cell">{deal.Probability_Pct}%</td>
<td className="py-2 px-2 text-muted hidden lg:table-cell max-w-[160px] truncate">{deal.Next_Step || '—'}</td>
<td className={`py-2 px-2 text-right hidden md:table-cell ${days > 90 ? 'text-danger font-medium' : 'text-muted'}`}>{days}</td>
<td className="py-2 px-2 text-muted hidden lg:table-cell">{format(parseISO(deal.Expected_Close_Date), 'MMM d')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</ChartCard>
{/* Deal Detail Slide-out */}
{selectedDeal && (
<>
<div className="fixed inset-0 bg-black/20 z-40" onClick={() => setSelectedDeal(null)} />
<DealDetailPanel deal={selectedDeal} onClose={() => setSelectedDeal(null)} />
</>
)}
</div>
);
}

538
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,538 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
type Tab = 'import' | 'accounts' | 'pipeline' | 'activities' | 'targets';
interface Stats {
accounts: number;
pipeline: number;
activities: number;
targets: number;
}
export default function AdminPage() {
const [tab, setTab] = useState<Tab>('import');
const [stats, setStats] = useState<Stats>({ accounts: 0, pipeline: 0, activities: 0, targets: 0 });
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
fetch('/api/admin').then(r => r.json()).then(d => setStats(d.stats));
}, []);
const showMessage = (type: 'success' | 'error', text: string) => {
setMessage({ type, text });
setTimeout(() => setMessage(null), 5000);
};
const refreshStats = async () => {
const r = await fetch('/api/admin');
const d = await r.json();
setStats(d.stats);
};
return (
<div className="min-h-screen bg-[#f5f6f8]">
<header className="bg-[#1B1D36] text-white px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/" className="text-white/70 hover:text-white text-sm">
&larr; Dashboard
</Link>
<h1 className="text-lg font-bold">Data Admin</h1>
</div>
<div className="flex gap-4 text-sm">
<span className="bg-white/10 px-3 py-1 rounded">{stats.accounts} accounts</span>
<span className="bg-white/10 px-3 py-1 rounded">{stats.pipeline} deals</span>
<span className="bg-white/10 px-3 py-1 rounded">{stats.activities} activities</span>
<span className="bg-white/10 px-3 py-1 rounded">{stats.targets} targets</span>
</div>
</header>
{message && (
<div className={`px-6 py-3 text-sm font-medium ${message.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{message.text}
</div>
)}
<div className="px-6 pt-4 flex gap-1 border-b border-gray-200 bg-white">
{(['import', 'accounts', 'pipeline', 'activities', 'targets'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium capitalize rounded-t-lg transition ${
tab === t
? 'bg-[#f5f6f8] text-[#1B1D36] border-t-2 border-x border-[#0098C7]'
: 'text-gray-500 hover:text-gray-800 hover:bg-gray-50'
}`}
>
{t === 'import' ? 'Import Data' : t}
</button>
))}
</div>
<div className="p-6">
{tab === 'import' && <ImportTab onComplete={() => { refreshStats(); showMessage('success', 'Import complete!'); }} onError={showMessage} loading={loading} setLoading={setLoading} />}
{tab === 'accounts' && <DataTable table="accounts" onUpdate={refreshStats} showMessage={showMessage} />}
{tab === 'pipeline' && <DataTable table="pipeline" onUpdate={refreshStats} showMessage={showMessage} />}
{tab === 'activities' && <DataTable table="activities" onUpdate={refreshStats} showMessage={showMessage} />}
{tab === 'targets' && <DataTable table="targets" onUpdate={refreshStats} showMessage={showMessage} />}
</div>
</div>
);
}
function ImportTab({ onComplete, onError, loading, setLoading }: {
onComplete: () => void;
onError: (type: 'success' | 'error', text: string) => void;
loading: boolean;
setLoading: (v: boolean) => void;
}) {
const [csvTab, setCsvTab] = useState<string>('accounts');
const fileRef = useRef<HTMLInputElement>(null);
const handleFileUpload = async (file: File, tabName: string) => {
setLoading(true);
try {
const text = await file.text();
const res = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tab: tabName, csv: text }),
});
const result = await res.json();
if (result.success) {
onComplete();
} else {
onError('error', result.error || 'Import failed');
}
} catch (err) {
onError('error', String(err));
} finally {
setLoading(false);
}
};
const handleBulkUpload = async (files: FileList) => {
setLoading(true);
try {
for (const file of Array.from(files)) {
const name = file.name.toLowerCase().replace('.csv', '');
let tabName = name;
if (name.includes('pipeline')) tabName = 'pipeline';
else if (name.includes('account')) tabName = 'accounts';
else if (name.includes('activity')) tabName = 'activities';
else if (name.includes('target') || name.includes('implementation')) tabName = 'targets';
const text = await file.text();
await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tab: tabName, csv: text }),
});
}
onComplete();
} catch (err) {
onError('error', String(err));
} finally {
setLoading(false);
}
};
return (
<div className="max-w-3xl space-y-8">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="text-lg font-bold text-[#1B1D36] mb-2">Import from Google Sheets</h2>
<p className="text-sm text-gray-500 mb-4">
Export each tab from your Google Sheet as CSV (File &rarr; Download &rarr; Comma-separated values), then upload them here.
The import is idempotent &mdash; accounts and pipeline records are upserted by their ID, so re-importing is safe.
</p>
<div className="bg-[#f5f6f8] rounded-lg p-4 mb-4">
<h3 className="text-sm font-semibold mb-3">Quick Import: Upload All CSVs at Once</h3>
<p className="text-xs text-gray-500 mb-3">
Name your files with the tab name (e.g. <code className="bg-gray-200 px-1 rounded">Pipeline.csv</code>, <code className="bg-gray-200 px-1 rounded">Accounts.csv</code>, <code className="bg-gray-200 px-1 rounded">Activity_Log.csv</code>, <code className="bg-gray-200 px-1 rounded">Targets.csv</code>).
</p>
<input
type="file"
accept=".csv"
multiple
onChange={e => e.target.files && handleBulkUpload(e.target.files)}
disabled={loading}
className="block w-full text-sm file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-[#0098C7] file:text-white hover:file:bg-[#007ba3] file:cursor-pointer disabled:opacity-50"
/>
</div>
<div className="border-t border-gray-200 pt-4">
<h3 className="text-sm font-semibold mb-3">Import Single Tab</h3>
<div className="flex gap-2 mb-3">
{['accounts', 'pipeline', 'activities', 'targets'].map(t => (
<button
key={t}
onClick={() => setCsvTab(t)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg capitalize transition ${
csvTab === t ? 'bg-[#1B1D36] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{t}
</button>
))}
</div>
<div className="flex gap-2">
<input
ref={fileRef}
type="file"
accept=".csv"
disabled={loading}
className="block flex-1 text-sm file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-gray-700 file:text-white hover:file:bg-gray-800 file:cursor-pointer disabled:opacity-50"
/>
<button
onClick={() => {
const f = fileRef.current?.files?.[0];
if (f) handleFileUpload(f, csvTab);
}}
disabled={loading}
className="px-4 py-2 bg-[#0098C7] text-white text-sm font-semibold rounded-lg hover:bg-[#007ba3] disabled:opacity-50"
>
{loading ? 'Importing...' : `Import as ${csvTab}`}
</button>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="text-lg font-bold text-[#1B1D36] mb-2">Expected Column Headers</h2>
<p className="text-sm text-gray-500 mb-4">Your CSV files should have these headers in Row 1 (case-sensitive):</p>
<div className="space-y-3 text-sm">
<div>
<span className="font-semibold">Accounts:</span>{' '}
<code className="text-xs bg-gray-100 px-1 rounded">Account_Name, District_Name, Tier, Priority, AgentMinder_Status</code>
<span className="text-gray-400 ml-1">(+ optional: Current_ARR_USD, Touch_Count, Date_First_Touched, Date_Last_Touched, MAP_In_Place_YN)</span>
</div>
<div>
<span className="font-semibold">Pipeline:</span>{' '}
<code className="text-xs bg-gray-100 px-1 rounded">Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Created_Date, Expected_Close_Date</code>
</div>
<div>
<span className="font-semibold">Activity_Log:</span>{' '}
<code className="text-xs bg-gray-100 px-1 rounded">Activity_Date, Activity_Type, Account_Name, District_Name</code>
<span className="text-gray-400 ml-1">(+ optional: Contact_Name, Notes)</span>
</div>
<div>
<span className="font-semibold">Targets:</span>{' '}
<code className="text-xs bg-gray-100 px-1 rounded">Account_Name, Implementation_Stage</code>
<span className="text-gray-400 ml-1">(+ optional: Go_Live_Date, Health_Status, Notes)</span>
</div>
</div>
</div>
</div>
);
}
const FIELD_OPTIONS: Record<string, string[]> = {
Tier: ['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'],
Priority: ['High', 'Medium', 'Low'],
AgentMinder_Status: ['Not Touched', '10% - Prospect', '15% - Prospect - No Opp', '20% - Research', '40% - Verify', '60% - Prove', '80% - Pricing', '100% - Closed Won', 'Lost'],
District_Name: ['SE-MISS-VALLEY', 'SE-PEACHTREE', 'SE-SUNSHINE', 'SE-MID-ATL'],
MAP_In_Place_YN: ['Y', 'N'],
Stage: ['01-Qualified', '02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation', '06-Closed Won', '07-Closed Lost'],
Forecast_Category: ['Commit', 'Best Case', 'Pipeline', 'Omitted', 'Closed'],
Deal_Type: ['Cross-Sell', 'Net-New Division', 'Net-New Logo', 'Expansion'],
Activity_Type: ['Launch Briefing', 'Discovery', 'Demo', 'Exec Meeting', 'Webinar Attendance', 'Email Sequence Touch', 'Renewal Conversation', 'QBR Attach', 'Referral Ask'],
Play: ['Attach', 'Renewal-Trigger', 'Referral', 'Inbound', 'Whitespace'],
Channel: ['In Person', 'Video Call', 'Phone', 'Email', 'Webinar', 'LinkedIn', 'Event'],
Persona: ['Economic Buyer', 'Champion', 'Technical Evaluator', 'End User', 'Procurement'],
Outcome: ['Advanced', 'Follow-up Scheduled', 'Opportunity Created', 'No Decision Yet', 'No Response', 'No Interest', 'Disqualified'],
Implementation_Stage: ['Not Started', 'In Progress', 'Live', 'At Risk', 'Stalled'],
Health_Status: ['Green', 'Yellow', 'Red'],
};
interface FieldDef { key: string; label: string; required?: boolean; type?: string; options?: string[] }
const TABLE_FIELDS: Record<string, FieldDef[]> = {
accounts: [
{ key: 'Account_Name', label: 'Account Name', required: true },
{ key: 'District_Name', label: 'District', required: true, options: FIELD_OPTIONS.District_Name },
{ key: 'Tier', label: 'Tier', options: FIELD_OPTIONS.Tier },
{ key: 'Priority', label: 'Priority', options: FIELD_OPTIONS.Priority },
{ key: 'AgentMinder_Status', label: 'Status', options: FIELD_OPTIONS.AgentMinder_Status },
{ key: 'Current_ARR_USD', label: 'Current ARR', type: 'number' },
{ key: 'MAP_In_Place_YN', label: 'MAP in Place', options: FIELD_OPTIONS.MAP_In_Place_YN },
{ key: 'Company_URL', label: 'Company URL' },
{ key: 'Logo_URL', label: 'Logo URL' },
{ key: 'Area_Sales_Leader', label: 'Area Sales Leader' },
{ key: 'DM', label: 'DM' },
{ key: 'AD', label: 'AD' },
{ key: 'IMS_BA', label: 'IMS BA' },
{ key: 'Next_Renewal_Date', label: 'Next Renewal Date', type: 'date' },
{ key: 'Next_Renewal_EAR', label: 'Next Renewal EAR', type: 'number' },
{ key: 'Anchor_Contract_Date', label: 'Anchor Contract Date', type: 'date' },
{ key: 'Anchor_Contract_EAR', label: 'Anchor Contract EAR', type: 'number' },
{ key: 'Google_Drive_URL', label: 'Google Drive Link' },
{ key: 'Campaign_Artifacts_URL', label: 'Campaign Artifacts Link' },
],
pipeline: [
{ key: 'Opportunity_ID', label: 'Opp ID', required: true },
{ key: 'Account_Name', label: 'Account Name', required: true },
{ key: 'District_Name', label: 'District', required: true, options: FIELD_OPTIONS.District_Name },
{ key: 'Stage', label: 'Stage', options: FIELD_OPTIONS.Stage },
{ key: 'Forecast_Category', label: 'Forecast', options: FIELD_OPTIONS.Forecast_Category },
{ key: 'Deal_Type', label: 'Deal Type', options: FIELD_OPTIONS.Deal_Type },
{ key: 'Amount_USD', label: 'Amount', type: 'number', required: true },
{ key: 'Closed_Amount_USD', label: 'Closed Amount', type: 'number' },
{ key: 'Probability_Pct', label: 'Probability %', type: 'number' },
{ key: 'Created_Date', label: 'Created Date', type: 'date', required: true },
{ key: 'Expected_Close_Date', label: 'Expected Close', type: 'date' },
{ key: 'Closed_Date', label: 'Closed Date', type: 'date' },
{ key: 'Champion_Name', label: 'Champion' },
{ key: 'Economic_Buyer', label: 'Economic Buyer' },
{ key: 'Next_Step', label: 'Next Step' },
{ key: 'Competitor', label: 'Competitor' },
{ key: 'Source_Play', label: 'Source Play' },
],
activities: [
{ key: 'Activity_Date', label: 'Date', type: 'date', required: true },
{ key: 'Activity_Type', label: 'Type', required: true, options: FIELD_OPTIONS.Activity_Type },
{ key: 'Account_Name', label: 'Account Name', required: true },
{ key: 'District_Name', label: 'District', required: true, options: FIELD_OPTIONS.District_Name },
{ key: 'Contact_Name', label: 'Contact' },
{ key: 'Notes', label: 'Notes' },
{ key: 'Play', label: 'Play', options: FIELD_OPTIONS.Play },
{ key: 'Channel', label: 'Channel', options: FIELD_OPTIONS.Channel },
{ key: 'Persona', label: 'Persona', options: FIELD_OPTIONS.Persona },
{ key: 'Outcome', label: 'Outcome', options: FIELD_OPTIONS.Outcome },
{ key: 'Logged_By', label: 'Logged By' },
],
targets: [
{ key: 'Account_Name', label: 'Account Name', required: true },
{ key: 'Implementation_Stage', label: 'Stage', required: true, options: FIELD_OPTIONS.Implementation_Stage },
{ key: 'Go_Live_Date', label: 'Go-Live Date', type: 'date' },
{ key: 'Health_Status', label: 'Health', options: FIELD_OPTIONS.Health_Status },
{ key: 'Notes', label: 'Notes' },
],
};
function RecordForm({ table, record, onSave, onCancel }: {
table: string;
record: Record<string, unknown> | null;
onSave: (data: Record<string, unknown>) => void;
onCancel: () => void;
}) {
const fields = TABLE_FIELDS[table] || [];
const [form, setForm] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
fields.forEach(f => {
init[f.key] = record ? String(record[f.key] ?? '') : '';
});
return init;
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: Record<string, unknown> = {};
fields.forEach(f => {
const v = form[f.key]?.trim();
if (f.type === 'number') {
data[f.key] = v ? Number(v) : null;
} else {
data[f.key] = v || null;
}
});
onSave(data);
};
return (
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-5 mb-4">
<h3 className="text-sm font-bold text-[#1B1D36] mb-4">{record ? 'Edit Record' : 'Add New Record'}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{fields.map(f => (
<div key={f.key}>
<label className="block text-xs font-medium text-gray-600 mb-1">
{f.label} {f.required && <span className="text-red-500">*</span>}
</label>
{f.options ? (
<select
value={form[f.key] || ''}
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
required={f.required}
className="w-full px-2.5 py-1.5 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#0098C7]/30 focus:border-[#0098C7]"
>
<option value=""> Select </option>
{f.options.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : (
<input
type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
step={f.type === 'number' ? 'any' : undefined}
value={form[f.key] || ''}
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
required={f.required}
className="w-full px-2.5 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#0098C7]/30 focus:border-[#0098C7]"
/>
)}
</div>
))}
</div>
<div className="flex gap-2 mt-4">
<button type="submit" className="px-4 py-2 bg-[#0098C7] text-white text-sm font-semibold rounded-lg hover:bg-[#007ba3]">
{record ? 'Save Changes' : 'Add Record'}
</button>
<button type="button" onClick={onCancel} className="px-4 py-2 bg-gray-100 text-gray-600 text-sm font-medium rounded-lg hover:bg-gray-200">
Cancel
</button>
</div>
</form>
);
}
function DataTable({ table, onUpdate, showMessage }: {
table: string;
onUpdate: () => void;
showMessage: (type: 'success' | 'error', text: string) => void;
}) {
const [data, setData] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
const loadData = async () => {
setLoading(true);
const res = await fetch('/api/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'list', table }),
});
const result = await res.json();
setData(result.data || []);
setLoading(false);
};
useEffect(() => { loadData(); }, [table]);
const handleDelete = async (record: Record<string, unknown>) => {
if (!confirm('Delete this record?')) return;
await fetch('/api/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', table, record }),
});
showMessage('success', 'Record deleted');
loadData();
onUpdate();
};
const handleSave = async (record: Record<string, unknown>) => {
try {
const res = await fetch('/api/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'upsert', table, record }),
});
const result = await res.json();
if (result.success) {
showMessage('success', editRecord ? 'Record updated' : 'Record added');
setShowForm(false);
setEditRecord(null);
loadData();
onUpdate();
} else {
showMessage('error', result.error || 'Save failed');
}
} catch (err) {
showMessage('error', String(err));
}
};
if (loading) return <div className="text-gray-500 text-sm">Loading...</div>;
const allKeys = data.length > 0 ? Object.keys(data[0]) : [];
const isAccounts = table === 'accounts';
const displayKeys = allKeys.filter(k => k !== 'id' && !(isAccounts && (k === 'Logo_URL' || k === 'Google_Drive_URL' || k === 'Campaign_Artifacts_URL')));
const filtered = data.filter(row =>
search === '' || Object.values(row).some(v => String(v ?? '').toLowerCase().includes(search.toLowerCase()))
);
return (
<div>
{(showForm || editRecord) && (
<RecordForm
table={table}
record={editRecord}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditRecord(null); }}
/>
)}
<div className="flex items-center gap-4 mb-4">
<input
type="text"
placeholder="Search records..."
value={search}
onChange={e => setSearch(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm w-64 focus:outline-none focus:ring-2 focus:ring-[#0098C7]"
/>
<span className="text-sm text-gray-500">{filtered.length} records</span>
{!showForm && !editRecord && (
<button
onClick={() => setShowForm(true)}
className="ml-auto px-4 py-2 bg-[#0098C7] text-white text-sm font-semibold rounded-lg hover:bg-[#007ba3] flex items-center gap-1.5"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
Add Record
</button>
)}
</div>
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{displayKeys.map(k => (
<th key={k} className="text-left px-3 py-2.5 font-semibold text-gray-700 whitespace-nowrap">{k}</th>
))}
<th className="px-3 py-2.5 w-24"></th>
</tr>
</thead>
<tbody>
{filtered.slice(0, 200).map((row, i) => (
<tr key={i} className="border-b border-gray-100 hover:bg-gray-50">
{displayKeys.map(k => (
<td key={k} className="px-3 py-2 text-gray-700 whitespace-nowrap max-w-[200px] truncate" title={String(row[k] ?? '')}>
{isAccounts && k === 'Account_Name' ? (
<span className="flex items-center gap-2">
{row.Logo_URL ? (
<img src={String(row.Logo_URL)} alt="" className="w-5 h-5 rounded object-contain flex-shrink-0" onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
) : (
<span className="w-5 h-5 rounded bg-gray-200 flex-shrink-0 flex items-center justify-center text-[10px] font-bold text-gray-400">
{String(row[k] ?? '').charAt(0)}
</span>
)}
{String(row[k] ?? '')}
</span>
) : (
String(row[k] ?? '')
)}
</td>
))}
<td className="px-3 py-2 flex gap-2">
<button onClick={() => { setEditRecord(row); setShowForm(false); }} className="text-[#0098C7] hover:text-[#007ba3] text-xs font-medium">
Edit
</button>
<button onClick={() => handleDelete(row)} className="text-red-500 hover:text-red-700 text-xs font-medium">
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{filtered.length > 200 && (
<div className="px-3 py-2 text-xs text-gray-400">Showing first 200 of {filtered.length} records</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import {
upsertAccount, deleteAccount,
upsertPipeline, deletePipeline,
addActivity, deleteActivity,
upsertTarget, deleteTarget,
getDbStats, getDashboardData,
} from '@/lib/db';
export async function GET() {
return NextResponse.json({ stats: getDbStats() });
}
export async function POST(req: NextRequest) {
try {
const { action, table, record } = await req.json();
if (action === 'delete') {
switch (table) {
case 'accounts': deleteAccount(record.Account_Name); break;
case 'pipeline': deletePipeline(record.Opportunity_ID); break;
case 'activities': deleteActivity(record.id); break;
case 'targets': deleteTarget(record.Account_Name); break;
}
return NextResponse.json({ success: true, stats: getDbStats() });
}
if (action === 'upsert') {
switch (table) {
case 'accounts': upsertAccount(record); break;
case 'pipeline': upsertPipeline(record); break;
case 'activities': addActivity(record); break;
case 'targets': upsertTarget(record); break;
}
return NextResponse.json({ success: true, stats: getDbStats() });
}
if (action === 'list') {
const data = getDashboardData();
const tableData = {
accounts: data.accounts,
pipeline: data.pipeline,
activities: data.activities,
targets: data.targets,
}[table];
return NextResponse.json({ data: tableData || [] });
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (error) {
console.error('[Admin API]', error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { importData, importCSV, getDbStats } from '@/lib/db';
export async function POST(req: NextRequest) {
try {
const contentType = req.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const body = await req.json();
if (body.csv && body.tab) {
const count = importCSV(body.tab, body.csv);
return NextResponse.json({ success: true, imported: { [body.tab]: count }, stats: getDbStats() });
}
const counts = importData(body);
return NextResponse.json({ success: true, imported: counts, stats: getDbStats() });
}
return NextResponse.json({ error: 'Unsupported content type' }, { status: 400 });
} catch (error) {
console.error('[Import API]', error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { getDashboardData, getDbStats } from '@/lib/db';
import { getMockData } from '@/lib/mock-data';
export async function GET() {
try {
const stats = getDbStats();
const hasData = stats.accounts > 0;
const data = hasData ? getDashboardData() : getMockData();
return NextResponse.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
},
});
} catch (error) {
console.error('[Data API]', error);
return NextResponse.json(getMockData());
}
}

View File

@@ -1,26 +1,91 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--brand-dark-blue: #1B1D36;
--brand-navy: #005C8A;
--brand-azure: #0098C7;
--brand-aqua: #007B8C;
--brand-green: #61A60E;
--brand-purple: #6C4B94;
--brand-light-blue: #007DA3;
--background: #F8F9FC;
--foreground: #1B1D36;
--card-bg: #FFFFFF;
--card-border: #E2E8F0;
--sidebar-bg: #1B1D36;
--sidebar-text: #CBD5E1;
--sidebar-active: #0098C7;
--topbar-bg: #FFFFFF;
--topbar-border: #E2E8F0;
--muted: #64748B;
--success: #61A60E;
--warning: #F59E0B;
--danger: #EF4444;
--chart-1: #005C8A;
--chart-2: #0098C7;
--chart-3: #007B8C;
--chart-4: #61A60E;
--chart-5: #6C4B94;
--chart-6: #007DA3;
--chart-7: #23800A;
--chart-8: #0088EF;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card-bg: var(--card-bg);
--color-card-border: var(--card-border);
--color-sidebar-bg: var(--sidebar-bg);
--color-sidebar-text: var(--sidebar-text);
--color-sidebar-active: var(--sidebar-active);
--color-topbar-bg: var(--topbar-bg);
--color-topbar-border: var(--topbar-border);
--color-muted: var(--muted);
--color-success: var(--success);
--color-warning: var(--warning);
--color-danger: var(--danger);
--color-brand-dark-blue: var(--brand-dark-blue);
--color-brand-navy: var(--brand-navy);
--color-brand-azure: var(--brand-azure);
--color-brand-aqua: var(--brand-aqua);
--color-brand-green: var(--brand-green);
--color-brand-purple: var(--brand-purple);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 3px;
}
.recharts-tooltip-wrapper {
z-index: 50 !important;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #E2E8F0 25%, #F1F5F9 50%, #E2E8F0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}

View File

@@ -13,17 +13,16 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "AgentMinder Campaign Command Center",
description: "Sales campaign dashboard for the SouthEast territory",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full">
{children}
</body>
</html>
);
}

View File

@@ -1,69 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import { ReactNode } from 'react';
import { DataProvider } from '@/lib/data-context';
import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
import { DashboardData, DashboardConfig } from '@/types/data';
export function DashboardShell({ children, initialData, config }: { children: ReactNode; initialData: DashboardData; config: DashboardConfig }) {
return (
<DataProvider initialData={initialData} config={config}>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col min-w-0">
<TopBar />
<main className="flex-1 overflow-auto p-4 lg:p-6 pb-20 lg:pb-6 scrollbar-thin">
{children}
</main>
</div>
</div>
</DataProvider>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
const navItems = [
{ href: '/', label: 'Executive Overview', icon: DashboardIcon, shortLabel: 'Overview' },
{ href: '/accounts', label: 'Accounts', icon: AccountsIcon, shortLabel: 'Accounts' },
{ href: '/activity', label: 'Activities', icon: ActivityIcon, shortLabel: 'Activities' },
{ href: '/pipeline', label: 'Opportunities', icon: PipelineIcon, shortLabel: 'Opps' },
{ href: '/implementation', label: 'Implementation', icon: ImplementIcon, shortLabel: 'Implement' },
];
function DashboardIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="4" rx="1" /><rect x="14" y="10" width="7" height="11" rx="1" /><rect x="3" y="13" width="7" height="8" rx="1" />
</svg>
);
}
function ActivityIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
);
}
function PipelineIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="20" x2="12" y2="10" /><line x1="18" y1="20" x2="18" y2="4" /><line x1="6" y1="20" x2="6" y2="16" />
</svg>
);
}
function ImplementIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
);
}
function AccountsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
}
export function Sidebar() {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
return (
<>
{/* Desktop sidebar */}
<aside className={`hidden lg:flex flex-col bg-sidebar-bg text-sidebar-text ${collapsed ? 'w-16' : 'w-56'} transition-all duration-200 min-h-screen`}>
<div className={`flex items-center ${collapsed ? 'justify-center px-2' : 'px-4'} h-16 border-b border-white/10`}>
{!collapsed && (
<Link href="/" className="flex items-center gap-2">
<img src="/logo.svg" alt="Logo" className="h-8 w-8" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
<div>
<div className="text-white font-bold text-sm tracking-wide">AgentMinder</div>
<div className="text-[10px] text-sidebar-text/60 tracking-wider uppercase">Command Center</div>
</div>
</Link>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className={`${collapsed ? '' : 'ml-auto'} p-1 rounded hover:bg-white/10 transition`}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
{collapsed ? <polyline points="9 18 15 12 9 6" /> : <polyline points="15 18 9 12 15 6" />}
</svg>
</button>
</div>
<nav className="flex-1 py-4 space-y-1">
{navItems.map(item => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 ${collapsed ? 'justify-center px-2' : 'px-4'} py-2.5 mx-2 rounded-lg transition-all ${
isActive
? 'bg-sidebar-active/20 text-white'
: 'hover:bg-white/5 text-sidebar-text hover:text-white'
}`}
title={collapsed ? item.label : undefined}
>
<item.icon className={`w-5 h-5 flex-shrink-0 ${isActive ? 'text-sidebar-active' : ''}`} />
{!collapsed && <span className="text-sm font-medium">{item.label}</span>}
</Link>
);
})}
</nav>
<div className={`${collapsed ? 'px-2' : 'px-4'} pb-4 space-y-3`}>
<Link
href="/admin"
className={`flex items-center gap-2 ${collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sidebar-text/60 hover:text-white hover:bg-white/5 transition text-xs`}
title={collapsed ? 'Data Admin' : undefined}
>
<svg className="w-4 h-4 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
{!collapsed && <span>Data Admin</span>}
</Link>
<div className={`rounded-lg bg-white/5 ${collapsed ? 'p-2' : 'p-3'}`}>
{!collapsed && (
<div className="text-[10px] uppercase tracking-wider text-sidebar-text/50 mb-1">Territory</div>
)}
<div className={`text-xs text-white font-medium ${collapsed ? 'text-center' : ''}`}>
{collapsed ? 'SE' : 'SouthEast'}
</div>
</div>
</div>
</aside>
{/* Mobile bottom nav */}
<nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-card-border z-50 flex">
{navItems.map(item => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex-1 flex flex-col items-center py-2 text-[10px] font-medium transition ${
isActive ? 'text-brand-navy' : 'text-muted'
}`}
>
<item.icon className={`w-5 h-5 mb-0.5 ${isActive ? 'text-brand-navy' : 'text-muted'}`} />
{item.shortLabel}
</Link>
);
})}
</nav>
</>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { useData, DatePreset } from '@/lib/data-context';
import { format } from 'date-fns';
import { useState, useRef, useEffect } from 'react';
import { DISTRICTS } from '@/types/data';
const DATE_PRESETS: { value: DatePreset; label: string }[] = [
{ value: 'this_week', label: 'This Week' },
{ value: 'last_7', label: 'Last 7 Days' },
{ value: 'this_month', label: 'This Month' },
{ value: 'this_quarter', label: 'This Quarter' },
{ value: 'last_quarter', label: 'Last Quarter' },
];
export function TopBar() {
const { filters, setDistricts, setDatePreset, refresh, isLoading, lastRefreshed, clearAllFilters, clearCrossFilter } = useData();
const [showDistrictDropdown, setShowDistrictDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowDistrictDropdown(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const toggleDistrict = (d: string) => {
const current = filters.districts;
if (current.includes(d)) {
setDistricts(current.filter(x => x !== d));
} else {
setDistricts([...current, d]);
}
};
const districtLabel = filters.districts.length === 0
? 'All Districts'
: filters.districts.length === 1
? filters.districts[0].replace('SE-', '')
: `${filters.districts.length} Districts`;
return (
<header className="h-14 bg-topbar-bg border-b border-topbar-border flex items-center px-4 gap-3 flex-shrink-0">
{/* Mobile logo */}
<div className="lg:hidden flex items-center gap-2 mr-auto">
<img src="/logo.svg" alt="Logo" className="h-6 w-6" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
<span className="font-bold text-sm text-brand-dark-blue">AgentMinder</span>
</div>
{/* Desktop spacer */}
<div className="hidden lg:block flex-1" />
{/* Active filters indicator */}
{filters.crossFilter && (
<button
onClick={clearCrossFilter}
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 bg-brand-azure/10 text-brand-azure text-xs font-medium rounded-full hover:bg-brand-azure/20 transition"
>
<span>Filter: {filters.crossFilter.value}</span>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
</button>
)}
{/* Date preset */}
<select
value={filters.datePreset}
onChange={e => setDatePreset(e.target.value as DatePreset)}
className="hidden sm:block text-xs border border-card-border rounded-lg px-2.5 py-1.5 bg-white text-foreground focus:outline-none focus:ring-2 focus:ring-brand-azure/30"
>
{DATE_PRESETS.map(p => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
{/* District filter */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowDistrictDropdown(!showDistrictDropdown)}
className="text-xs border border-card-border rounded-lg px-2.5 py-1.5 bg-white text-foreground flex items-center gap-1.5 hover:border-brand-azure/50 transition"
>
<svg className="w-3.5 h-3.5 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /></svg>
<span className="hidden sm:inline">{districtLabel}</span>
<span className="sm:hidden">
{filters.districts.length > 0 ? filters.districts.length : 'All'}
</span>
</button>
{showDistrictDropdown && (
<div className="absolute right-0 top-full mt-1 w-52 bg-white rounded-lg shadow-lg border border-card-border py-1 z-50">
<button
onClick={() => { setDistricts([]); setShowDistrictDropdown(false); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-brand-azure/5 ${filters.districts.length === 0 ? 'text-brand-azure font-semibold' : 'text-foreground'}`}
>
All Districts
</button>
{DISTRICTS.map(d => (
<button
key={d}
onClick={() => toggleDistrict(d)}
className="w-full text-left px-3 py-2 text-xs hover:bg-brand-azure/5 flex items-center gap-2"
>
<div className={`w-3.5 h-3.5 rounded border ${filters.districts.includes(d) ? 'bg-brand-azure border-brand-azure' : 'border-card-border'} flex items-center justify-center`}>
{filters.districts.includes(d) && (
<svg className="w-2.5 h-2.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><polyline points="20 6 9 17 4 12" /></svg>
)}
</div>
{d.replace('SE-', '')}
</button>
))}
</div>
)}
</div>
{/* Refresh */}
<button
onClick={refresh}
disabled={isLoading}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-card-border hover:border-brand-azure/50 transition disabled:opacity-50"
title={`Last refreshed: ${lastRefreshed ? format(new Date(lastRefreshed), 'MMM d, h:mm a') : 'Never'}`}
>
<svg className={`w-3.5 h-3.5 text-muted ${isLoading ? 'animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10" /><polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
<span className="hidden sm:inline">Refresh</span>
</button>
{/* Last updated */}
<div className="hidden md:block text-[10px] text-muted">
{lastRefreshed && `Updated ${format(new Date(lastRefreshed), 'h:mm a')}`}
</div>
</header>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import { ReactNode } from 'react';
interface ChartCardProps {
title: string;
subtitle?: string;
children: ReactNode;
className?: string;
action?: ReactNode;
}
export function ChartCard({ title, subtitle, children, className = '', action }: ChartCardProps) {
return (
<div className={`bg-card-bg rounded-xl border border-card-border p-5 ${className}`}>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle && <p className="text-xs text-muted mt-0.5">{subtitle}</p>}
</div>
{action}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,15 @@
'use client';
interface PageHeaderProps {
title: string;
subtitle: string;
}
export function PageHeader({ title, subtitle }: PageHeaderProps) {
return (
<div className="mb-6">
<h1 className="text-xl font-bold text-foreground">{title}</h1>
<p className="text-sm text-muted mt-0.5">{subtitle}</p>
</div>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
interface ScorecardProps {
label: string;
value: string | number;
subtitle?: string;
trend?: number;
color?: 'default' | 'green' | 'red' | 'amber';
onClick?: () => void;
}
export function Scorecard({ label, value, subtitle, trend, color = 'default', onClick }: ScorecardProps) {
const colorClasses = {
default: 'border-card-border',
green: 'border-l-4 border-l-success border-t-card-border border-r-card-border border-b-card-border',
red: 'border-l-4 border-l-danger border-t-card-border border-r-card-border border-b-card-border',
amber: 'border-l-4 border-l-warning border-t-card-border border-r-card-border border-b-card-border',
};
return (
<div
className={`bg-card-bg rounded-xl border ${colorClasses[color]} p-4 ${onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}`}
onClick={onClick}
>
<div className="text-xs text-muted font-medium uppercase tracking-wider mb-1">{label}</div>
<div className="flex items-end gap-2">
<div className="text-2xl font-bold text-foreground tracking-tight">{value}</div>
{trend !== undefined && (
<div className={`flex items-center gap-0.5 text-xs font-medium mb-0.5 ${trend >= 0 ? 'text-success' : 'text-danger'}`}>
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
{trend >= 0 ? <polyline points="18 15 12 9 6 15" /> : <polyline points="6 9 12 15 18 9" />}
</svg>
{Math.abs(trend)}%
</div>
)}
</div>
{subtitle && <div className="text-xs text-muted mt-1">{subtitle}</div>}
</div>
);
}

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

135
src/types/data.ts Normal file
View File

@@ -0,0 +1,135 @@
export interface PipelineRecord {
Opportunity_ID: string;
Account_Name: string;
District_Name: string;
Stage: string;
Forecast_Category: string;
Amount_USD: number;
Closed_Amount_USD: number | null;
Probability_Pct: number;
Created_Date: string;
Expected_Close_Date: string;
Closed_Date: string | null;
Champion_Name: string | null;
Economic_Buyer: string | null;
Next_Step: string | null;
Next_Step_Date: string | null;
Primary_Objection: string | null;
Competitor: string | null;
Source_Play: string | null;
Product: string;
Deal_Type: string | null;
Status: string | null;
Stage_Entered_Date: string | null;
Tier: string | null;
MAP_In_Place_YN: string | null;
}
export interface AccountRecord {
Account_Name: string;
District_Name: string;
Tier: string;
Priority: string;
AgentMinder_Status: string;
Current_ARR_USD: number | null;
Touch_Count: number | null;
Date_First_Touched: string | null;
Date_Last_Touched: string | null;
MAP_In_Place_YN: 'Y' | 'N' | null;
Company_URL: string | null;
Area_Sales_Leader: string | null;
DM: string | null;
AD: string | null;
IMS_BA: string | null;
Logo_URL: string | null;
Next_Renewal_Date: string | null;
Next_Renewal_EAR: number | null;
Anchor_Contract_Date: string | null;
Anchor_Contract_EAR: number | null;
Google_Drive_URL: string | null;
Campaign_Artifacts_URL: string | null;
}
export interface ActivityRecord {
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;
}
export interface TargetRecord {
Account_Name: string;
Implementation_Stage: string;
Go_Live_Date: string | null;
Health_Status: string | null;
Notes: string | null;
}
export interface ImplementationRecord {
Implementation_ID: string;
Opportunity_ID: string | null;
Account_Name: string;
District_Name: string | null;
Tier: string | null;
Closed_Date: string | null;
Closed_Amount_USD: number | null;
Kickoff_Date: string | null;
Go_Live_Target: string | null;
Go_Live_Actual: string | null;
Deployment_Status: string;
Health_RYG: string | null;
Milestones_Complete: number;
Milestones_Total: number;
Onboarding_Owner: string | null;
Risk_Notes: string | null;
}
export interface MetricTarget {
Target_ID: string;
Period_Type: string;
Period_Label: string;
District_Name: string;
Metric: string;
Target_Value: number;
Notes: string | null;
}
export interface DashboardData {
pipeline: PipelineRecord[];
accounts: AccountRecord[];
activities: ActivityRecord[];
targets: TargetRecord[];
implementations: ImplementationRecord[];
metricTargets: MetricTarget[];
lastRefreshed: string;
}
export interface DashboardConfig {
quotaTarget: number;
weeklyActivityTarget: number;
}
export type District = 'SE-SUNSHINE' | 'SE-PEACHTREE' | 'SE-MISS-VALLEY' | 'SE-MID-ATL';
export const DISTRICTS: District[] = ['SE-SUNSHINE', 'SE-PEACHTREE', 'SE-MISS-VALLEY', 'SE-MID-ATL'];
export const STAGES = [
'01-Qualified', '02-Discovery', '03-Evaluation', '04-Business Case',
'05-Negotiation', '06-Closed Won', '07-Closed Lost'
] as const;
export const FORECAST_CATEGORIES = ['Commit', 'Best Case', 'Pipeline', 'Closed', 'Omit'] as const;
export const IMPLEMENTATION_STAGES = ['Not Started', 'In Progress', 'Complete', 'Stalled'] as const;
export const ACTIVITY_TYPES = [
'Launch Briefing', 'Discovery', 'QBR Attach', 'Exec Meeting',
'Demo', 'Workshop', 'Email', 'Call'
] as const;

7
start.command Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
cd "$(dirname "$0")"
echo "Starting Campaign Command Center..."
echo "Dashboard will open at http://localhost:3000"
echo ""
open "http://localhost:3000"
npm run dev