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:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -33,6 +33,12 @@ yarn-error.log*
|
|||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
# database
|
||||||
|
/data/
|
||||||
|
|
||||||
|
# source data files
|
||||||
|
*.xlsx
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
serverExternalPackages: ['better-sqlite3'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1441
package-lock.json
generated
1441
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -9,12 +9,19 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^13.0.3",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"googleapis": "^176.0.0",
|
||||||
"next": "16.3.3",
|
"next": "16.3.3",
|
||||||
|
"next-auth": "^5.0.0-beta.32",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -22,5 +29,8 @@
|
|||||||
"eslint-config-next": "16.3.3",
|
"eslint-config-next": "16.3.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"better-sqlite3@13.0.3": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
public/logo.svg
Normal file
12
public/logo.svg
Normal 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
488
scripts/import-xlsx.js
Normal 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);
|
||||||
665
src/app/(dashboard)/accounts/page.tsx
Normal file
665
src/app/(dashboard)/accounts/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
598
src/app/(dashboard)/activity/page.tsx
Normal file
598
src/app/(dashboard)/activity/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/app/(dashboard)/implementation/page.tsx
Normal file
171
src/app/(dashboard)/implementation/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/(dashboard)/layout.tsx
Normal file
15
src/app/(dashboard)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
src/app/(dashboard)/page.tsx
Normal file
209
src/app/(dashboard)/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
315
src/app/(dashboard)/pipeline/page.tsx
Normal file
315
src/app/(dashboard)/pipeline/page.tsx
Normal 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
538
src/app/admin/page.tsx
Normal 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">
|
||||||
|
← 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 → Download → Comma-separated values), then upload them here.
|
||||||
|
The import is idempotent — 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/app/api/admin/route.ts
Normal file
54
src/app/api/admin/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/api/import/route.ts
Normal file
25
src/app/api/import/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/api/sheets/route.ts
Normal file
21
src/app/api/sheets/route.ts
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,91 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--brand-dark-blue: #1B1D36;
|
||||||
--foreground: #171717;
|
--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 {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--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-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,17 +13,16 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "AgentMinder Campaign Command Center",
|
||||||
description: "Generated by create next app",
|
description: "Sales campaign dashboard for the SouthEast territory",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
||||||
lang="en"
|
<body className="min-h-full">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
{children}
|
||||||
>
|
</body>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
23
src/components/layout/DashboardShell.tsx
Normal file
23
src/components/layout/DashboardShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/components/layout/Sidebar.tsx
Normal file
145
src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/components/layout/TopBar.tsx
Normal file
137
src/components/layout/TopBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/ui/ChartCard.tsx
Normal file
26
src/components/ui/ChartCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/ui/PageHeader.tsx
Normal file
15
src/components/ui/PageHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/ui/Scorecard.tsx
Normal file
40
src/components/ui/Scorecard.tsx
Normal 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
173
src/lib/data-context.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useMemo, useCallback, ReactNode } from 'react';
|
||||||
|
import { DashboardData, DashboardConfig, PipelineRecord, AccountRecord, ActivityRecord, TargetRecord } from '@/types/data';
|
||||||
|
import { startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfQuarter, endOfQuarter, subDays, parseISO, isWithinInterval } from 'date-fns';
|
||||||
|
|
||||||
|
export type DatePreset = 'this_week' | 'last_7' | 'this_month' | 'this_quarter' | 'last_quarter' | 'custom';
|
||||||
|
|
||||||
|
interface FilterState {
|
||||||
|
districts: string[];
|
||||||
|
datePreset: DatePreset;
|
||||||
|
dateRange: { start: string; end: string };
|
||||||
|
crossFilter: { key: string; value: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataContextValue {
|
||||||
|
raw: DashboardData;
|
||||||
|
config: DashboardConfig;
|
||||||
|
filters: FilterState;
|
||||||
|
setDistricts: (d: string[]) => void;
|
||||||
|
setDatePreset: (p: DatePreset) => void;
|
||||||
|
setDateRange: (start: string, end: string) => void;
|
||||||
|
setCrossFilter: (key: string, value: string) => void;
|
||||||
|
clearCrossFilter: () => void;
|
||||||
|
clearAllFilters: () => void;
|
||||||
|
filtered: {
|
||||||
|
pipeline: PipelineRecord[];
|
||||||
|
accounts: AccountRecord[];
|
||||||
|
activities: ActivityRecord[];
|
||||||
|
targets: TargetRecord[];
|
||||||
|
};
|
||||||
|
isLoading: boolean;
|
||||||
|
lastRefreshed: string;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataContext = createContext<DataContextValue | null>(null);
|
||||||
|
|
||||||
|
function getDateRange(preset: DatePreset, custom?: { start: string; end: string }): { start: Date; end: Date } {
|
||||||
|
const now = new Date();
|
||||||
|
switch (preset) {
|
||||||
|
case 'this_week':
|
||||||
|
return { start: startOfWeek(now, { weekStartsOn: 1 }), end: endOfWeek(now, { weekStartsOn: 1 }) };
|
||||||
|
case 'last_7':
|
||||||
|
return { start: subDays(now, 7), end: now };
|
||||||
|
case 'this_month':
|
||||||
|
return { start: startOfMonth(now), end: endOfMonth(now) };
|
||||||
|
case 'this_quarter':
|
||||||
|
return { start: startOfQuarter(now), end: endOfQuarter(now) };
|
||||||
|
case 'last_quarter': {
|
||||||
|
const lastQ = new Date(now);
|
||||||
|
lastQ.setMonth(lastQ.getMonth() - 3);
|
||||||
|
return { start: startOfQuarter(lastQ), end: endOfQuarter(lastQ) };
|
||||||
|
}
|
||||||
|
case 'custom':
|
||||||
|
if (custom) return { start: parseISO(custom.start), end: parseISO(custom.end) };
|
||||||
|
return { start: subDays(now, 30), end: now };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataProvider({ children, initialData, config }: { children: ReactNode; initialData: DashboardData; config: DashboardConfig }) {
|
||||||
|
const [data, setData] = useState<DashboardData>(initialData);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
districts: [],
|
||||||
|
datePreset: 'this_quarter',
|
||||||
|
dateRange: { start: '', end: '' },
|
||||||
|
crossFilter: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setDistricts = useCallback((d: string[]) => {
|
||||||
|
setFilters(f => ({ ...f, districts: d }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setDatePreset = useCallback((p: DatePreset) => {
|
||||||
|
setFilters(f => ({ ...f, datePreset: p }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setDateRange = useCallback((start: string, end: string) => {
|
||||||
|
setFilters(f => ({ ...f, datePreset: 'custom', dateRange: { start, end } }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setCrossFilter = useCallback((key: string, value: string) => {
|
||||||
|
setFilters(f => ({ ...f, crossFilter: { key, value } }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearCrossFilter = useCallback(() => {
|
||||||
|
setFilters(f => ({ ...f, crossFilter: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
setFilters({ districts: [], datePreset: 'this_quarter', dateRange: { start: '', end: '' }, crossFilter: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sheets?bust=' + Date.now());
|
||||||
|
if (res.ok) {
|
||||||
|
const newData = await res.json();
|
||||||
|
setData(newData);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const { districts: distFilter, datePreset, dateRange, crossFilter } = filters;
|
||||||
|
const range = getDateRange(datePreset, dateRange);
|
||||||
|
|
||||||
|
let pipeline = data.pipeline;
|
||||||
|
let accounts = data.accounts;
|
||||||
|
let activities = data.activities;
|
||||||
|
let targets = data.targets;
|
||||||
|
|
||||||
|
if (distFilter.length > 0) {
|
||||||
|
pipeline = pipeline.filter(p => distFilter.includes(p.District_Name));
|
||||||
|
accounts = accounts.filter(a => distFilter.includes(a.District_Name));
|
||||||
|
activities = activities.filter(a => distFilter.includes(a.District_Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
activities = activities.filter(a => {
|
||||||
|
try {
|
||||||
|
return isWithinInterval(parseISO(a.Activity_Date), range);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (crossFilter) {
|
||||||
|
const { key, value } = crossFilter;
|
||||||
|
const match = (obj: object) => String((obj as never)[key as never]) === value;
|
||||||
|
pipeline = pipeline.filter(match);
|
||||||
|
accounts = accounts.filter(match);
|
||||||
|
activities = activities.filter(match);
|
||||||
|
targets = targets.filter(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distFilter.length > 0) {
|
||||||
|
const accountNames = new Set(accounts.map(a => a.Account_Name));
|
||||||
|
targets = targets.filter(t => accountNames.has(t.Account_Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pipeline, accounts, activities, targets };
|
||||||
|
}, [data, filters]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataContext.Provider value={{
|
||||||
|
raw: data,
|
||||||
|
config,
|
||||||
|
filters,
|
||||||
|
setDistricts,
|
||||||
|
setDatePreset,
|
||||||
|
setDateRange,
|
||||||
|
setCrossFilter,
|
||||||
|
clearCrossFilter,
|
||||||
|
clearAllFilters,
|
||||||
|
filtered,
|
||||||
|
isLoading,
|
||||||
|
lastRefreshed: data.lastRefreshed,
|
||||||
|
refresh,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</DataContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useData() {
|
||||||
|
const ctx = useContext(DataContext);
|
||||||
|
if (!ctx) throw new Error('useData must be used within DataProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
484
src/lib/db.ts
Normal file
484
src/lib/db.ts
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import path from 'path';
|
||||||
|
import {
|
||||||
|
PipelineRecord,
|
||||||
|
AccountRecord,
|
||||||
|
ActivityRecord,
|
||||||
|
TargetRecord,
|
||||||
|
ImplementationRecord,
|
||||||
|
MetricTarget,
|
||||||
|
DashboardData,
|
||||||
|
} from '@/types/data';
|
||||||
|
|
||||||
|
const DB_PATH = path.join(process.cwd(), 'data', 'campaign.db');
|
||||||
|
|
||||||
|
let _db: Database.Database | null = null;
|
||||||
|
|
||||||
|
function getDb(): Database.Database {
|
||||||
|
if (!_db) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const dir = path.dirname(DB_PATH);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
_db = new Database(DB_PATH);
|
||||||
|
_db.pragma('journal_mode = WAL');
|
||||||
|
initSchema(_db);
|
||||||
|
}
|
||||||
|
return _db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSchema(db: Database.Database) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
Account_Name TEXT PRIMARY KEY,
|
||||||
|
District_Name TEXT NOT NULL DEFAULT '',
|
||||||
|
Tier TEXT NOT NULL DEFAULT '',
|
||||||
|
Priority TEXT NOT NULL DEFAULT '',
|
||||||
|
AgentMinder_Status TEXT NOT NULL DEFAULT 'Not Touched',
|
||||||
|
Current_ARR_USD REAL,
|
||||||
|
Touch_Count INTEGER DEFAULT 0,
|
||||||
|
Date_First_Touched TEXT,
|
||||||
|
Date_Last_Touched TEXT,
|
||||||
|
MAP_In_Place_YN TEXT,
|
||||||
|
Company_URL TEXT,
|
||||||
|
Area_Sales_Leader TEXT,
|
||||||
|
DM TEXT,
|
||||||
|
AD TEXT,
|
||||||
|
Logo_URL TEXT,
|
||||||
|
Next_Renewal_Date TEXT,
|
||||||
|
Next_Renewal_EAR REAL,
|
||||||
|
Anchor_Contract_Date TEXT,
|
||||||
|
Anchor_Contract_EAR REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pipeline (
|
||||||
|
Opportunity_ID TEXT PRIMARY KEY,
|
||||||
|
Account_Name TEXT NOT NULL,
|
||||||
|
District_Name TEXT NOT NULL DEFAULT '',
|
||||||
|
Stage TEXT NOT NULL DEFAULT '01-Qualified',
|
||||||
|
Forecast_Category TEXT NOT NULL DEFAULT 'Pipeline',
|
||||||
|
Amount_USD REAL NOT NULL DEFAULT 0,
|
||||||
|
Closed_Amount_USD REAL,
|
||||||
|
Probability_Pct INTEGER NOT NULL DEFAULT 0,
|
||||||
|
Created_Date TEXT NOT NULL,
|
||||||
|
Expected_Close_Date TEXT NOT NULL DEFAULT '',
|
||||||
|
Closed_Date TEXT,
|
||||||
|
Champion_Name TEXT,
|
||||||
|
Economic_Buyer TEXT,
|
||||||
|
Next_Step TEXT,
|
||||||
|
Next_Step_Date TEXT,
|
||||||
|
Primary_Objection TEXT,
|
||||||
|
Competitor TEXT,
|
||||||
|
Source_Play TEXT,
|
||||||
|
Product TEXT NOT NULL DEFAULT 'AgentMinder',
|
||||||
|
Deal_Type TEXT,
|
||||||
|
Status TEXT DEFAULT 'Open',
|
||||||
|
Stage_Entered_Date TEXT,
|
||||||
|
Tier TEXT,
|
||||||
|
MAP_In_Place_YN TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activities (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
Activity_ID TEXT,
|
||||||
|
Activity_Date TEXT NOT NULL,
|
||||||
|
Activity_Type TEXT NOT NULL,
|
||||||
|
Account_Name TEXT NOT NULL,
|
||||||
|
District_Name TEXT NOT NULL DEFAULT '',
|
||||||
|
Contact_Name TEXT,
|
||||||
|
Notes TEXT,
|
||||||
|
Play TEXT,
|
||||||
|
Channel TEXT,
|
||||||
|
Persona TEXT,
|
||||||
|
Outcome TEXT,
|
||||||
|
Logged_By TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS implementations (
|
||||||
|
Implementation_ID TEXT PRIMARY KEY,
|
||||||
|
Opportunity_ID TEXT,
|
||||||
|
Account_Name TEXT NOT NULL,
|
||||||
|
District_Name TEXT,
|
||||||
|
Tier TEXT,
|
||||||
|
Closed_Date TEXT,
|
||||||
|
Closed_Amount_USD REAL,
|
||||||
|
Kickoff_Date TEXT,
|
||||||
|
Go_Live_Target TEXT,
|
||||||
|
Go_Live_Actual TEXT,
|
||||||
|
Deployment_Status TEXT NOT NULL DEFAULT 'Not Started',
|
||||||
|
Health_RYG TEXT,
|
||||||
|
Milestones_Complete INTEGER DEFAULT 0,
|
||||||
|
Milestones_Total INTEGER DEFAULT 5,
|
||||||
|
Onboarding_Owner TEXT,
|
||||||
|
Risk_Notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS metric_targets (
|
||||||
|
Target_ID TEXT PRIMARY KEY,
|
||||||
|
Period_Type TEXT NOT NULL,
|
||||||
|
Period_Label TEXT NOT NULL DEFAULT '',
|
||||||
|
District_Name TEXT NOT NULL DEFAULT '',
|
||||||
|
Metric TEXT NOT NULL,
|
||||||
|
Target_Value REAL NOT NULL DEFAULT 0,
|
||||||
|
Notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pipeline_account ON pipeline(Account_Name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pipeline_district ON pipeline(District_Name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activities_account ON activities(Account_Name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activities_date ON activities(Activity_Date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_impl_account ON implementations(Account_Name);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDashboardData(): DashboardData {
|
||||||
|
const db = getDb();
|
||||||
|
return {
|
||||||
|
pipeline: db.prepare('SELECT * FROM pipeline').all() as PipelineRecord[],
|
||||||
|
accounts: db.prepare('SELECT * FROM accounts').all() as AccountRecord[],
|
||||||
|
activities: db.prepare('SELECT id, Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By FROM activities ORDER BY Activity_Date DESC').all() as (ActivityRecord & { id: number })[],
|
||||||
|
targets: buildTargetsFromImplementations(db),
|
||||||
|
implementations: db.prepare('SELECT * FROM implementations WHERE Account_Name IS NOT NULL').all() as ImplementationRecord[],
|
||||||
|
metricTargets: db.prepare('SELECT * FROM metric_targets').all() as MetricTarget[],
|
||||||
|
lastRefreshed: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTargetsFromImplementations(db: Database.Database): TargetRecord[] {
|
||||||
|
const impls = db.prepare('SELECT * FROM implementations WHERE Account_Name IS NOT NULL').all() as ImplementationRecord[];
|
||||||
|
return impls.map(impl => ({
|
||||||
|
Account_Name: impl.Account_Name,
|
||||||
|
Implementation_Stage: impl.Deployment_Status || 'Not Started',
|
||||||
|
Go_Live_Date: impl.Go_Live_Target || impl.Go_Live_Actual || null,
|
||||||
|
Health_Status: impl.Health_RYG || null,
|
||||||
|
Notes: impl.Risk_Notes || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDbStats() {
|
||||||
|
const db = getDb();
|
||||||
|
return {
|
||||||
|
accounts: (db.prepare('SELECT COUNT(*) as c FROM accounts').get() as { c: number }).c,
|
||||||
|
pipeline: (db.prepare('SELECT COUNT(*) as c FROM pipeline').get() as { c: number }).c,
|
||||||
|
activities: (db.prepare('SELECT COUNT(*) as c FROM activities').get() as { c: number }).c,
|
||||||
|
targets: (db.prepare('SELECT COUNT(*) as c FROM implementations').get() as { c: number }).c,
|
||||||
|
metricTargets: (db.prepare('SELECT COUNT(*) as c FROM metric_targets').get() as { c: number }).c,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLastTouched(accountName: string, date: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const acct = db.prepare('SELECT Date_First_Touched, Touch_Count FROM accounts WHERE Account_Name = ?').get(accountName) as { Date_First_Touched: string | null; Touch_Count: number | null } | undefined;
|
||||||
|
if (!acct) return;
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
Account_Name: accountName,
|
||||||
|
Date_Last_Touched: date,
|
||||||
|
Touch_Count: (acct.Touch_Count || 0) + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!acct.Date_First_Touched) {
|
||||||
|
updates.Date_First_Touched = date;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE accounts SET
|
||||||
|
Date_Last_Touched = @Date_Last_Touched,
|
||||||
|
Touch_Count = @Touch_Count
|
||||||
|
${!acct.Date_First_Touched ? ', Date_First_Touched = @Date_First_Touched' : ''}
|
||||||
|
WHERE Account_Name = @Account_Name
|
||||||
|
`).run(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recalcAccountTouchData() {
|
||||||
|
const db = getDb();
|
||||||
|
const activities = db.prepare(`
|
||||||
|
SELECT Account_Name, MIN(Activity_Date) as first_touch, MAX(Activity_Date) as last_touch, COUNT(*) as touch_count
|
||||||
|
FROM activities
|
||||||
|
GROUP BY Account_Name
|
||||||
|
`).all() as { Account_Name: string; first_touch: string; last_touch: string; touch_count: number }[];
|
||||||
|
|
||||||
|
const update = db.prepare(`
|
||||||
|
UPDATE accounts SET
|
||||||
|
Date_First_Touched = @first_touch,
|
||||||
|
Date_Last_Touched = @last_touch,
|
||||||
|
Touch_Count = @touch_count
|
||||||
|
WHERE Account_Name = @Account_Name
|
||||||
|
`);
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const a of activities) {
|
||||||
|
update.run(a);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recalcAccountStatus() {
|
||||||
|
const db = getDb();
|
||||||
|
db.exec(`
|
||||||
|
UPDATE accounts SET AgentMinder_Status = CASE
|
||||||
|
WHEN Account_Name IN (SELECT DISTINCT Account_Name FROM pipeline WHERE Status = 'Open') THEN '20% - Research'
|
||||||
|
WHEN Account_Name IN (SELECT DISTINCT Account_Name FROM activities) THEN '10% - Prospect'
|
||||||
|
ELSE 'Not Touched'
|
||||||
|
END
|
||||||
|
WHERE AgentMinder_Status = 'Not Touched'
|
||||||
|
OR AgentMinder_Status IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addActivity(record: {
|
||||||
|
Activity_Date: string;
|
||||||
|
Activity_Type: string;
|
||||||
|
Account_Name: string;
|
||||||
|
District_Name: string;
|
||||||
|
Contact_Name?: string | null;
|
||||||
|
Notes?: string | null;
|
||||||
|
Play?: string | null;
|
||||||
|
Channel?: string | null;
|
||||||
|
Persona?: string | null;
|
||||||
|
Outcome?: string | null;
|
||||||
|
Logged_By?: string | null;
|
||||||
|
}) {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO activities (Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By)
|
||||||
|
VALUES (@Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By)
|
||||||
|
`).run({
|
||||||
|
Activity_Date: record.Activity_Date,
|
||||||
|
Activity_Type: record.Activity_Type,
|
||||||
|
Account_Name: record.Account_Name,
|
||||||
|
District_Name: record.District_Name,
|
||||||
|
Contact_Name: record.Contact_Name || null,
|
||||||
|
Notes: record.Notes || null,
|
||||||
|
Play: record.Play || null,
|
||||||
|
Channel: record.Channel || null,
|
||||||
|
Persona: record.Persona || null,
|
||||||
|
Outcome: record.Outcome || null,
|
||||||
|
Logged_By: record.Logged_By || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
updateLastTouched(record.Account_Name, record.Activity_Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importData(data: {
|
||||||
|
accounts?: Record<string, string | null>[];
|
||||||
|
pipeline?: Record<string, string | null>[];
|
||||||
|
activities?: Record<string, string | null>[];
|
||||||
|
implementations?: Record<string, string | null>[];
|
||||||
|
metricTargets?: Record<string, string | null>[];
|
||||||
|
}): Record<string, number> {
|
||||||
|
const db = getDb();
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
if (data.accounts?.length) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO accounts (Account_Name, District_Name, Tier, Priority, AgentMinder_Status, Current_ARR_USD, Touch_Count, Date_First_Touched, Date_Last_Touched, MAP_In_Place_YN, Company_URL, Area_Sales_Leader, DM, AD, Logo_URL, Next_Renewal_Date, Next_Renewal_EAR, Anchor_Contract_Date, Anchor_Contract_EAR)
|
||||||
|
VALUES (@Account_Name, @District_Name, @Tier, @Priority, @AgentMinder_Status, @Current_ARR_USD, @Touch_Count, @Date_First_Touched, @Date_Last_Touched, @MAP_In_Place_YN, @Company_URL, @Area_Sales_Leader, @DM, @AD, @Logo_URL, @Next_Renewal_Date, @Next_Renewal_EAR, @Anchor_Contract_Date, @Anchor_Contract_EAR)
|
||||||
|
ON CONFLICT(Account_Name) DO UPDATE SET
|
||||||
|
District_Name=excluded.District_Name, Tier=excluded.Tier, Priority=excluded.Priority,
|
||||||
|
AgentMinder_Status=excluded.AgentMinder_Status, Current_ARR_USD=excluded.Current_ARR_USD,
|
||||||
|
Touch_Count=excluded.Touch_Count, Date_First_Touched=excluded.Date_First_Touched,
|
||||||
|
Date_Last_Touched=excluded.Date_Last_Touched, MAP_In_Place_YN=excluded.MAP_In_Place_YN,
|
||||||
|
Company_URL=excluded.Company_URL, Area_Sales_Leader=excluded.Area_Sales_Leader,
|
||||||
|
DM=excluded.DM, AD=excluded.AD, Logo_URL=excluded.Logo_URL,
|
||||||
|
Next_Renewal_Date=excluded.Next_Renewal_Date, Next_Renewal_EAR=excluded.Next_Renewal_EAR,
|
||||||
|
Anchor_Contract_Date=excluded.Anchor_Contract_Date, Anchor_Contract_EAR=excluded.Anchor_Contract_EAR
|
||||||
|
`);
|
||||||
|
let c = 0;
|
||||||
|
for (const r of data.accounts) {
|
||||||
|
if (!r.Account_Name) continue;
|
||||||
|
stmt.run(r);
|
||||||
|
c++;
|
||||||
|
}
|
||||||
|
counts.accounts = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.pipeline?.length) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO pipeline (Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Closed_Amount_USD, Probability_Pct, Created_Date, Expected_Close_Date, Closed_Date, Champion_Name, Economic_Buyer, Next_Step, Next_Step_Date, Primary_Objection, Competitor, Source_Play, Product, Deal_Type, Status, Stage_Entered_Date, Tier, MAP_In_Place_YN)
|
||||||
|
VALUES (@Opportunity_ID, @Account_Name, @District_Name, @Stage, @Forecast_Category, @Amount_USD, @Closed_Amount_USD, @Probability_Pct, @Created_Date, @Expected_Close_Date, @Closed_Date, @Champion_Name, @Economic_Buyer, @Next_Step, @Next_Step_Date, @Primary_Objection, @Competitor, @Source_Play, @Product, @Deal_Type, @Status, @Stage_Entered_Date, @Tier, @MAP_In_Place_YN)
|
||||||
|
ON CONFLICT(Opportunity_ID) DO UPDATE SET
|
||||||
|
Account_Name=excluded.Account_Name, District_Name=excluded.District_Name, Stage=excluded.Stage,
|
||||||
|
Forecast_Category=excluded.Forecast_Category, Amount_USD=excluded.Amount_USD,
|
||||||
|
Closed_Amount_USD=excluded.Closed_Amount_USD, Probability_Pct=excluded.Probability_Pct,
|
||||||
|
Created_Date=excluded.Created_Date, Expected_Close_Date=excluded.Expected_Close_Date,
|
||||||
|
Closed_Date=excluded.Closed_Date, Champion_Name=excluded.Champion_Name,
|
||||||
|
Economic_Buyer=excluded.Economic_Buyer, Next_Step=excluded.Next_Step,
|
||||||
|
Next_Step_Date=excluded.Next_Step_Date, Primary_Objection=excluded.Primary_Objection,
|
||||||
|
Competitor=excluded.Competitor, Source_Play=excluded.Source_Play, Product=excluded.Product,
|
||||||
|
Deal_Type=excluded.Deal_Type, Status=excluded.Status, Stage_Entered_Date=excluded.Stage_Entered_Date,
|
||||||
|
Tier=excluded.Tier, MAP_In_Place_YN=excluded.MAP_In_Place_YN
|
||||||
|
`);
|
||||||
|
let c = 0;
|
||||||
|
for (const r of data.pipeline) {
|
||||||
|
if (!r.Opportunity_ID) continue;
|
||||||
|
stmt.run(r);
|
||||||
|
c++;
|
||||||
|
}
|
||||||
|
counts.pipeline = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.activities?.length) {
|
||||||
|
db.prepare('DELETE FROM activities').run();
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO activities (Activity_ID, Activity_Date, Activity_Type, Account_Name, District_Name, Contact_Name, Notes, Play, Channel, Persona, Outcome, Logged_By)
|
||||||
|
VALUES (@Activity_ID, @Activity_Date, @Activity_Type, @Account_Name, @District_Name, @Contact_Name, @Notes, @Play, @Channel, @Persona, @Outcome, @Logged_By)
|
||||||
|
`);
|
||||||
|
let c = 0;
|
||||||
|
for (const r of data.activities) {
|
||||||
|
if (!r.Account_Name || !r.Activity_Date) continue;
|
||||||
|
stmt.run(r);
|
||||||
|
c++;
|
||||||
|
}
|
||||||
|
counts.activities = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.implementations?.length) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO implementations (Implementation_ID, Opportunity_ID, Account_Name, District_Name, Tier, Closed_Date, Closed_Amount_USD, Kickoff_Date, Go_Live_Target, Go_Live_Actual, Deployment_Status, Health_RYG, Milestones_Complete, Milestones_Total, Onboarding_Owner, Risk_Notes)
|
||||||
|
VALUES (@Implementation_ID, @Opportunity_ID, @Account_Name, @District_Name, @Tier, @Closed_Date, @Closed_Amount_USD, @Kickoff_Date, @Go_Live_Target, @Go_Live_Actual, @Deployment_Status, @Health_RYG, @Milestones_Complete, @Milestones_Total, @Onboarding_Owner, @Risk_Notes)
|
||||||
|
ON CONFLICT(Implementation_ID) DO UPDATE SET
|
||||||
|
Opportunity_ID=excluded.Opportunity_ID, Account_Name=excluded.Account_Name,
|
||||||
|
District_Name=excluded.District_Name, Tier=excluded.Tier,
|
||||||
|
Closed_Date=excluded.Closed_Date, Closed_Amount_USD=excluded.Closed_Amount_USD,
|
||||||
|
Kickoff_Date=excluded.Kickoff_Date, Go_Live_Target=excluded.Go_Live_Target,
|
||||||
|
Go_Live_Actual=excluded.Go_Live_Actual, Deployment_Status=excluded.Deployment_Status,
|
||||||
|
Health_RYG=excluded.Health_RYG, Milestones_Complete=excluded.Milestones_Complete,
|
||||||
|
Milestones_Total=excluded.Milestones_Total, Onboarding_Owner=excluded.Onboarding_Owner,
|
||||||
|
Risk_Notes=excluded.Risk_Notes
|
||||||
|
`);
|
||||||
|
let c = 0;
|
||||||
|
for (const r of data.implementations) {
|
||||||
|
if (!r.Implementation_ID || !r.Account_Name) continue;
|
||||||
|
stmt.run(r);
|
||||||
|
c++;
|
||||||
|
}
|
||||||
|
counts.implementations = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.metricTargets?.length) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO metric_targets (Target_ID, Period_Type, Period_Label, District_Name, Metric, Target_Value, Notes)
|
||||||
|
VALUES (@Target_ID, @Period_Type, @Period_Label, @District_Name, @Metric, @Target_Value, @Notes)
|
||||||
|
ON CONFLICT(Target_ID) DO UPDATE SET
|
||||||
|
Period_Type=excluded.Period_Type, Period_Label=excluded.Period_Label,
|
||||||
|
District_Name=excluded.District_Name, Metric=excluded.Metric,
|
||||||
|
Target_Value=excluded.Target_Value, Notes=excluded.Notes
|
||||||
|
`);
|
||||||
|
let c = 0;
|
||||||
|
for (const r of data.metricTargets) {
|
||||||
|
if (!r.Target_ID) continue;
|
||||||
|
stmt.run(r);
|
||||||
|
c++;
|
||||||
|
}
|
||||||
|
counts.metricTargets = c;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tx();
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAccount(name: string) {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('DELETE FROM accounts WHERE Account_Name = ?').run(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePipeline(id: string) {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('DELETE FROM pipeline WHERE Opportunity_ID = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteActivity(id: number) {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTarget(accountName: string) {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('DELETE FROM implementations WHERE Account_Name = ?').run(accountName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertAccount(record: Record<string, unknown>) {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`INSERT INTO accounts (Account_Name, District_Name, Tier, Priority, AgentMinder_Status, Current_ARR_USD, MAP_In_Place_YN, Company_URL, Area_Sales_Leader, DM, AD)
|
||||||
|
VALUES (@Account_Name, @District_Name, @Tier, @Priority, @AgentMinder_Status, @Current_ARR_USD, @MAP_In_Place_YN, @Company_URL, @Area_Sales_Leader, @DM, @AD)
|
||||||
|
ON CONFLICT(Account_Name) DO UPDATE SET
|
||||||
|
District_Name=excluded.District_Name, Tier=excluded.Tier, Priority=excluded.Priority,
|
||||||
|
AgentMinder_Status=excluded.AgentMinder_Status, Current_ARR_USD=excluded.Current_ARR_USD,
|
||||||
|
MAP_In_Place_YN=excluded.MAP_In_Place_YN, Company_URL=excluded.Company_URL,
|
||||||
|
Area_Sales_Leader=excluded.Area_Sales_Leader, DM=excluded.DM, AD=excluded.AD
|
||||||
|
`).run({
|
||||||
|
Account_Name: record.Account_Name || '',
|
||||||
|
District_Name: record.District_Name || '',
|
||||||
|
Tier: record.Tier || '',
|
||||||
|
Priority: record.Priority || '',
|
||||||
|
AgentMinder_Status: record.AgentMinder_Status || 'Not Touched',
|
||||||
|
Current_ARR_USD: record.Current_ARR_USD ?? null,
|
||||||
|
MAP_In_Place_YN: record.MAP_In_Place_YN ?? null,
|
||||||
|
Company_URL: record.Company_URL ?? null,
|
||||||
|
Area_Sales_Leader: record.Area_Sales_Leader ?? null,
|
||||||
|
DM: record.DM ?? null,
|
||||||
|
AD: record.AD ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertPipeline(record: Record<string, unknown>) {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`INSERT INTO pipeline (Opportunity_ID, Account_Name, District_Name, Stage, Forecast_Category, Amount_USD, Closed_Amount_USD, Probability_Pct, Created_Date, Expected_Close_Date, Closed_Date, Champion_Name, Economic_Buyer, Next_Step, Next_Step_Date, Primary_Objection, Competitor, Source_Play, Product)
|
||||||
|
VALUES (@Opportunity_ID, @Account_Name, @District_Name, @Stage, @Forecast_Category, @Amount_USD, @Closed_Amount_USD, @Probability_Pct, @Created_Date, @Expected_Close_Date, @Closed_Date, @Champion_Name, @Economic_Buyer, @Next_Step, @Next_Step_Date, @Primary_Objection, @Competitor, @Source_Play, @Product)
|
||||||
|
ON CONFLICT(Opportunity_ID) DO UPDATE SET
|
||||||
|
Account_Name=excluded.Account_Name, District_Name=excluded.District_Name, Stage=excluded.Stage,
|
||||||
|
Forecast_Category=excluded.Forecast_Category, Amount_USD=excluded.Amount_USD,
|
||||||
|
Closed_Amount_USD=excluded.Closed_Amount_USD, Probability_Pct=excluded.Probability_Pct,
|
||||||
|
Expected_Close_Date=excluded.Expected_Close_Date, Closed_Date=excluded.Closed_Date,
|
||||||
|
Champion_Name=excluded.Champion_Name, Economic_Buyer=excluded.Economic_Buyer,
|
||||||
|
Next_Step=excluded.Next_Step, Next_Step_Date=excluded.Next_Step_Date,
|
||||||
|
Primary_Objection=excluded.Primary_Objection, Competitor=excluded.Competitor,
|
||||||
|
Source_Play=excluded.Source_Play
|
||||||
|
`).run({
|
||||||
|
Opportunity_ID: record.Opportunity_ID || '',
|
||||||
|
Account_Name: record.Account_Name || '',
|
||||||
|
District_Name: record.District_Name || '',
|
||||||
|
Stage: record.Stage || '01-Qualified',
|
||||||
|
Forecast_Category: record.Forecast_Category || 'Pipeline',
|
||||||
|
Amount_USD: record.Amount_USD ?? 0,
|
||||||
|
Closed_Amount_USD: record.Closed_Amount_USD ?? null,
|
||||||
|
Probability_Pct: record.Probability_Pct ?? 0,
|
||||||
|
Created_Date: record.Created_Date || new Date().toISOString().split('T')[0],
|
||||||
|
Expected_Close_Date: record.Expected_Close_Date || '',
|
||||||
|
Closed_Date: record.Closed_Date ?? null,
|
||||||
|
Champion_Name: record.Champion_Name ?? null,
|
||||||
|
Economic_Buyer: record.Economic_Buyer ?? null,
|
||||||
|
Next_Step: record.Next_Step ?? null,
|
||||||
|
Next_Step_Date: record.Next_Step_Date ?? null,
|
||||||
|
Primary_Objection: record.Primary_Objection ?? null,
|
||||||
|
Competitor: record.Competitor ?? null,
|
||||||
|
Source_Play: record.Source_Play ?? null,
|
||||||
|
Product: record.Product || 'AgentMinder',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertTarget(record: Record<string, unknown>) {
|
||||||
|
const db = getDb();
|
||||||
|
const id = (record.Implementation_ID as string) || `IMPL-${Date.now()}`;
|
||||||
|
db.prepare(`INSERT INTO implementations (Implementation_ID, Account_Name, District_Name, Deployment_Status, Health_RYG, Go_Live_Target, Go_Live_Actual, Risk_Notes)
|
||||||
|
VALUES (@Implementation_ID, @Account_Name, @District_Name, @Deployment_Status, @Health_RYG, @Go_Live_Target, @Go_Live_Actual, @Risk_Notes)
|
||||||
|
ON CONFLICT(Implementation_ID) DO UPDATE SET
|
||||||
|
Account_Name=excluded.Account_Name, District_Name=excluded.District_Name,
|
||||||
|
Deployment_Status=excluded.Deployment_Status, Health_RYG=excluded.Health_RYG,
|
||||||
|
Go_Live_Target=excluded.Go_Live_Target, Go_Live_Actual=excluded.Go_Live_Actual,
|
||||||
|
Risk_Notes=excluded.Risk_Notes
|
||||||
|
`).run({
|
||||||
|
Implementation_ID: id,
|
||||||
|
Account_Name: record.Account_Name || '',
|
||||||
|
District_Name: record.District_Name ?? null,
|
||||||
|
Deployment_Status: record.Deployment_Status || record.Implementation_Stage || 'Not Started',
|
||||||
|
Health_RYG: record.Health_RYG || record.Health_Status || null,
|
||||||
|
Go_Live_Target: record.Go_Live_Target || record.Go_Live_Date || null,
|
||||||
|
Go_Live_Actual: record.Go_Live_Actual ?? null,
|
||||||
|
Risk_Notes: record.Risk_Notes || record.Notes || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
92
src/lib/formatters.ts
Normal file
92
src/lib/formatters.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
export function formatCurrency(value: number, compact = false): string {
|
||||||
|
if (compact && Math.abs(value) >= 1_000_000) {
|
||||||
|
return '$' + (value / 1_000_000).toFixed(1) + 'M';
|
||||||
|
}
|
||||||
|
if (compact && Math.abs(value) >= 1_000) {
|
||||||
|
return '$' + (value / 1_000).toFixed(0) + 'K';
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(value: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US').format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value: number, decimals = 0): string {
|
||||||
|
return value.toFixed(decimals) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRatio(value: number): string {
|
||||||
|
return value.toFixed(1) + 'x';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHART_COLORS = {
|
||||||
|
navy: '#005C8A',
|
||||||
|
azure: '#0098C7',
|
||||||
|
aqua: '#007B8C',
|
||||||
|
green: '#61A60E',
|
||||||
|
purple: '#6C4B94',
|
||||||
|
lightBlue: '#007DA3',
|
||||||
|
darkGreen: '#23800A',
|
||||||
|
brightBlue: '#0088EF',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHART_PALETTE = [
|
||||||
|
CHART_COLORS.navy,
|
||||||
|
CHART_COLORS.azure,
|
||||||
|
CHART_COLORS.aqua,
|
||||||
|
CHART_COLORS.green,
|
||||||
|
CHART_COLORS.purple,
|
||||||
|
CHART_COLORS.lightBlue,
|
||||||
|
CHART_COLORS.darkGreen,
|
||||||
|
CHART_COLORS.brightBlue,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STATUS_COLORS: Record<string, string> = {
|
||||||
|
'Not Touched': '#94A3B8',
|
||||||
|
'10% - Prospect': CHART_COLORS.azure,
|
||||||
|
'20% - Research': CHART_COLORS.aqua,
|
||||||
|
'30% - Engaged': CHART_COLORS.green,
|
||||||
|
'40% - Active Opp': CHART_COLORS.navy,
|
||||||
|
'50% - Implement': CHART_COLORS.purple,
|
||||||
|
Aware: CHART_COLORS.azure,
|
||||||
|
Engaged: CHART_COLORS.green,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FORECAST_COLORS: Record<string, string> = {
|
||||||
|
Commit: CHART_COLORS.navy,
|
||||||
|
'Best Case': CHART_COLORS.azure,
|
||||||
|
Pipeline: CHART_COLORS.aqua,
|
||||||
|
Closed: CHART_COLORS.green,
|
||||||
|
Omit: '#94A3B8',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STAGE_COLORS: Record<string, string> = {
|
||||||
|
'01-Qualified': CHART_COLORS.lightBlue,
|
||||||
|
'02-Discovery': CHART_COLORS.azure,
|
||||||
|
'03-Evaluation': CHART_COLORS.aqua,
|
||||||
|
'04-Business Case': CHART_COLORS.navy,
|
||||||
|
'05-Negotiation': CHART_COLORS.purple,
|
||||||
|
'06-Closed Won': CHART_COLORS.green,
|
||||||
|
'07-Closed Lost': '#94A3B8',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TIER_COLORS: Record<string, string> = {
|
||||||
|
'Tier 1': CHART_COLORS.navy,
|
||||||
|
'Tier 2': CHART_COLORS.azure,
|
||||||
|
'Tier 3': CHART_COLORS.aqua,
|
||||||
|
'Tier 4': '#94A3B8',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HEALTH_COLORS: Record<string, string> = {
|
||||||
|
Green: '#61A60E',
|
||||||
|
Yellow: '#F59E0B',
|
||||||
|
Red: '#EF4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DISTRICT_SHORT: Record<string, string> = {
|
||||||
|
'SE-SUNSHINE': 'Sunshine',
|
||||||
|
'SE-PEACHTREE': 'Peachtree',
|
||||||
|
'SE-MISS-VALLEY': 'Miss Valley',
|
||||||
|
'SE-MID-ATL': 'Mid-Atl',
|
||||||
|
};
|
||||||
134
src/lib/google-sheets.ts
Normal file
134
src/lib/google-sheets.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { google } from 'googleapis';
|
||||||
|
import {
|
||||||
|
PipelineRecord,
|
||||||
|
AccountRecord,
|
||||||
|
ActivityRecord,
|
||||||
|
TargetRecord,
|
||||||
|
DashboardData,
|
||||||
|
} from '@/types/data';
|
||||||
|
|
||||||
|
const SPREADSHEET_ID = process.env.GOOGLE_SHEETS_SPREADSHEET_ID;
|
||||||
|
|
||||||
|
function getAuth() {
|
||||||
|
return new google.auth.JWT({
|
||||||
|
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||||
|
key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||||
|
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTab(sheets: ReturnType<typeof google.sheets>, tab: string): Promise<string[][]> {
|
||||||
|
const res = await sheets.spreadsheets.values.get({
|
||||||
|
spreadsheetId: SPREADSHEET_ID,
|
||||||
|
range: `${tab}!A:Z`,
|
||||||
|
});
|
||||||
|
return (res.data.values as string[][]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsToObjects<T>(rows: string[][]): T[] {
|
||||||
|
if (rows.length < 2) return [];
|
||||||
|
const headers = rows[0];
|
||||||
|
return rows.slice(1).map(row => {
|
||||||
|
const obj: Record<string, string | null> = {};
|
||||||
|
headers.forEach((h, i) => {
|
||||||
|
obj[h] = row[i] ?? null;
|
||||||
|
});
|
||||||
|
return obj as unknown as T;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePipeline(raw: Record<string, string | null>[]): PipelineRecord[] {
|
||||||
|
return raw.map(r => ({
|
||||||
|
Opportunity_ID: r.Opportunity_ID || '',
|
||||||
|
Account_Name: r.Account_Name || '',
|
||||||
|
District_Name: r.District_Name || '',
|
||||||
|
Stage: (r.Stage || 'Discovery') as PipelineRecord['Stage'],
|
||||||
|
Forecast_Category: (r.Forecast_Category || 'Pipeline') as PipelineRecord['Forecast_Category'],
|
||||||
|
Amount_USD: parseFloat(r.Amount_USD || '0') || 0,
|
||||||
|
Closed_Amount_USD: r.Closed_Amount_USD ? parseFloat(r.Closed_Amount_USD) : null,
|
||||||
|
Probability_Pct: parseInt(r.Probability_Pct || '0') || 0,
|
||||||
|
Created_Date: r.Created_Date || '',
|
||||||
|
Expected_Close_Date: r.Expected_Close_Date || '',
|
||||||
|
Closed_Date: r.Closed_Date || null,
|
||||||
|
Champion_Name: r.Champion_Name || null,
|
||||||
|
Economic_Buyer: r.Economic_Buyer || null,
|
||||||
|
Next_Step: r.Next_Step || null,
|
||||||
|
Next_Step_Date: r.Next_Step_Date || null,
|
||||||
|
Primary_Objection: r.Primary_Objection || null,
|
||||||
|
Competitor: r.Competitor || null,
|
||||||
|
Source_Play: r.Source_Play || null,
|
||||||
|
Product: r.Product || 'AgentMinder',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAccounts(raw: Record<string, string | null>[]): AccountRecord[] {
|
||||||
|
return raw.map(r => ({
|
||||||
|
Account_Name: r.Account_Name || '',
|
||||||
|
District_Name: r.District_Name || '',
|
||||||
|
Tier: (r.Tier || '3') as AccountRecord['Tier'],
|
||||||
|
Priority: (r.Priority || 'Low') as AccountRecord['Priority'],
|
||||||
|
AgentMinder_Status: (r.AgentMinder_Status || 'Not Touched') as AccountRecord['AgentMinder_Status'],
|
||||||
|
Current_ARR_USD: r.Current_ARR_USD ? parseFloat(r.Current_ARR_USD) : null,
|
||||||
|
Touch_Count: r.Touch_Count ? parseInt(r.Touch_Count) : null,
|
||||||
|
Date_First_Touched: r.Date_First_Touched || null,
|
||||||
|
Date_Last_Touched: r.Date_Last_Touched || null,
|
||||||
|
MAP_In_Place_YN: (r.MAP_In_Place_YN as AccountRecord['MAP_In_Place_YN']) || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActivities(raw: Record<string, string | null>[]): ActivityRecord[] {
|
||||||
|
return raw.map(r => ({
|
||||||
|
Activity_Date: r.Activity_Date || '',
|
||||||
|
Activity_Type: r.Activity_Type || '',
|
||||||
|
Account_Name: r.Account_Name || '',
|
||||||
|
District_Name: r.District_Name || '',
|
||||||
|
Contact_Name: r.Contact_Name || null,
|
||||||
|
Notes: r.Notes || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTargets(raw: Record<string, string | null>[]): TargetRecord[] {
|
||||||
|
return raw.map(r => ({
|
||||||
|
Account_Name: r.Account_Name || '',
|
||||||
|
Implementation_Stage: (r.Implementation_Stage || 'Contract Signed') as TargetRecord['Implementation_Stage'],
|
||||||
|
Go_Live_Date: r.Go_Live_Date || null,
|
||||||
|
Health_Status: (r.Health_Status as TargetRecord['Health_Status']) || null,
|
||||||
|
Notes: r.Notes || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSheetData(): Promise<DashboardData> {
|
||||||
|
const auth = getAuth();
|
||||||
|
const sheets = google.sheets({ version: 'v4', auth });
|
||||||
|
|
||||||
|
const [pipelineRows, accountRows, activityRows, targetRows] = await Promise.all([
|
||||||
|
fetchTab(sheets, 'Pipeline'),
|
||||||
|
fetchTab(sheets, 'Accounts'),
|
||||||
|
fetchTab(sheets, 'Activity_Log'),
|
||||||
|
fetchTab(sheets, 'Targets'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const requiredHeaders: Record<string, string[]> = {
|
||||||
|
Pipeline: ['Opportunity_ID', 'Account_Name', 'District_Name', 'Stage', 'Amount_USD'],
|
||||||
|
Accounts: ['Account_Name', 'District_Name', 'Tier', 'Priority', 'AgentMinder_Status'],
|
||||||
|
Activity_Log: ['Activity_Date', 'Activity_Type', 'Account_Name', 'District_Name'],
|
||||||
|
Targets: ['Account_Name', 'Implementation_Stage'],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [tab, required] of Object.entries(requiredHeaders)) {
|
||||||
|
const rows = { Pipeline: pipelineRows, Accounts: accountRows, Activity_Log: activityRows, Targets: targetRows }[tab]!;
|
||||||
|
const headers = rows[0] || [];
|
||||||
|
const missing = required.filter(h => !headers.includes(h));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.warn(`[Sheets] Tab "${tab}" missing columns: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pipeline: parsePipeline(rowsToObjects(pipelineRows)),
|
||||||
|
accounts: parseAccounts(rowsToObjects(accountRows)),
|
||||||
|
activities: parseActivities(rowsToObjects(activityRows)),
|
||||||
|
targets: parseTargets(rowsToObjects(targetRows)),
|
||||||
|
lastRefreshed: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
251
src/lib/mock-data.ts
Normal file
251
src/lib/mock-data.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import {
|
||||||
|
PipelineRecord,
|
||||||
|
AccountRecord,
|
||||||
|
ActivityRecord,
|
||||||
|
TargetRecord,
|
||||||
|
ImplementationRecord,
|
||||||
|
MetricTarget,
|
||||||
|
DashboardData,
|
||||||
|
DashboardConfig,
|
||||||
|
} from '@/types/data';
|
||||||
|
|
||||||
|
const districts = ['SE-SUNSHINE', 'SE-PEACHTREE', 'SE-MISS-VALLEY', 'SE-MID-ATL'] as const;
|
||||||
|
const tiers = ['Tier 1', 'Tier 2', 'Tier 3', 'Tier 4'] as const;
|
||||||
|
const priorities = ['High', 'Medium', 'Low'] as const;
|
||||||
|
const statuses = ['10% - Prospect', '20% - Research', 'Not Touched'] as const;
|
||||||
|
const stages = ['02-Discovery', '03-Evaluation', '04-Business Case', '05-Negotiation', '06-Closed Won', '07-Closed Lost'] as const;
|
||||||
|
const forecastCats = ['Commit', 'Best Case', 'Pipeline', 'Closed', 'Omit'] as const;
|
||||||
|
const activityTypes = ['Launch Briefing', 'Discovery', 'QBR Attach', 'Exec Meeting', 'Demo', 'Workshop', 'Email', 'Call'] as const;
|
||||||
|
const competitors = ['Competitor A', 'Competitor B', 'Competitor C', 'Incumbent', null];
|
||||||
|
const sourcePlays = ['Attach', 'Renewal-Trigger', 'Champion Referral', 'Cold Outbound', 'Event Follow-Up'];
|
||||||
|
const implStages = ['Not Started', 'In Progress', 'Complete', 'Stalled'] as const;
|
||||||
|
|
||||||
|
function randomDate(start: Date, end: Date): string {
|
||||||
|
const d = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick<T>(arr: readonly T[]): T {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function weightedPick<T>(arr: readonly T[], weights: number[]): T {
|
||||||
|
const total = weights.reduce((a, b) => a + b, 0);
|
||||||
|
let r = Math.random() * total;
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
r -= weights[i];
|
||||||
|
if (r <= 0) return arr[i];
|
||||||
|
}
|
||||||
|
return arr[arr.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountNames = [
|
||||||
|
'Baptist Health System', 'Emory Healthcare', 'Piedmont Healthcare', 'Grady Health', 'WellStar Health',
|
||||||
|
'Southeast Regional Medical', 'Atrium Health', 'Prisma Health', 'HCA Florida', 'AdventHealth Orlando',
|
||||||
|
'UF Health Jacksonville', 'Tampa General Hospital', 'Moffitt Cancer Center', 'Mayo Clinic Jacksonville',
|
||||||
|
'Nemours Children\'s', 'Duke Energy', 'Southern Company', 'Florida Power & Light', 'Coca-Cola Enterprises',
|
||||||
|
'Home Depot Regional', 'Delta Air Lines', 'UPS Southeast Hub', 'Norfolk Southern', 'AutoNation Southeast',
|
||||||
|
'Publix Super Markets', 'SunTrust / Truist', 'Regions Financial', 'Synovus Bank', 'Raymond James',
|
||||||
|
'Jacksonville Jaguars', 'Georgia Tech Research', 'University of Florida', 'Vanderbilt University',
|
||||||
|
'Clemson University', 'Georgia-Pacific', 'Mohawk Industries', 'Interface Inc', 'Aflac',
|
||||||
|
'TSYS / Global Payments', 'NCR Corporation', 'Gartner SE Office', 'Genuine Parts Co', 'Rollins Inc',
|
||||||
|
'Flowers Foods', 'Americold Realty', 'BlueLinx Holdings', 'Chart Industries', 'EzFill Holdings',
|
||||||
|
'MiMedx Group', 'Cardlytics Inc', 'GreenSky', 'Kabbage / Amex', 'Calendly HQ', 'Mailchimp / Intuit',
|
||||||
|
'SalesLoft', 'Greenlight Financial', 'OneTrust', 'Ionic Security', 'Pindrop Security', 'CallRail',
|
||||||
|
'FullStory', 'Rubicon Global', 'InComm Payments', 'EasySend SE', 'Datacert Legal', 'Aptean SE',
|
||||||
|
'Manhattan Associates', 'ControlScan', 'Oversight Systems', 'BitPay', 'Bakkt Holdings',
|
||||||
|
'Intercontinental Exchange', 'Invesco SE', 'Jackson Hewitt SE', 'Aaron\'s Holdings', 'FleetCor Technologies',
|
||||||
|
'Worldpay SE', 'Fiserv SE', 'Equifax', 'TransUnion SE', 'LexisNexis Risk', 'Verint SE',
|
||||||
|
'Encompass Health', 'Community Health Systems', 'Tenet Healthcare SE', 'LifePoint Health',
|
||||||
|
'Kindred Healthcare SE', 'Amedisys', 'Brookdale Senior Living', 'Surgery Partners',
|
||||||
|
];
|
||||||
|
|
||||||
|
function generateAccounts(): AccountRecord[] {
|
||||||
|
return accountNames.slice(0, 90).map((name, i) => {
|
||||||
|
const tier = i < 10 ? 'Tier 1' : i < 25 ? 'Tier 2' : i < 50 ? 'Tier 3' : 'Tier 4';
|
||||||
|
const district = districts[i % 4];
|
||||||
|
const status = weightedPick(statuses, [10, 5, 85]);
|
||||||
|
const priority = tier === 'Tier 1' ? 'High' : tier === 'Tier 2' ? weightedPick(priorities, [40, 50, 10]) : weightedPick(priorities, [10, 40, 50]);
|
||||||
|
const touchCount = status === 'Not Touched' ? 0 : Math.floor(Math.random() * 15) + 1;
|
||||||
|
const hasTouch = touchCount > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Account_Name: name,
|
||||||
|
District_Name: district,
|
||||||
|
Tier: tier,
|
||||||
|
Priority: priority,
|
||||||
|
AgentMinder_Status: status,
|
||||||
|
Current_ARR_USD: Math.random() > 0.3 ? Math.floor(Math.random() * 500000) + 10000 : null,
|
||||||
|
Touch_Count: touchCount,
|
||||||
|
Date_First_Touched: hasTouch ? randomDate(new Date('2026-01-15'), new Date('2026-06-01')) : null,
|
||||||
|
Date_Last_Touched: hasTouch ? randomDate(new Date('2026-06-01'), new Date('2026-08-25')) : null,
|
||||||
|
MAP_In_Place_YN: hasTouch && Math.random() > 0.5 ? 'Y' : 'N',
|
||||||
|
Company_URL: null,
|
||||||
|
Area_Sales_Leader: null,
|
||||||
|
DM: null,
|
||||||
|
AD: null,
|
||||||
|
IMS_BA: null,
|
||||||
|
Logo_URL: null,
|
||||||
|
Next_Renewal_Date: null,
|
||||||
|
Next_Renewal_EAR: null,
|
||||||
|
Anchor_Contract_Date: null,
|
||||||
|
Anchor_Contract_EAR: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePipeline(accounts: AccountRecord[]): PipelineRecord[] {
|
||||||
|
const touchedAccounts = accounts.filter(a => a.AgentMinder_Status !== 'Not Touched');
|
||||||
|
const opps: PipelineRecord[] = [];
|
||||||
|
let oppId = 1;
|
||||||
|
|
||||||
|
for (const account of touchedAccounts) {
|
||||||
|
const numOpps = account.Tier === 'Tier 1' ? Math.ceil(Math.random() * 2) : Math.random() > 0.5 ? 1 : 0;
|
||||||
|
for (let j = 0; j < numOpps; j++) {
|
||||||
|
const stage = weightedPick(stages, [25, 15, 15, 10, 25, 10]);
|
||||||
|
const isClosed = stage === '06-Closed Won' || stage === '07-Closed Lost';
|
||||||
|
const amount = account.Tier === 'Tier 1'
|
||||||
|
? Math.floor(Math.random() * 400000) + 100000
|
||||||
|
: account.Tier === 'Tier 2'
|
||||||
|
? Math.floor(Math.random() * 150000) + 25000
|
||||||
|
: Math.floor(Math.random() * 50000) + 5000;
|
||||||
|
|
||||||
|
const createdDate = randomDate(new Date('2026-02-01'), new Date('2026-07-15'));
|
||||||
|
const expectedClose = randomDate(new Date('2026-08-01'), new Date('2026-12-31'));
|
||||||
|
const prob = stage === '06-Closed Won' ? 100 : stage === '07-Closed Lost' ? 0
|
||||||
|
: stage === '05-Negotiation' ? 60 + Math.floor(Math.random() * 20)
|
||||||
|
: stage === '04-Business Case' ? 40 + Math.floor(Math.random() * 15)
|
||||||
|
: stage === '03-Evaluation' ? 20 + Math.floor(Math.random() * 15)
|
||||||
|
: 10 + Math.floor(Math.random() * 10);
|
||||||
|
|
||||||
|
const forecast =
|
||||||
|
stage === '06-Closed Won' ? 'Closed'
|
||||||
|
: stage === '07-Closed Lost' ? 'Omit'
|
||||||
|
: stage === '05-Negotiation' ? weightedPick(['Commit', 'Best Case', 'Pipeline'] as const, [50, 35, 15])
|
||||||
|
: stage === '04-Business Case' ? weightedPick(['Best Case', 'Pipeline'] as const, [40, 60])
|
||||||
|
: 'Pipeline';
|
||||||
|
|
||||||
|
opps.push({
|
||||||
|
Opportunity_ID: `OPP-${String(oppId++).padStart(4, '0')}`,
|
||||||
|
Account_Name: account.Account_Name,
|
||||||
|
District_Name: account.District_Name,
|
||||||
|
Stage: stage,
|
||||||
|
Forecast_Category: forecast,
|
||||||
|
Amount_USD: amount,
|
||||||
|
Closed_Amount_USD: stage === '06-Closed Won' ? amount : null,
|
||||||
|
Probability_Pct: prob,
|
||||||
|
Created_Date: createdDate,
|
||||||
|
Expected_Close_Date: isClosed ? createdDate : expectedClose,
|
||||||
|
Closed_Date: isClosed ? randomDate(new Date(createdDate), new Date('2026-08-25')) : null,
|
||||||
|
Champion_Name: Math.random() > 0.3 ? `Contact ${oppId}` : null,
|
||||||
|
Economic_Buyer: Math.random() > 0.4 ? `VP ${pick(['Operations', 'IT', 'Finance', 'Clinical'])}` : null,
|
||||||
|
Next_Step: isClosed ? null : pick(['Schedule executive briefing', 'Send proposal', 'Negotiate terms', 'Technical validation', 'Reference call', 'POC planning']),
|
||||||
|
Next_Step_Date: isClosed ? null : randomDate(new Date('2026-08-25'), new Date('2026-10-01')),
|
||||||
|
Primary_Objection: Math.random() > 0.5 ? pick(['Budget timing', 'Competing priority', 'Need more references', 'Integration concerns', 'Contract terms']) : null,
|
||||||
|
Competitor: pick(competitors),
|
||||||
|
Source_Play: pick(sourcePlays),
|
||||||
|
Product: 'AgentMinder',
|
||||||
|
Deal_Type: null,
|
||||||
|
Status: null,
|
||||||
|
Stage_Entered_Date: null,
|
||||||
|
Tier: account.Tier,
|
||||||
|
MAP_In_Place_YN: account.MAP_In_Place_YN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateActivities(accounts: AccountRecord[]): ActivityRecord[] {
|
||||||
|
const activities: ActivityRecord[] = [];
|
||||||
|
const touchedAccounts = accounts.filter(a => a.AgentMinder_Status !== 'Not Touched');
|
||||||
|
|
||||||
|
for (const account of touchedAccounts) {
|
||||||
|
const numActivities = Math.floor(Math.random() * 8) + 1;
|
||||||
|
for (let j = 0; j < numActivities; j++) {
|
||||||
|
activities.push({
|
||||||
|
Activity_Date: randomDate(new Date('2026-03-01'), new Date('2026-08-27')),
|
||||||
|
Activity_Type: pick(activityTypes),
|
||||||
|
Account_Name: account.Account_Name,
|
||||||
|
District_Name: account.District_Name,
|
||||||
|
Contact_Name: Math.random() > 0.3 ? `${pick(['Dr.', 'Mr.', 'Ms.'])} ${pick(['Smith', 'Johnson', 'Williams', 'Brown', 'Davis', 'Garcia', 'Miller', 'Wilson', 'Anderson', 'Thomas'])}` : null,
|
||||||
|
Notes: Math.random() > 0.4 ? pick([
|
||||||
|
'Positive meeting - moving forward with evaluation',
|
||||||
|
'Discussed ROI framework and implementation timeline',
|
||||||
|
'Champion is engaged, need exec sponsor alignment',
|
||||||
|
'Competitor mentioned - need to differentiate on security',
|
||||||
|
'Budget approved for Q3 - accelerating timeline',
|
||||||
|
'Technical team needs integration documentation',
|
||||||
|
'Follow-up scheduled for next week',
|
||||||
|
'QBR went well - expansion opportunity identified',
|
||||||
|
'Need to address compliance requirements before proceeding',
|
||||||
|
'Demo completed - strong interest from clinical team',
|
||||||
|
]) : null,
|
||||||
|
Play: null,
|
||||||
|
Channel: null,
|
||||||
|
Persona: null,
|
||||||
|
Outcome: null,
|
||||||
|
Logged_By: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return activities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date));
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTargets(pipeline: PipelineRecord[]): TargetRecord[] {
|
||||||
|
const closedWon = pipeline.filter(p => p.Stage === '06-Closed Won');
|
||||||
|
return closedWon.map(opp => {
|
||||||
|
const implStage = weightedPick(implStages, [15, 30, 35, 20]);
|
||||||
|
return {
|
||||||
|
Account_Name: opp.Account_Name,
|
||||||
|
Implementation_Stage: implStage,
|
||||||
|
Go_Live_Date: implStage === 'Complete'
|
||||||
|
? randomDate(new Date('2026-05-01'), new Date('2026-08-20'))
|
||||||
|
: implStage === 'Stalled' ? null
|
||||||
|
: randomDate(new Date('2026-09-01'), new Date('2026-11-30')),
|
||||||
|
Health_Status: implStage === 'Stalled' ? 'Red'
|
||||||
|
: implStage === 'Complete' ? 'Green'
|
||||||
|
: weightedPick(['Green', 'Yellow', 'Red'] as const, [50, 35, 15]),
|
||||||
|
Notes: pick([
|
||||||
|
'On track for target go-live',
|
||||||
|
'Awaiting IT resource allocation',
|
||||||
|
'Configuration in progress - 60% complete',
|
||||||
|
'Successfully live - collecting reference feedback',
|
||||||
|
'Blocked on SSO integration',
|
||||||
|
'Training sessions scheduled',
|
||||||
|
null,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedData: DashboardData | null = null;
|
||||||
|
|
||||||
|
export function getMockData(): DashboardData {
|
||||||
|
if (cachedData) return cachedData;
|
||||||
|
|
||||||
|
const accounts = generateAccounts();
|
||||||
|
const pipeline = generatePipeline(accounts);
|
||||||
|
const activities = generateActivities(accounts);
|
||||||
|
const targets = generateTargets(pipeline);
|
||||||
|
|
||||||
|
cachedData = {
|
||||||
|
pipeline,
|
||||||
|
accounts,
|
||||||
|
activities,
|
||||||
|
targets,
|
||||||
|
implementations: [],
|
||||||
|
metricTargets: [],
|
||||||
|
lastRefreshed: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return cachedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMockConfig(): DashboardConfig {
|
||||||
|
return {
|
||||||
|
quotaTarget: 5000000,
|
||||||
|
weeklyActivityTarget: 25,
|
||||||
|
};
|
||||||
|
}
|
||||||
135
src/types/data.ts
Normal file
135
src/types/data.ts
Normal 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
7
start.command
Executable 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
|
||||||
Reference in New Issue
Block a user