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:
2026-08-31 18:07:46 -04:00
parent 3c8c8ee594
commit d2d5632086
28 changed files with 3541 additions and 25 deletions

View File

@@ -0,0 +1,244 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
const ROLES = ['Economic Buyer', 'Champion', 'Technical Evaluator', 'End User', 'Procurement', 'Executive Sponsor'] as const;
const SENTIMENTS = ['Champion', 'Supportive', 'Neutral', 'Skeptical', 'Blocker'] as const;
type Role = (typeof ROLES)[number];
type Sentiment = (typeof SENTIMENTS)[number];
interface Contact {
id: number;
Account_Name: string;
Contact_Name: string;
Role: Role;
Sentiment: Sentiment;
Email?: string;
LinkedIn_URL?: string;
}
interface ContactMapProps {
accountName: string;
}
const SENTIMENT_COLORS: Record<Sentiment, string> = {
Champion: 'bg-emerald-500',
Supportive: 'bg-emerald-400',
Neutral: 'bg-yellow-400',
Skeptical: 'bg-red-400',
Blocker: 'bg-red-600',
};
const ROLE_BADGE_CLASSES = 'inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#1B1D36]/10 text-[#1B1D36] dark:bg-[#0098C7]/15 dark:text-[#0098C7]';
const emptyForm = { Contact_Name: '', Role: 'Technical Evaluator' as Role, Sentiment: 'Neutral' as Sentiment, Email: '', LinkedIn_URL: '' };
export function ContactMap({ accountName }: ContactMapProps) {
const [contacts, setContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState(emptyForm);
const [submitting, setSubmitting] = useState(false);
const fetchContacts = useCallback(async () => {
try {
const res = await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'contacts.list', Account_Name: accountName }),
});
const json = await res.json();
setContacts(json.data ?? []);
} catch {
console.error('Failed to fetch contacts');
} finally {
setLoading(false);
}
}, [accountName]);
useEffect(() => {
fetchContacts();
}, [fetchContacts]);
const resetForm = () => {
setForm(emptyForm);
setShowForm(false);
setEditingId(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.Contact_Name.trim()) return;
setSubmitting(true);
try {
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'contacts.upsert',
Account_Name: accountName,
Contact_Name: form.Contact_Name.trim(),
Role: form.Role,
Sentiment: form.Sentiment,
Email: form.Email.trim() || undefined,
LinkedIn_URL: form.LinkedIn_URL.trim() || undefined,
...(editingId != null ? { id: editingId } : {}),
}),
});
resetForm();
await fetchContacts();
} catch {
console.error('Failed to save contact');
} finally {
setSubmitting(false);
}
};
const handleEdit = (contact: Contact) => {
setEditingId(contact.id);
setForm({
Contact_Name: contact.Contact_Name,
Role: contact.Role,
Sentiment: contact.Sentiment,
Email: contact.Email ?? '',
LinkedIn_URL: contact.LinkedIn_URL ?? '',
});
setShowForm(true);
};
const handleDelete = async (id: number) => {
try {
await fetch('/api/phase2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'contacts.delete', id }),
});
await fetchContacts();
} catch {
console.error('Failed to delete contact');
}
};
const selectClasses = 'w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#0098C7]';
const inputClasses = selectClasses;
return (
<div className="bg-card-bg rounded-xl border border-card-border p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground">Stakeholder Map</h3>
{!showForm && (
<button
onClick={() => { resetForm(); setShowForm(true); }}
className="px-2.5 py-1 text-xs font-medium text-white bg-[#0098C7] rounded-lg hover:bg-[#0098C7]/90 transition-colors"
>
Add Contact
</button>
)}
</div>
{/* Inline Form */}
{showForm && (
<form onSubmit={handleSubmit} className="mb-4 space-y-2 rounded-lg border border-card-border p-3 bg-background">
<input
type="text"
value={form.Contact_Name}
onChange={(e) => setForm({ ...form, Contact_Name: e.target.value })}
placeholder="Contact name"
className={inputClasses}
/>
<div className="grid grid-cols-2 gap-2">
<select value={form.Role} onChange={(e) => setForm({ ...form, Role: e.target.value as Role })} className={selectClasses}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<select value={form.Sentiment} onChange={(e) => setForm({ ...form, Sentiment: e.target.value as Sentiment })} className={selectClasses}>
{SENTIMENTS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<input
type="email"
value={form.Email}
onChange={(e) => setForm({ ...form, Email: e.target.value })}
placeholder="Email (optional)"
className={inputClasses}
/>
<input
type="url"
value={form.LinkedIn_URL}
onChange={(e) => setForm({ ...form, LinkedIn_URL: e.target.value })}
placeholder="LinkedIn URL (optional)"
className={inputClasses}
/>
<div className="flex gap-2 justify-end">
<button type="button" onClick={resetForm} className="px-2 py-1 text-xs text-muted hover:text-foreground transition-colors">Cancel</button>
<button
type="submit"
disabled={submitting || !form.Contact_Name.trim()}
className="px-3 py-1.5 text-xs font-medium text-white bg-[#0098C7] rounded-lg hover:bg-[#0098C7]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? 'Saving...' : editingId != null ? 'Update' : 'Add'}
</button>
</div>
</form>
)}
{/* Contacts List */}
{loading ? (
<p className="text-xs text-muted">Loading contacts...</p>
) : contacts.length === 0 ? (
<p className="text-xs text-muted">No stakeholders added yet.</p>
) : (
<div className="space-y-2">
{contacts.map((contact) => (
<div key={contact.id} className="rounded-lg border border-card-border p-3 bg-background">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${SENTIMENT_COLORS[contact.Sentiment]}`} title={contact.Sentiment} />
<span className="text-sm font-medium text-foreground truncate">{contact.Contact_Name}</span>
<span className={ROLE_BADGE_CLASSES}>{contact.Role}</span>
</div>
<div className="flex items-center gap-3 ml-4">
{contact.Email && (
<a href={`mailto:${contact.Email}`} className="text-xs text-[#0098C7] hover:underline truncate">{contact.Email}</a>
)}
{contact.LinkedIn_URL && (
<a href={contact.LinkedIn_URL} target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors" title="LinkedIn">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
</a>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => handleEdit(contact)}
title="Edit"
className="p-1 rounded hover:bg-card-bg text-muted hover:text-foreground transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => handleDelete(contact.id)}
title="Delete"
className="p-1 rounded hover:bg-card-bg text-muted hover:text-danger transition-colors"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6m5 0V4a1 1 0 011-1h2a1 1 0 011 1v2" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}