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 [priorityFilter, setPriorityFilter] = 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 [showCreateOpp, setShowCreateOpp] = 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 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 now = new Date();
|
||||
@@ -190,6 +192,7 @@ export default function AccountExplorer() {
|
||||
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 (cpmFilter) result = result.filter(a => a.CPM === cpmFilter);
|
||||
if (priorityFilter) result = result.filter(a => a.Priority === priorityFilter);
|
||||
if (competitorFilter) result = result.filter(a => {
|
||||
const comps = (a.Competitors || '').split(',').map(c => c.trim()).filter(Boolean);
|
||||
@@ -213,7 +216,7 @@ export default function AccountExplorer() {
|
||||
}
|
||||
}
|
||||
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 acctNames = new Set(filteredAccounts.map(a => a.Account_Name));
|
||||
@@ -292,11 +295,12 @@ export default function AccountExplorer() {
|
||||
setImsbaFilter(null);
|
||||
setPriorityFilter(null);
|
||||
setCompetitorFilter(null);
|
||||
setCpmFilter(null);
|
||||
setRenewalFilter('all');
|
||||
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) {
|
||||
const suggestion = getSuggestedPriority(selectedAccount);
|
||||
@@ -395,8 +399,8 @@ export default function AccountExplorer() {
|
||||
</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.AD || selectedAccount.IMS_BA || selectedAccount.CPM || selectedAccount.Area_Sales_Leader || selectedAccount.DM) && (
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
{selectedAccount.CPM && (
|
||||
<div>
|
||||
<div className="text-[10px] text-muted uppercase">CPM</div>
|
||||
<div className="text-sm font-semibold">{selectedAccount.CPM}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -815,6 +825,16 @@ export default function AccountExplorer() {
|
||||
{imsbaValues.map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</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
|
||||
value={renewalFilter}
|
||||
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="text-[10px] text-muted">{DISTRICT_SHORT[account.District_Name]}</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) && (
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
{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>
|
||||
</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.AD || overlayData.account.DM || overlayData.account.Area_Sales_Leader || overlayData.account.CPM) && (
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
{overlayData.account.CPM && (
|
||||
<div>
|
||||
<div className="text-[10px] text-muted uppercase">CPM</div>
|
||||
<div className="font-semibold">{overlayData.account.CPM}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -270,6 +270,7 @@ const TABLE_FIELDS: Record<string, FieldDef[]> = {
|
||||
{ key: 'DM', label: 'DM' },
|
||||
{ key: 'AD', label: 'AD' },
|
||||
{ key: 'IMS_BA', label: 'IMS BA' },
|
||||
{ key: 'CPM', label: 'CPM' },
|
||||
{ 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' },
|
||||
|
||||
@@ -32,6 +32,7 @@ function getDb(): Database.Database {
|
||||
initSchema(_db);
|
||||
migrateSchema(_db);
|
||||
seedDefaultPlaybooks();
|
||||
seedCPMValues();
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
@@ -48,6 +49,9 @@ function migrateSchema(db: Database.Database) {
|
||||
if (!colNames.has('Campaign_Artifacts_URL')) {
|
||||
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) {
|
||||
@@ -67,6 +71,8 @@ function initSchema(db: Database.Database) {
|
||||
Area_Sales_Leader TEXT,
|
||||
DM TEXT,
|
||||
AD TEXT,
|
||||
IMS_BA TEXT,
|
||||
CPM TEXT,
|
||||
Logo_URL TEXT,
|
||||
Next_Renewal_Date TEXT,
|
||||
Next_Renewal_EAR REAL,
|
||||
@@ -504,13 +510,13 @@ export function deleteTarget(accountName: string) {
|
||||
|
||||
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)
|
||||
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, @CPM)
|
||||
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
|
||||
Area_Sales_Leader=excluded.Area_Sales_Leader, DM=excluded.DM, AD=excluded.AD, CPM=excluded.CPM
|
||||
`).run({
|
||||
Account_Name: record.Account_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,
|
||||
DM: record.DM ?? 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,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
AD: null,
|
||||
IMS_BA: null,
|
||||
CPM: null,
|
||||
Logo_URL: null,
|
||||
Next_Renewal_Date: null,
|
||||
Next_Renewal_EAR: null,
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface AccountRecord {
|
||||
DM: string | null;
|
||||
AD: string | null;
|
||||
IMS_BA: string | null;
|
||||
CPM: string | null;
|
||||
Logo_URL: string | null;
|
||||
Next_Renewal_Date: string | null;
|
||||
Next_Renewal_EAR: number | null;
|
||||
|
||||
Reference in New Issue
Block a user