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>
149 lines
5.7 KiB
TypeScript
149 lines
5.7 KiB
TypeScript
'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>
|
|
);
|
|
}
|