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:
244
src/components/account/ContactMap.tsx
Normal file
244
src/components/account/ContactMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user