Add CPM (Customer Portfolio Manager) attribute to accounts
- New CPM field on AccountRecord type, DB schema, and migration - Seed "David Stefan" as CPM for 20 specified accounts - CPM filter dropdown on Account Explorer (alongside AD and IMS BA) - CPM shown on account cards, account detail view, and activity overlay - CPM column added to admin data table - upsertAccount includes CPM in insert/update Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,7 @@ export default function AccountExplorer() {
|
|||||||
const [renewalFilter, setRenewalFilter] = useState<string>('all');
|
const [renewalFilter, setRenewalFilter] = useState<string>('all');
|
||||||
const [priorityFilter, setPriorityFilter] = useState<string | null>(null);
|
const [priorityFilter, setPriorityFilter] = useState<string | null>(null);
|
||||||
const [competitorFilter, setCompetitorFilter] = useState<string | null>(null);
|
const [competitorFilter, setCompetitorFilter] = useState<string | null>(null);
|
||||||
|
const [cpmFilter, setCpmFilter] = useState<string | null>(null);
|
||||||
const [selectedAccount, setSelectedAccount] = useState<AccountRecord | null>(null);
|
const [selectedAccount, setSelectedAccount] = useState<AccountRecord | null>(null);
|
||||||
const [showCreateOpp, setShowCreateOpp] = useState(false);
|
const [showCreateOpp, setShowCreateOpp] = useState(false);
|
||||||
const [showAddActivity, setShowAddActivity] = useState(false);
|
const [showAddActivity, setShowAddActivity] = useState(false);
|
||||||
@@ -177,6 +178,7 @@ export default function AccountExplorer() {
|
|||||||
|
|
||||||
const adValues = useMemo(() => Array.from(new Set(accounts.map(a => a.AD).filter(Boolean))).sort() as string[], [accounts]);
|
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 imsbaValues = useMemo(() => Array.from(new Set(accounts.map(a => a.IMS_BA).filter(Boolean))).sort() as string[], [accounts]);
|
||||||
|
const cpmValues = useMemo(() => Array.from(new Set(accounts.map(a => a.CPM).filter(Boolean))).sort() as string[], [accounts]);
|
||||||
|
|
||||||
const filteredAccounts = useMemo(() => {
|
const filteredAccounts = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -190,6 +192,7 @@ export default function AccountExplorer() {
|
|||||||
if (districtFilter) result = result.filter(a => a.District_Name === districtFilter);
|
if (districtFilter) result = result.filter(a => a.District_Name === districtFilter);
|
||||||
if (adFilter) result = result.filter(a => a.AD === adFilter);
|
if (adFilter) result = result.filter(a => a.AD === adFilter);
|
||||||
if (imsbaFilter) result = result.filter(a => a.IMS_BA === imsbaFilter);
|
if (imsbaFilter) result = result.filter(a => a.IMS_BA === imsbaFilter);
|
||||||
|
if (cpmFilter) result = result.filter(a => a.CPM === cpmFilter);
|
||||||
if (priorityFilter) result = result.filter(a => a.Priority === priorityFilter);
|
if (priorityFilter) result = result.filter(a => a.Priority === priorityFilter);
|
||||||
if (competitorFilter) result = result.filter(a => {
|
if (competitorFilter) result = result.filter(a => {
|
||||||
const comps = (a.Competitors || '').split(',').map(c => c.trim()).filter(Boolean);
|
const comps = (a.Competitors || '').split(',').map(c => c.trim()).filter(Boolean);
|
||||||
@@ -213,7 +216,7 @@ export default function AccountExplorer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [accounts, search, tierFilter, statusFilter, districtFilter, adFilter, imsbaFilter, renewalFilter, priorityFilter, competitorFilter]);
|
}, [accounts, search, tierFilter, statusFilter, districtFilter, adFilter, imsbaFilter, cpmFilter, renewalFilter, priorityFilter, competitorFilter]);
|
||||||
|
|
||||||
const kpis = useMemo(() => {
|
const kpis = useMemo(() => {
|
||||||
const acctNames = new Set(filteredAccounts.map(a => a.Account_Name));
|
const acctNames = new Set(filteredAccounts.map(a => a.Account_Name));
|
||||||
@@ -292,11 +295,12 @@ export default function AccountExplorer() {
|
|||||||
setImsbaFilter(null);
|
setImsbaFilter(null);
|
||||||
setPriorityFilter(null);
|
setPriorityFilter(null);
|
||||||
setCompetitorFilter(null);
|
setCompetitorFilter(null);
|
||||||
|
setCpmFilter(null);
|
||||||
setRenewalFilter('all');
|
setRenewalFilter('all');
|
||||||
setSearch('');
|
setSearch('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFilters = tierFilter || statusFilter || districtFilter || adFilter || imsbaFilter || priorityFilter || competitorFilter || renewalFilter !== 'all' || search;
|
const hasFilters = tierFilter || statusFilter || districtFilter || adFilter || imsbaFilter || cpmFilter || priorityFilter || competitorFilter || renewalFilter !== 'all' || search;
|
||||||
|
|
||||||
if (selectedAccount) {
|
if (selectedAccount) {
|
||||||
const suggestion = getSuggestedPriority(selectedAccount);
|
const suggestion = getSuggestedPriority(selectedAccount);
|
||||||
@@ -395,8 +399,8 @@ export default function AccountExplorer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(selectedAccount.AD || selectedAccount.IMS_BA || selectedAccount.Area_Sales_Leader || selectedAccount.DM) && (
|
{(selectedAccount.AD || selectedAccount.IMS_BA || selectedAccount.CPM || 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">
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mt-3 pt-3 border-t border-card-border">
|
||||||
{selectedAccount.Area_Sales_Leader && (
|
{selectedAccount.Area_Sales_Leader && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted uppercase">Area Sales Leader</div>
|
<div className="text-[10px] text-muted uppercase">Area Sales Leader</div>
|
||||||
@@ -421,6 +425,12 @@ export default function AccountExplorer() {
|
|||||||
<div className="text-sm font-semibold">{selectedAccount.IMS_BA}</div>
|
<div className="text-sm font-semibold">{selectedAccount.IMS_BA}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedAccount.CPM && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted uppercase">CPM</div>
|
||||||
|
<div className="text-sm font-semibold">{selectedAccount.CPM}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -815,6 +825,16 @@ export default function AccountExplorer() {
|
|||||||
{imsbaValues.map(v => <option key={v} value={v}>{v}</option>)}
|
{imsbaValues.map(v => <option key={v} value={v}>{v}</option>)}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
{cpmValues.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={cpmFilter || ''}
|
||||||
|
onChange={e => setCpmFilter(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 CPMs</option>
|
||||||
|
{cpmValues.map(v => <option key={v} value={v}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<select
|
<select
|
||||||
value={renewalFilter}
|
value={renewalFilter}
|
||||||
onChange={e => setRenewalFilter(e.target.value)}
|
onChange={e => setRenewalFilter(e.target.value)}
|
||||||
@@ -866,6 +886,7 @@ export default function AccountExplorer() {
|
|||||||
<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="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>
|
<span className="text-[10px] text-muted">{DISTRICT_SHORT[account.District_Name]}</span>
|
||||||
{account.AD && <span className="text-[10px] text-muted">| {account.AD}</span>}
|
{account.AD && <span className="text-[10px] text-muted">| {account.AD}</span>}
|
||||||
|
{account.CPM && <span className="text-[10px] text-muted">| CPM: {account.CPM}</span>}
|
||||||
{(account.Google_Drive_URL || account.Campaign_Artifacts_URL) && (
|
{(account.Google_Drive_URL || account.Campaign_Artifacts_URL) && (
|
||||||
<div className="flex items-center gap-1.5 ml-auto">
|
<div className="flex items-center gap-1.5 ml-auto">
|
||||||
{account.Google_Drive_URL && (
|
{account.Google_Drive_URL && (
|
||||||
|
|||||||
@@ -554,8 +554,8 @@ export default function ActivityDeepDive() {
|
|||||||
<div className="font-semibold">{overlayData.account.MAP_In_Place_YN === 'Y' ? 'Yes' : 'No'}</div>
|
<div className="font-semibold">{overlayData.account.MAP_In_Place_YN === 'Y' ? 'Yes' : 'No'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(overlayData.account.AD || overlayData.account.DM || overlayData.account.Area_Sales_Leader) && (
|
{(overlayData.account.AD || overlayData.account.DM || overlayData.account.Area_Sales_Leader || overlayData.account.CPM) && (
|
||||||
<div className="grid grid-cols-3 gap-3 mt-3 pt-3 border-t border-card-border text-xs">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3 pt-3 border-t border-card-border text-xs">
|
||||||
{overlayData.account.Area_Sales_Leader && (
|
{overlayData.account.Area_Sales_Leader && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted uppercase">ASL</div>
|
<div className="text-[10px] text-muted uppercase">ASL</div>
|
||||||
@@ -574,6 +574,12 @@ export default function ActivityDeepDive() {
|
|||||||
<div className="font-semibold">{overlayData.account.AD}</div>
|
<div className="font-semibold">{overlayData.account.AD}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{overlayData.account.CPM && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted uppercase">CPM</div>
|
||||||
|
<div className="font-semibold">{overlayData.account.CPM}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ const TABLE_FIELDS: Record<string, FieldDef[]> = {
|
|||||||
{ key: 'DM', label: 'DM' },
|
{ key: 'DM', label: 'DM' },
|
||||||
{ key: 'AD', label: 'AD' },
|
{ key: 'AD', label: 'AD' },
|
||||||
{ key: 'IMS_BA', label: 'IMS BA' },
|
{ key: 'IMS_BA', label: 'IMS BA' },
|
||||||
|
{ key: 'CPM', label: 'CPM' },
|
||||||
{ key: 'Next_Renewal_Date', label: 'Next Renewal Date', type: 'date' },
|
{ key: 'Next_Renewal_Date', label: 'Next Renewal Date', type: 'date' },
|
||||||
{ key: 'Next_Renewal_EAR', label: 'Next Renewal EAR', type: 'number' },
|
{ key: 'Next_Renewal_EAR', label: 'Next Renewal EAR', type: 'number' },
|
||||||
{ key: 'Anchor_Contract_Date', label: 'Anchor Contract Date', type: 'date' },
|
{ key: 'Anchor_Contract_Date', label: 'Anchor Contract Date', type: 'date' },
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function getDb(): Database.Database {
|
|||||||
initSchema(_db);
|
initSchema(_db);
|
||||||
migrateSchema(_db);
|
migrateSchema(_db);
|
||||||
seedDefaultPlaybooks();
|
seedDefaultPlaybooks();
|
||||||
|
seedCPMValues();
|
||||||
}
|
}
|
||||||
return _db;
|
return _db;
|
||||||
}
|
}
|
||||||
@@ -48,6 +49,9 @@ function migrateSchema(db: Database.Database) {
|
|||||||
if (!colNames.has('Campaign_Artifacts_URL')) {
|
if (!colNames.has('Campaign_Artifacts_URL')) {
|
||||||
db.exec("ALTER TABLE accounts ADD COLUMN Campaign_Artifacts_URL TEXT DEFAULT NULL");
|
db.exec("ALTER TABLE accounts ADD COLUMN Campaign_Artifacts_URL TEXT DEFAULT NULL");
|
||||||
}
|
}
|
||||||
|
if (!colNames.has('CPM')) {
|
||||||
|
db.exec("ALTER TABLE accounts ADD COLUMN CPM TEXT DEFAULT NULL");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initSchema(db: Database.Database) {
|
function initSchema(db: Database.Database) {
|
||||||
@@ -67,6 +71,8 @@ function initSchema(db: Database.Database) {
|
|||||||
Area_Sales_Leader TEXT,
|
Area_Sales_Leader TEXT,
|
||||||
DM TEXT,
|
DM TEXT,
|
||||||
AD TEXT,
|
AD TEXT,
|
||||||
|
IMS_BA TEXT,
|
||||||
|
CPM TEXT,
|
||||||
Logo_URL TEXT,
|
Logo_URL TEXT,
|
||||||
Next_Renewal_Date TEXT,
|
Next_Renewal_Date TEXT,
|
||||||
Next_Renewal_EAR REAL,
|
Next_Renewal_EAR REAL,
|
||||||
@@ -504,13 +510,13 @@ export function deleteTarget(accountName: string) {
|
|||||||
|
|
||||||
export function upsertAccount(record: Record<string, unknown>) {
|
export function upsertAccount(record: Record<string, unknown>) {
|
||||||
const db = getDb();
|
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)
|
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, CPM)
|
||||||
VALUES (@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, @CPM)
|
||||||
ON CONFLICT(Account_Name) DO UPDATE SET
|
ON CONFLICT(Account_Name) DO UPDATE SET
|
||||||
District_Name=excluded.District_Name, Tier=excluded.Tier, Priority=excluded.Priority,
|
District_Name=excluded.District_Name, Tier=excluded.Tier, Priority=excluded.Priority,
|
||||||
AgentMinder_Status=excluded.AgentMinder_Status, Current_ARR_USD=excluded.Current_ARR_USD,
|
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,
|
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
|
Area_Sales_Leader=excluded.Area_Sales_Leader, DM=excluded.DM, AD=excluded.AD, CPM=excluded.CPM
|
||||||
`).run({
|
`).run({
|
||||||
Account_Name: record.Account_Name || '',
|
Account_Name: record.Account_Name || '',
|
||||||
District_Name: record.District_Name || '',
|
District_Name: record.District_Name || '',
|
||||||
@@ -523,6 +529,7 @@ export function upsertAccount(record: Record<string, unknown>) {
|
|||||||
Area_Sales_Leader: record.Area_Sales_Leader ?? null,
|
Area_Sales_Leader: record.Area_Sales_Leader ?? null,
|
||||||
DM: record.DM ?? null,
|
DM: record.DM ?? null,
|
||||||
AD: record.AD ?? null,
|
AD: record.AD ?? null,
|
||||||
|
CPM: record.CPM ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,3 +809,19 @@ export function upsertTarget(record: Record<string, unknown>) {
|
|||||||
Risk_Notes: record.Risk_Notes || record.Notes || null,
|
Risk_Notes: record.Risk_Notes || record.Notes || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedCPMValues() {
|
||||||
|
const db = getDb();
|
||||||
|
const cpmAccounts = [
|
||||||
|
'ADT', 'ADVENTHEALTH', 'ARBITRATION FORUMS', 'CARLYLE GROUP', 'CARNIVAL',
|
||||||
|
'ENTERGY', 'Geographic Solutions', 'GUIDEWELL BLUE CROSS BLUE SHIELD OF FLORIDA',
|
||||||
|
'JABIL CIRCUIT', 'KASEYA', 'LEE MEMORIAL HEALTH SYSTEM', 'LUMEN',
|
||||||
|
'NEXTERA ENERGY', 'NORWEGIAN CRUISE LINE', 'OCHSNER HEALTH SYSTEM',
|
||||||
|
'ORLANDO HEALTH', 'PAYSPAN', 'PUBLIX SUPERMARKETS', 'RAYMOND JAMES FINANCIAL',
|
||||||
|
'TECH DATA',
|
||||||
|
];
|
||||||
|
const stmt = db.prepare("UPDATE accounts SET CPM = 'David Stefan' WHERE Account_Name = ? AND (CPM IS NULL OR CPM = '')");
|
||||||
|
for (const name of cpmAccounts) {
|
||||||
|
stmt.run(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ function generateAccounts(): AccountRecord[] {
|
|||||||
DM: null,
|
DM: null,
|
||||||
AD: null,
|
AD: null,
|
||||||
IMS_BA: null,
|
IMS_BA: null,
|
||||||
|
CPM: null,
|
||||||
Logo_URL: null,
|
Logo_URL: null,
|
||||||
Next_Renewal_Date: null,
|
Next_Renewal_Date: null,
|
||||||
Next_Renewal_EAR: null,
|
Next_Renewal_EAR: null,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface AccountRecord {
|
|||||||
DM: string | null;
|
DM: string | null;
|
||||||
AD: string | null;
|
AD: string | null;
|
||||||
IMS_BA: string | null;
|
IMS_BA: string | null;
|
||||||
|
CPM: string | null;
|
||||||
Logo_URL: string | null;
|
Logo_URL: string | null;
|
||||||
Next_Renewal_Date: string | null;
|
Next_Renewal_Date: string | null;
|
||||||
Next_Renewal_EAR: number | null;
|
Next_Renewal_EAR: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user