Add lead detail modal on account page and show dates on lead cards
AccountLeads: clicking a lead now opens a full detail modal with executive summary, outreach hook, talking points, signals, contacts, renewal context, and action buttons. Leads list: show lead generation date in the top line of each card. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -213,7 +213,8 @@ export default function LeadsPage() {
|
|||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${PRIORITY_STYLES[lead.priority] || ''}`}>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${PRIORITY_STYLES[lead.priority] || ''}`}>
|
||||||
{lead.priority}
|
{lead.priority}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ml-auto ${STATUS_STYLES[lead.status] || ''}`}>
|
<span className="text-[10px] text-muted ml-auto">{new Date(lead.created_at).toLocaleDateString()}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${STATUS_STYLES[lead.status] || ''}`}>
|
||||||
{lead.status}
|
{lead.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ const URGENCY_COLORS: Record<string, string> = {
|
|||||||
low: '#6B7280',
|
low: '#6B7280',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PRIORITY_STYLES: Record<string, string> = {
|
||||||
|
priority: 'bg-red-100 text-red-800',
|
||||||
|
standard: 'bg-blue-100 text-blue-800',
|
||||||
|
low: 'bg-gray-100 text-gray-600',
|
||||||
|
};
|
||||||
|
|
||||||
const STATUS_STYLES: Record<string, string> = {
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
pending: 'bg-amber-100 text-amber-800',
|
pending: 'bg-amber-100 text-amber-800',
|
||||||
accepted: 'bg-green-100 text-green-800',
|
accepted: 'bg-green-100 text-green-800',
|
||||||
@@ -22,9 +28,177 @@ function api(action: string, body: Record<string, unknown> = {}) {
|
|||||||
return fetch('/api/phase2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...body }) }).then(r => r.json());
|
return fetch('/api/phase2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...body }) }).then(r => r.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LeadDetailModal({ lead, onClose, onAction }: { lead: Lead; onClose: () => void; onAction: (id: string, status: string, note?: string) => void }) {
|
||||||
|
const [actionNote, setActionNote] = useState('');
|
||||||
|
let signals: { source?: string; signal_type?: string; content?: string; published_at?: string; url?: string }[] = [];
|
||||||
|
let talkingPoints: string[] = [];
|
||||||
|
let contacts: { name?: string; title?: string; email?: string }[] = [];
|
||||||
|
let renewalCtx: { product?: string; renewalDate?: string; daysUntil?: number } | null = null;
|
||||||
|
try { signals = JSON.parse(lead.signals_json || '[]'); } catch { /* empty */ }
|
||||||
|
try { talkingPoints = JSON.parse(lead.talking_points_json || '[]'); } catch { /* empty */ }
|
||||||
|
try { contacts = JSON.parse(lead.target_contacts_json || '[]'); } catch { /* empty */ }
|
||||||
|
try { renewalCtx = lead.renewal_context_json ? JSON.parse(lead.renewal_context_json) : null; } catch { /* empty */ }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-y-auto"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 bg-white border-b border-card-border px-5 py-3 flex items-center gap-3 z-10">
|
||||||
|
<div className="w-1.5 h-8 rounded-full flex-shrink-0" style={{ backgroundColor: URGENCY_COLORS[lead.urgency_level] || '#6B7280' }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-brand-navy/10 text-brand-navy font-medium">{lead.capability_name}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${PRIORITY_STYLES[lead.priority] || ''}`}>{lead.priority}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${STATUS_STYLES[lead.status] || ''}`}>{lead.status}</span>
|
||||||
|
<span className="text-[10px] text-muted ml-auto">{new Date(lead.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-foreground">{lead.headline}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex-shrink-0">
|
||||||
|
<div className="text-lg font-bold text-brand-navy">{Math.round(lead.composite_score * 100)}</div>
|
||||||
|
<div className="text-[10px] text-muted">score</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="ml-2 p-1 rounded-lg hover:bg-gray-100 transition flex-shrink-0">
|
||||||
|
<svg className="w-4 h-4 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-foreground mb-1">Executive Summary</div>
|
||||||
|
<div className="text-xs text-muted leading-relaxed">{lead.executive_summary}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lead.outreach_hook && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-foreground mb-1">Outreach Hook</div>
|
||||||
|
<div className="text-xs text-brand-azure italic">“{lead.outreach_hook}”</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{talkingPoints.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-foreground mb-1">Talking Points</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{talkingPoints.map((tp, i) => (
|
||||||
|
<li key={i} className="text-xs text-muted flex gap-2">
|
||||||
|
<span className="text-brand-azure flex-shrink-0">•</span>
|
||||||
|
{tp}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{signals.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-foreground mb-1">Signals ({signals.length})</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{signals.map((s, i) => (
|
||||||
|
<div key={i} className="text-xs bg-gray-50 rounded border border-card-border p-2">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="font-medium text-foreground">{s.source || 'Unknown'}</span>
|
||||||
|
<span className="text-[10px] px-1 py-0.5 rounded bg-gray-100 text-gray-500">{s.signal_type}</span>
|
||||||
|
{s.published_at && <span className="text-[10px] text-muted ml-auto">{new Date(s.published_at).toLocaleDateString()}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted line-clamp-3">{s.content}</div>
|
||||||
|
{s.url && (
|
||||||
|
<a href={s.url} target="_blank" rel="noopener noreferrer" className="text-[10px] text-brand-azure hover:underline mt-0.5 inline-block">Source →</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contacts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-foreground mb-1">Target Contacts</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{contacts.map((c, i) => (
|
||||||
|
<div key={i} className="text-xs bg-gray-50 rounded border border-card-border px-2 py-1">
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
{c.title && <span className="text-muted"> — {c.title}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renewalCtx && (
|
||||||
|
<div className="flex items-center gap-2 text-xs bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||||
|
<span>💰</span>
|
||||||
|
<span className="font-medium text-amber-800">
|
||||||
|
Renewal Window: {renewalCtx.product} — {renewalCtx.daysUntil} days ({renewalCtx.renewalDate})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-[10px] text-muted flex gap-4">
|
||||||
|
<span>Confidence: {Math.round(lead.confidence_score * 100)}%</span>
|
||||||
|
<span>Urgency: {lead.urgency_level}</span>
|
||||||
|
{lead.feedback_at && <span>Last action: {new Date(lead.feedback_at).toLocaleDateString()}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lead.status === 'pending' && (
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-card-border">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Note (optional)..."
|
||||||
|
value={actionNote}
|
||||||
|
onChange={e => setActionNote(e.target.value)}
|
||||||
|
className="flex-1 text-xs border border-card-border rounded px-2 py-1.5"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => { onAction(lead.id, 'accepted', actionNote); onClose(); }}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded hover:bg-green-700 transition"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { onAction(lead.id, 'snoozed', actionNote); onClose(); }}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium bg-purple-600 text-white rounded hover:bg-purple-700 transition"
|
||||||
|
>
|
||||||
|
Snooze
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { onAction(lead.id, 'rejected', actionNote); onClose(); }}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium bg-gray-400 text-white rounded hover:bg-gray-500 transition"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lead.status === 'accepted' && (
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-card-border">
|
||||||
|
<span className="text-xs text-muted">Outcome:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { onAction(lead.id, 'won'); onClose(); }}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium bg-emerald-600 text-white rounded hover:bg-emerald-700 transition"
|
||||||
|
>
|
||||||
|
Won
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { onAction(lead.id, 'lost'); onClose(); }}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition"
|
||||||
|
>
|
||||||
|
Lost
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AccountLeads({ accountName }: { accountName: string }) {
|
export function AccountLeads({ accountName }: { accountName: string }) {
|
||||||
const [leads, setLeads] = useState<Lead[]>([]);
|
const [leads, setLeads] = useState<Lead[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
|
||||||
|
|
||||||
const fetchLeads = useCallback(async () => {
|
const fetchLeads = useCallback(async () => {
|
||||||
const res = await api('leads.list', { Account_Name: accountName, limit: 20 });
|
const res = await api('leads.list', { Account_Name: accountName, limit: 20 });
|
||||||
@@ -35,8 +209,9 @@ export function AccountLeads({ accountName }: { accountName: string }) {
|
|||||||
fetchLeads().finally(() => setLoading(false));
|
fetchLeads().finally(() => setLoading(false));
|
||||||
}, [fetchLeads]);
|
}, [fetchLeads]);
|
||||||
|
|
||||||
async function handleAction(id: string, status: string) {
|
async function handleAction(id: string, status: string, note?: string) {
|
||||||
await api('leads.action', { id, status });
|
await api('leads.action', { id, status, note: note || undefined });
|
||||||
|
setSelectedLead(null);
|
||||||
await fetchLeads();
|
await fetchLeads();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,20 +219,26 @@ export function AccountLeads({ accountName }: { accountName: string }) {
|
|||||||
if (leads.length === 0) return <div className="text-xs text-muted py-4">No leads found for this account.</div>;
|
if (leads.length === 0) return <div className="text-xs text-muted py-4">No leads found for this account.</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{leads.map(lead => {
|
{leads.map(lead => {
|
||||||
let talkingPoints: string[] = [];
|
let talkingPoints: string[] = [];
|
||||||
try { talkingPoints = JSON.parse(lead.talking_points_json || '[]'); } catch { /* empty */ }
|
try { talkingPoints = JSON.parse(lead.talking_points_json || '[]'); } catch { /* empty */ }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={lead.id} className="bg-white rounded-lg border border-card-border p-3">
|
<div
|
||||||
|
key={lead.id}
|
||||||
|
className="bg-white rounded-lg border border-card-border p-3 cursor-pointer hover:border-brand-azure/40 hover:shadow-sm transition"
|
||||||
|
onClick={() => setSelectedLead(lead)}
|
||||||
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="w-1 self-stretch rounded-full flex-shrink-0" style={{ backgroundColor: URGENCY_COLORS[lead.urgency_level] || '#6B7280' }} />
|
<div className="w-1 self-stretch rounded-full flex-shrink-0" style={{ backgroundColor: URGENCY_COLORS[lead.urgency_level] || '#6B7280' }} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-brand-navy/10 text-brand-navy font-medium">{lead.capability_name}</span>
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-brand-navy/10 text-brand-navy font-medium">{lead.capability_name}</span>
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${STATUS_STYLES[lead.status] || ''}`}>{lead.status}</span>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${STATUS_STYLES[lead.status] || ''}`}>{lead.status}</span>
|
||||||
<span className="text-xs font-bold text-brand-navy ml-auto">{Math.round(lead.composite_score * 100)}</span>
|
<span className="text-[10px] text-muted ml-auto">{new Date(lead.created_at).toLocaleDateString()}</span>
|
||||||
|
<span className="text-xs font-bold text-brand-navy">{Math.round(lead.composite_score * 100)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs font-medium text-foreground mb-0.5">{lead.headline}</div>
|
<div className="text-xs font-medium text-foreground mb-0.5">{lead.headline}</div>
|
||||||
<div className="text-[11px] text-muted line-clamp-2 mb-1">{lead.executive_summary}</div>
|
<div className="text-[11px] text-muted line-clamp-2 mb-1">{lead.executive_summary}</div>
|
||||||
@@ -72,19 +253,19 @@ export function AccountLeads({ accountName }: { accountName: string }) {
|
|||||||
{lead.status === 'pending' && (
|
{lead.status === 'pending' && (
|
||||||
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-card-border/50">
|
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-card-border/50">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction(lead.id, 'accepted')}
|
onClick={e => { e.stopPropagation(); handleAction(lead.id, 'accepted'); }}
|
||||||
className="px-2 py-1 text-[10px] font-medium bg-green-600 text-white rounded hover:bg-green-700 transition"
|
className="px-2 py-1 text-[10px] font-medium bg-green-600 text-white rounded hover:bg-green-700 transition"
|
||||||
>
|
>
|
||||||
Accept
|
Accept
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction(lead.id, 'snoozed')}
|
onClick={e => { e.stopPropagation(); handleAction(lead.id, 'snoozed'); }}
|
||||||
className="px-2 py-1 text-[10px] font-medium bg-purple-600 text-white rounded hover:bg-purple-700 transition"
|
className="px-2 py-1 text-[10px] font-medium bg-purple-600 text-white rounded hover:bg-purple-700 transition"
|
||||||
>
|
>
|
||||||
Snooze
|
Snooze
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction(lead.id, 'rejected')}
|
onClick={e => { e.stopPropagation(); handleAction(lead.id, 'rejected'); }}
|
||||||
className="px-2 py-1 text-[10px] font-medium bg-gray-400 text-white rounded hover:bg-gray-500 transition"
|
className="px-2 py-1 text-[10px] font-medium bg-gray-400 text-white rounded hover:bg-gray-500 transition"
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
@@ -95,5 +276,14 @@ export function AccountLeads({ accountName }: { accountName: string }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedLead && (
|
||||||
|
<LeadDetailModal
|
||||||
|
lead={selectedLead}
|
||||||
|
onClose={() => setSelectedLead(null)}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user