Implement Phase 2 features for Campaign Command Center
Adds 14 new features across the dashboard: - Deal Risk Scoring (0-100) with risk factors on pipeline deals - Account Health Score with multi-factor analysis on account cards - Next Best Action Engine on Executive Overview - Account Notes with pin/edit/delete in activity overlay and account detail - Stakeholder Contact Map with role, sentiment, email, LinkedIn - Competitive Intelligence Tracker with multi-select competitors and filtering - CSV/Excel Export on accounts, activities, and pipeline pages - Data Quality Dashboard in admin with completeness scoring - Campaign Playbook Templates with 5 default plays and account progress tracking - Goals & District Targets with quarterly tracking and progress bars - Weekly Status Report (printable) and Executive Summary (printable) - Activity Effectiveness Scoring showing pipeline conversion by activity type - Mobile Activity FAB for quick activity logging on mobile - Multi-User Auth (NextAuth + Google OAuth, @broadcom.com domain, role-based) Also adds Phase 2 API route, scoring utilities, sidebar navigation updates, and database schema extensions for notes, contacts, snapshots, playbooks, and district targets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
148
src/components/ui/NextBestAction.tsx
Normal file
148
src/components/ui/NextBestAction.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { AccountRecord, ActivityRecord, PipelineRecord } from '@/types/data';
|
||||
import { differenceInDays, parseISO, format } from 'date-fns';
|
||||
|
||||
interface ActionItem {
|
||||
priority: 'urgent' | 'high' | 'medium';
|
||||
account: string;
|
||||
message: string;
|
||||
detail: string;
|
||||
type: 'stale' | 'renewal' | 'pipeline_gap' | 'deal_risk' | 'untouched';
|
||||
}
|
||||
|
||||
const PRIORITY_STYLES = {
|
||||
urgent: 'border-l-red-500 bg-red-50',
|
||||
high: 'border-l-amber-500 bg-amber-50',
|
||||
medium: 'border-l-blue-500 bg-blue-50',
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS = {
|
||||
urgent: { text: 'Urgent', color: 'text-red-600 bg-red-100' },
|
||||
high: { text: 'Action Needed', color: 'text-amber-700 bg-amber-100' },
|
||||
medium: { text: 'Consider', color: 'text-blue-600 bg-blue-100' },
|
||||
};
|
||||
|
||||
export function NextBestAction({ accounts, activities, pipeline, limit = 8 }: {
|
||||
accounts: AccountRecord[];
|
||||
activities: ActivityRecord[];
|
||||
pipeline: PipelineRecord[];
|
||||
limit?: number;
|
||||
}) {
|
||||
const actions = useMemo(() => {
|
||||
const items: ActionItem[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (const acct of accounts) {
|
||||
if (acct.Priority !== 'High' && acct.Priority !== 'Medium') continue;
|
||||
|
||||
const acctActivities = activities.filter(a => a.Account_Name === acct.Account_Name);
|
||||
const acctPipeline = pipeline.filter(p => p.Account_Name === acct.Account_Name && p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
|
||||
|
||||
// Stale high-priority accounts
|
||||
if (acct.Priority === 'High') {
|
||||
if (acctActivities.length === 0) {
|
||||
items.push({
|
||||
priority: 'urgent',
|
||||
account: acct.Account_Name,
|
||||
message: 'High-priority account with zero touches',
|
||||
detail: `${acct.Tier} account, ${acct.Current_ARR_USD ? `$${Math.round(acct.Current_ARR_USD / 1000)}K ARR` : 'no ARR recorded'}`,
|
||||
type: 'untouched',
|
||||
});
|
||||
} else {
|
||||
const lastDate = acctActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
|
||||
const daysSince = differenceInDays(now, parseISO(lastDate.Activity_Date));
|
||||
if (daysSince > 30) {
|
||||
items.push({
|
||||
priority: daysSince > 60 ? 'urgent' : 'high',
|
||||
account: acct.Account_Name,
|
||||
message: `No activity in ${daysSince} days`,
|
||||
detail: `Last touch: ${format(parseISO(lastDate.Activity_Date), 'MMM d')} (${lastDate.Activity_Type})`,
|
||||
type: 'stale',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Approaching renewals with no play
|
||||
if (acct.Next_Renewal_Date) {
|
||||
const daysToRenewal = differenceInDays(parseISO(acct.Next_Renewal_Date), now);
|
||||
if (daysToRenewal > 0 && daysToRenewal <= 120 && acctPipeline.length === 0) {
|
||||
items.push({
|
||||
priority: daysToRenewal <= 60 ? 'urgent' : 'high',
|
||||
account: acct.Account_Name,
|
||||
message: `Renewal in ${daysToRenewal} days, no pipeline`,
|
||||
detail: `${acct.Next_Renewal_EAR ? `$${Math.round(acct.Next_Renewal_EAR / 1000)}K EAR` : 'EAR not set'} — start attach/renewal play`,
|
||||
type: 'renewal',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// High-priority with no pipeline
|
||||
if (acct.Priority === 'High' && acctPipeline.length === 0 && acctActivities.length > 0) {
|
||||
const lastDate = acctActivities.sort((a, b) => b.Activity_Date.localeCompare(a.Activity_Date))[0];
|
||||
const daysSince = differenceInDays(now, parseISO(lastDate.Activity_Date));
|
||||
if (daysSince <= 30) {
|
||||
items.push({
|
||||
priority: 'medium',
|
||||
account: acct.Account_Name,
|
||||
message: 'Active engagement but no pipeline',
|
||||
detail: `${acctActivities.length} touches — consider creating an opportunity`,
|
||||
type: 'pipeline_gap',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deal risks — overdue close dates
|
||||
const openDeals = pipeline.filter(p => p.Stage !== '06-Closed Won' && p.Stage !== '07-Closed Lost');
|
||||
for (const deal of openDeals) {
|
||||
if (deal.Expected_Close_Date) {
|
||||
const daysOverdue = differenceInDays(now, parseISO(deal.Expected_Close_Date));
|
||||
if (daysOverdue > 14) {
|
||||
items.push({
|
||||
priority: daysOverdue > 30 ? 'urgent' : 'high',
|
||||
account: deal.Account_Name,
|
||||
message: `Deal close date ${daysOverdue} days overdue`,
|
||||
detail: `$${Math.round(deal.Amount_USD / 1000)}K — update close date or advance stage`,
|
||||
type: 'deal_risk',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
const order = { urgent: 0, high: 1, medium: 2 };
|
||||
return items.sort((a, b) => order[a.priority] - order[b.priority]).slice(0, limit);
|
||||
}, [accounts, activities, pipeline, limit]);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6 text-sm text-muted">
|
||||
No immediate actions needed — territory is well-covered.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{actions.map((action, i) => (
|
||||
<div key={i} className={`border-l-3 rounded-lg p-3 ${PRIORITY_STYLES[action.priority]}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-xs font-bold text-foreground truncate">{action.account}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold ${PRIORITY_LABELS[action.priority].color}`}>
|
||||
{PRIORITY_LABELS[action.priority].text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-foreground">{action.message}</div>
|
||||
<div className="text-[11px] text-muted mt-0.5">{action.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user