// Lead detail — conversation + Tommy draft + stage controls, wired to /leads/{id} // and the tommy approve/reject/coach endpoints. const STAGE_OPTIONS = [ 'NEW LEAD', 'FIRST TOUCH', 'INTERESTED', 'FOLLOW UP', 'DAY PASS SCHEDULED', 'NEEDS_RESCHEDULE', 'SHOWED', 'JOINED', 'MEMBER', 'DEAD', 'NO SHOW', ]; function LeadDetailView({ contactId, onNavigate, user }) { const [lead, setLead] = React.useState(null); const [approval, setApproval] = React.useState(null); const [loading, setLoading] = React.useState(true); const [draftText, setDraftText] = React.useState(''); const [typed, setTyped] = React.useState(''); const [sending, setSending] = React.useState(false); const [stageEdit, setStageEdit] = React.useState(false); const scrollRef = React.useRef(null); async function reload() { setLoading(true); try { const [l, appr] = await Promise.all([ api.get(`/api/v2/leads/${encodeURIComponent(contactId)}`), api.get('/api/v2/tommy/pending-approvals'), ]); const adapted = adaptLead(l); adapted.messages = l.messages || []; setLead(adapted); cacheContacts([adapted]); const mine = (appr?.approvals || []).find(a => a.contact_id === contactId); setApproval(mine || null); setDraftText(mine?.tommy_draft || ''); // Mark this thread read. api.post('/api/v2/tommy/mark-read', { contact_id: contactId }).catch(() => {}); } catch (e) { setLead(null); } finally { setLoading(false); } } React.useEffect(() => { reload(); /* eslint-disable-next-line */ }, [contactId]); React.useEffect(() => { function onEv(e) { const d = e.detail || {}; if (!d) return; if (d.contact_id === contactId || d.event_type === 'tommy_sent' || d.event_type === 'sms_inbound') { reload(); } } window.addEventListener('raw:event', onEv); return () => window.removeEventListener('raw:event', onEv); // eslint-disable-next-line react-hooks/exhaustive-deps }, [contactId]); React.useEffect(() => { scrollRef.current?.scrollTo({top: scrollRef.current.scrollHeight}); }, [lead?.messages?.length, approval?.id]); async function approve() { if (!approval) return; try { await api.post('/api/v2/tommy/approve', { approval_id: approval.id, message: draftText }); await reload(); } catch (e) { if (isQuietHoursError(e) && confirm(quietHoursPrompt(e, 'approve and send'))) { try { await api.post('/api/v2/tommy/approve', { approval_id: approval.id, message: draftText, override_quiet_hours: true, }); await reload(); return; } catch (e2) { alert(friendlyApiError(e2, 'Approve failed')); return; } } alert(friendlyApiError(e, 'Approve failed')); } } async function reject() { if (!approval) return; try { await api.post('/api/v2/tommy/reject', { approval_id: approval.id }); await reload(); } catch (e) { alert(friendlyApiError(e, 'Reject failed')); } } async function coach() { if (!approval) return; const note = prompt('What extra context should Tommy use?'); if (!note) return; try { await api.post('/api/v2/tommy/coach', { approval_id: approval.id, context_note: note }); await reload(); } catch (e) { alert(friendlyApiError(e, 'Coach failed')); } } async function dismiss() { if (!approval) return; try { await api.post('/api/v2/tommy/dismiss', { approval_id: approval.id }); await reload(); } catch (e) { alert(friendlyApiError(e, 'Dismiss failed')); } } async function sendManual() { if (!typed.trim()) return; setSending(true); const text = typed.trim(); try { await api.post(`/api/v2/leads/${encodeURIComponent(contactId)}/message`, { message: text }); setTyped(''); await reload(); } catch (e) { if (isQuietHoursError(e) && confirm(quietHoursPrompt(e, 'send this SMS'))) { try { await api.post(`/api/v2/leads/${encodeURIComponent(contactId)}/message`, { message: text, override_quiet_hours: true, }); setTyped(''); await reload(); } catch (e2) { alert(friendlyApiError(e2, 'Send failed')); } } else { alert(friendlyApiError(e, 'Send failed')); } } finally { setSending(false); } } async function promptTommy() { try { await api.post(`/api/v2/leads/${encodeURIComponent(contactId)}/prompt-tommy`, {}); setTimeout(reload, 800); } catch (e) { alert(friendlyApiError(e, 'Prompt failed')); } } async function changeStage(next) { try { await api.patch(`/api/v2/leads/${encodeURIComponent(contactId)}/stage`, { stage: next }); setStageEdit(false); await reload(); } catch (e) { alert(friendlyApiError(e, 'Stage change failed')); } } async function toggleHot() { if (!lead) return; const next = !lead.hot; try { await api.patch(`/api/v2/leads/${encodeURIComponent(contactId)}`, { hot: next }); await reload(); } catch (e) { alert(friendlyApiError(e, 'Failed to toggle hot')); } } if (loading && !lead) { return
Loading…
; } if (!lead) { return
Lead not found.
; } const c = lead; const msgs = (c.messages || []).map(m => { const from = m.direction === 'inbound' ? 'them' : 'tommy'; // sender_label comes from the backend (derived from GHL userId/source // when available, falls back to the [MANUAL] prefix for db rows). // Default to "Tommy · AI" so older API responses still render. const senderLabel = m.sender_label || 'Tommy · AI'; return { from, body: m.message, sent_at: m.sent_at, sender_label: senderLabel }; }); const stageRaw = c.stage_raw || c.stage; return (
Contact · {c.id} {c.is_member && ( member )}
{/* conversation pane */}
{initialsOf(c.name)}
{c.name}
{c.phone || '—'}{c.source ? ' · via ' + c.source : ''}
setStageEdit(v => !v)} style={{cursor:'pointer'}} >{stageRaw} {stageRaw === 'NEEDS_RESCHEDULE' && c.needs_reschedule_entered_at && ( Waiting {daysSince(c.needs_reschedule_entered_at)}d )} {stageEdit && (
{STAGE_OPTIONS.map(s => (
changeStage(s)} style={{padding:'7px 12px', fontSize:13, cursor:'pointer', borderRadius:4, background: s === stageRaw ? 'var(--bone-2)' : 'transparent'}}> {s.toLowerCase()}
))}
)}
{msgs.length === 0 &&
No messages yet.
} {msgs.map((m, i) => (
{m.from !== 'them' && (
{m.sender_label}
)}
{m.body}
{fmtAgoFrom(m.sent_at)}
))}
{approval && (
— Tommy draft needs approval {approval.hold_reason || approval.status}