// AGENTFORGE-IA — CRM completo (resumen + leads + cuentas + pipeline + actividad + automatizaciones) const CRM = () => { const { t, fmt, lang } = useI18n(); const D = window.AF_CRM; const notify = (msg) => window.afNotify && window.afNotify(msg); const fileRef = React.useRef(null); const [contacts, setContacts] = React.useState([]); const [leadProfilesById, setLeadProfilesById] = React.useState({}); const [timelineById, setTimelineById] = React.useState({}); const [loading, setLoading] = React.useState(true); const [q, setQ] = React.useState(''); const [type, setType] = React.useState('todos'); const [stage, setStage] = React.useState('todas'); const [view, setView] = React.useState('overview'); const [open, setOpen] = React.useState(null); const [showCreate, setShowCreate] = React.useState(false); const [createSaving, setCreateSaving] = React.useState(false); const [createError, setCreateError] = React.useState(''); const [draft, setDraft] = React.useState({ name: '', email: '', company: '', title: '', phone: '', type: 'lead', stage: 'lead', country: 'España', ownerName: 'Admin', }); const getToken = () => sessionStorage.getItem('af_token') || ''; const loadContacts = React.useCallback(async () => { setLoading(true); try { const res = await fetch('/api/v1/crm/contacts', { headers: { Authorization: 'Bearer ' + getToken() } }); if (!res.ok) throw new Error('crm'); const data = await res.json(); const mapped = data.map((c) => ({ ...c, type: c.contact_type, initials: c.name.trim().split(/\s+/).map((x) => x[0]).join('').slice(0, 2).toUpperCase(), mrr: (c.mrr_cents || 0) / 100, arr: (c.arr_cents || 0) / 100, lastContactH: c.last_contact_at ? Math.round((Date.now() - new Date(c.last_contact_at).getTime()) / 3600000) : 999, health: Number(c.health || 75), sentiment: Number(c.sentiment || 0.7), ownerName: c.owner_name || 'Admin', owner: (c.owner_name || 'Admin').split(' ').map((x) => x[0]).join('').slice(0, 2).toUpperCase(), assignedAgent: c.assigned_agent || (D.agentNames && D.agentNames[0]) || 'Forge Agent', nextAction: c.next_action || 'Seguimiento manual', })); setContacts(mapped); try { const lr = await fetch('/api/v1/leads/items', { headers: { Authorization: 'Bearer ' + getToken() } }); if (lr.ok) { const leadsItems = await lr.json(); const byId = {}; for (const item of (Array.isArray(leadsItems) ? leadsItems : [])) { byId[item.id] = item; } setLeadProfilesById(byId); } else { setLeadProfilesById({}); } } catch (_) { setLeadProfilesById({}); } const timelineEntries = await Promise.all( mapped.slice(0, 40).map(async (c) => { try { const tr = await fetch(`/api/v1/crm/contacts/${c.id}/timeline`, { headers: { Authorization: 'Bearer ' + getToken() } }); if (!tr.ok) return [c.id, []]; const ev = await tr.json(); return [c.id, ev.map((x) => ({ ...x, t: new Date(x.created_at).getTime() }))]; } catch (_) { return [c.id, []]; } }) ); setTimelineById(Object.fromEntries(timelineEntries)); } catch (_) { notify(t('crm.error_loading')); } finally { setLoading(false); } }, [lang]); React.useEffect(() => { loadContacts(); }, [loadContacts]); const filtered = contacts.filter((c) => (type === 'todos' || c.type === type) && (stage === 'todas' || c.stage === stage) && (q === '' || `${c.name} ${c.company} ${c.email}`.toLowerCase().includes(q.toLowerCase())) ); const leads = filtered.filter((c) => c.type === 'lead'); const accounts = filtered.filter((c) => c.type === 'cliente'); const getLeadProfile = (id) => leadProfilesById[id] || null; const totalMRR = accounts.reduce((sum, c) => sum + (c.mrr || 0), 0); const activeAccounts = accounts.filter((c) => c.stage === 'active').length; const riskAccounts = accounts.filter((c) => c.stage === 'risk').length; const avgLeadHealth = leads.length ? Math.round(leads.reduce((s, c) => s + (c.health || 0), 0) / leads.length) : 0; const formatEur = (n) => fmt('currency', n || 0); const fmtAgo = (h) => { if (h < 1) return t('time.minutes_ago'); if (h < 24) return t('time.hours_ago', { n: h }); return t('time.days_ago', { n: Math.floor(h / 24) }); }; const healthColor = (h) => (h >= 70 ? 'var(--ok)' : h >= 40 ? 'var(--warn)' : 'var(--bad)'); const pushTimeline = async (contactId, item) => { setTimelineById((prev) => ({ ...prev, [contactId]: [item, ...(prev[contactId] || [])] })); try { await fetch(`/api/v1/crm/contacts/${contactId}/timeline`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() }, body: JSON.stringify({ kind: item.kind || 'nota', by: item.by || 'Usuario', title: item.title || 'Nota', body: item.body || null }), }); } catch (_) {} }; const updateContact = async (contactId, patch) => { setContacts((prev) => prev.map((c) => (c.id === contactId ? { ...c, ...patch } : c))); setOpen((prev) => (prev && prev.id === contactId ? { ...prev, ...patch } : prev)); const apiPatch = { ...patch }; if ('type' in apiPatch) { apiPatch.contact_type = apiPatch.type; delete apiPatch.type; } if ('mrr' in apiPatch) { apiPatch.mrr_cents = Math.round((apiPatch.mrr || 0) * 100); delete apiPatch.mrr; } if ('arr' in apiPatch) { apiPatch.arr_cents = Math.round((apiPatch.arr || 0) * 100); delete apiPatch.arr; } try { await fetch(`/api/v1/crm/contacts/${contactId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() }, body: JSON.stringify(apiPatch), }); } catch (_) {} }; const createContact = async () => { if (createSaving) return; setCreateError(''); if (!draft.name.trim() || !draft.email.trim() || !draft.company.trim()) { notify(t('crm.required_fields')); setCreateError('Completa nombre, email y empresa.'); return; } setCreateSaving(true); try { const res = await fetch('/api/v1/crm/contacts', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() }, body: JSON.stringify({ name: draft.name.trim(), email: draft.email.trim(), company: draft.company.trim(), title: draft.title.trim() || null, phone: draft.phone.trim() || null, country: draft.country, owner_name: draft.ownerName, contact_type: draft.type, stage: draft.stage, mrr_cents: draft.type === 'cliente' ? 150000 : 0, arr_cents: draft.type === 'cliente' ? 1800000 : 0, health: 72, }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); const msg = d.detail || t('crm.error_create'); notify(msg); setCreateError(msg); return; } setShowCreate(false); setCreateError(''); setDraft({ name: '', email: '', company: '', title: '', phone: '', type: 'lead', stage: 'lead', country: 'España', ownerName: 'Admin' }); await loadContacts(); notify(t('crm.created')); } catch (_) { notify(t('crm.network_error')); setCreateError(t('crm.network_error')); } finally { setCreateSaving(false); } }; const changeStage = (contactId, stageId) => { updateContact(contactId, { stage: stageId }); pushTimeline(contactId, { t: Date.now(), kind: 'sistema', by: 'Sistema', title: t('crm.stage_updated'), body: t('crm.moved_to', { stage: stageId }) }); }; const onImportCsv = async (event) => { const file = event.target.files && event.target.files[0]; if (!file) return; try { const text = await file.text(); const lines = text.split(/\r?\n/).filter(Boolean); if (lines.length <= 1) return; const header = lines[0].split(',').map((x) => x.trim().toLowerCase()); const idx = { name: header.indexOf('name'), email: header.indexOf('email'), company: header.indexOf('company'), title: header.indexOf('title') }; if (idx.name < 0 || idx.email < 0 || idx.company < 0) { notify(t('crm.csv_invalid')); return; } for (const line of lines.slice(1)) { const cols = line.split(',').map((x) => x.trim()); if (!cols[idx.name] || !cols[idx.email] || !cols[idx.company]) continue; await fetch('/api/v1/crm/contacts', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() }, body: JSON.stringify({ name: cols[idx.name], email: cols[idx.email], company: cols[idx.company], title: idx.title >= 0 ? cols[idx.title] : null, contact_type: 'lead', stage: 'lead', }), }); } await loadContacts(); notify(t('crm.csv_imported')); } catch (_) { notify(t('crm.csv_import_error')); } finally { event.target.value = ''; } }; const groupedByStage = D.stages.map((s) => ({ stage: s, items: leads.filter((c) => c.stage === s.id) })); const flatTimeline = Object.values(timelineById).flat().sort((a, b) => (b.t || 0) - (a.t || 0)); const runLeadScoring = async () => { const targetLeads = leads.slice(0, 25); if (!targetLeads.length) { notify(t('crm.no_leads_to_score')); return; } let ok = 0; for (const lead of targetLeads) { try { const res = await fetch(`/api/v1/leads/${lead.id}/score`, { method: 'POST', headers: { Authorization: 'Bearer ' + getToken() }, }); if (res.ok) ok += 1; } catch (_) {} } notify(t('crm.scored_count', { n: ok })); await loadContacts(); }; const generateOutreachDrafts = async () => { const targetLeads = leads.slice(0, 15); if (!targetLeads.length) { notify(t('crm.no_leads_outreach')); return; } let ok = 0; for (const lead of targetLeads) { try { const res = await fetch(`/api/v1/leads/${lead.id}/outreach`, { method: 'POST', headers: { Authorization: 'Bearer ' + getToken() }, }); if (res.ok) ok += 1; } catch (_) {} } notify(t('crm.outreach_count', { n: ok })); await loadContacts(); }; const syncTwenty = async () => { try { const res = await fetch('/api/v1/leads/sync/twenty', { method: 'POST', headers: { Authorization: 'Bearer ' + getToken() }, }); const data = await res.json().catch(() => ({})); if (!res.ok) { notify((data.detail || 'sync error')); return; } notify(t('crm.synced_count', { synced: data.synced, skipped: data.skipped })); } catch (_) { notify(t('crm.sync_error')); } }; const downloadCsvTemplate = () => { const csv = [ 'name,email,company,title,phone,country,owner_name', 'Ana Perez,ana.perez@empresa.com,Empresa Uno,Directora Comercial,+34600000001,España,Admin', 'Luis Gomez,luis.gomez@empresa.com,Empresa Dos,CEO,+34600000002,España,Admin', ].join('\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'leads_import_template.csv'; a.click(); URL.revokeObjectURL(url); notify('Plantilla CSV descargada'); }; const moduleTabs = [ { id: 'overview', label: t('crm.tab.overview') }, { id: 'leads', label: lang === 'en' ? 'Leads' : 'Leads' }, { id: 'accounts', label: t('crm.tab.accounts') }, { id: 'pipeline', label: t('crm.tab.pipeline') }, { id: 'activity', label: t('crm.tab.activity') }, { id: 'automations', label: t('crm.tab.automations') }, ]; return (

{t('page.crm')}

{t('page.crm.crumbs')} {t('page.crm.summary', { c: accounts.length, l: leads.length })}
{t('crm.kpi.mrr')}
{formatEur(totalMRR)}
{t('crm.delta.mom')}
{t('crm.kpi.active')}
{activeAccounts}
{t('crm.delta.month')}
{t('crm.kpi.risk')}
{riskAccounts}
{t('crm.delta.exposed', { v: formatEur(riskAccounts * 4200) })}
{t('crm.kpi.leads')}
{leads.length}
{t('crm.delta.value', { v: formatEur(leads.length * 1800) })}
{t('crm.kpi.lead_health')}
{avgLeadHealth}/100
{t('crm.kpi.auto_qualified')}
{moduleTabs.map((tab) => ( ))}
setQ(e.target.value)} />
{[['todos', t('crm.all')], ['cliente', t('crm.clients')], ['lead', t('crm.leads')]].map(([id, lbl]) => ( ))} {D.stages.map((s) => ( ))} {t('crm.results', { n: filtered.length })}
{loading && contacts.length === 0 &&
{t('crm.loading')}
} {view === 'overview' && (
{filtered.slice(0, 80).map((c) => ( setOpen(c)}> ))}
{t('crm.col.contact')}{t('crm.col.company')}{t('crm.col.stage')}{t('crm.col.health')}{t('crm.col.mrr')}{t('crm.col.agent')}{t('crm.col.last')}
{c.initials}
{c.name}
{c.title || '—'} · {c.email}
{c.company}
{c.country || '—'}
e.stopPropagation()}>
{c.health}
{c.mrr ? formatEur(c.mrr) : '—'} {c.assignedAgent || '—'} {fmtAgo(c.lastContactH)}
)} {view === 'leads' && (
{leads.length === 0 && ( )} {leads.slice(0, 80).map((c) => { const profile = getLeadProfile(c.id); const qualification = profile?.qualification || (c.health >= 75 ? 'high' : c.health >= 50 ? 'medium' : 'low'); const nextStep = profile?.recommended_next_step || c.nextAction || '—'; const scoreValue = profile?.score ?? c.health; return ( setOpen(c)}> ); })}
{t('crm.col.contact')}{t('crm.col.company')}{t('crm.col.stage')}{t('crm.col.qualification')}{t('crm.col.next_action')}
No hay leads todavía. Empieza con `Nuevo contacto` o `Importar CSV` usando `Plantilla CSV`.
{c.name}
{c.email}
{c.company}
{c.title || '—'}
{c.stage} {qualification === 'hot' ? (lang === 'en' ? 'High' : 'Alta') : qualification === 'warm' ? (lang === 'en' ? 'Medium' : 'Media') : qualification === 'cold' ? (lang === 'en' ? 'Low' : 'Baja') : `${scoreValue}/100`} {nextStep}
)} {view === 'accounts' && (
{accounts.slice(0, 80).map((c) => ( setOpen(c)}> ))}
{t('crm.col.contact')}{t('crm.col.company')}{t('crm.col.mrr')}ARR{t('crm.col.owner')}
{c.name}
{c.email}
{c.company} {formatEur(c.mrr || 0)} {formatEur(c.arr || 0)} {c.ownerName || '—'}
)} {view === 'pipeline' && (
{groupedByStage.map(({ stage: s, items }) => (
{s.label}{items.length}
{items.slice(0, 10).map((c) => (
setOpen(c)}>
{c.initials}
{c.name}
{c.company} · {c.title || '—'}
● {c.health}{fmtAgo(c.lastContactH)}
))}
))}
)} {view === 'activity' && (
{(flatTimeline.length ? flatTimeline : [{ id: 'empty', title: t('crm.empty_activity'), body: '—', by: 'Sistema', kind: 'sistema', t: Date.now() }]).slice(0, 80).map((ev, i) => (
{ev.title}{fmtAgo(Math.max(0, Math.round((Date.now() - (ev.t || Date.now())) / 3600000)))}
{ev.body || '—'}
{ev.by || 'Sistema'} · {ev.kind || 'nota'}
))}
)} {view === 'automations' && (
{t('crm.automation.lead_qualification')}

{t('crm.automation.lead_qualification.desc')}

{t('crm.automation.outbound')}

{t('crm.automation.outbound.desc')}

{t('crm.automation.sync')}

{t('crm.automation.sync.desc')}

)}
{showCreate && (
{ if (e.target === e.currentTarget) setShowCreate(false); }}>

{t('btn.new_contact')}

CRM
{t('crm.detail.name')}
setDraft((d) => ({ ...d, name: e.target.value }))} />
{t('crm.detail.email')}
setDraft((d) => ({ ...d, email: e.target.value }))} />
{t('crm.detail.company')}
setDraft((d) => ({ ...d, company: e.target.value }))} />
{t('crm.detail.title')}
setDraft((d) => ({ ...d, title: e.target.value }))} />
{t('crm.detail.phone')}
setDraft((d) => ({ ...d, phone: e.target.value }))} />
{t('crm.detail.type')}
{createError && (
{createError}
)}
)} {open && setOpen(null)} formatEur={formatEur} fmtAgo={fmtAgo} healthColor={healthColor} onChangeStage={changeStage} onLog={pushTimeline} />}
); }; const ContactDrawer = ({ c, onClose, formatEur, fmtAgo, healthColor, onChangeStage, onLog }) => { const D = window.AF_CRM; const { t, lang } = useI18n(); return ( <>
{c.initials}

{c.name}

{c.title || '—'} · {c.company} · {c.email}
MRR
{c.mrr ? formatEur(c.mrr) : '—'}
Health
{c.health}/100
ARR
{c.arr ? formatEur(c.arr) : '—'}
Last
{fmtAgo(c.lastContactH)}
{t('crm.detail.stage')}
{t('crm.tab.activity')}
{t('crm.detail.last_update')}
{c.nextAction || '—'}
); }; window.CRM = CRM;