// AGENTFORGE-IA — Panel de administración de usuarios const ROLE_META = { propietario: { label: 'Propietario', color: '#7c3aed', bg: 'rgba(124,58,237,0.15)' }, administrador:{ label: 'Administrador',color: '#f472b6', bg: 'rgba(244,114,182,0.15)' }, constructor: { label: 'Builder', color: '#34d399', bg: 'rgba(52,211,153,0.15)' }, revisor: { label: 'Revisor', color: '#60a5fa', bg: 'rgba(96,165,250,0.15)' }, lector: { label: 'Lector', color: '#fbbf24', bg: 'rgba(251,191,36,0.15)' }, operador_sensible: { label: 'Operador sensible', color: '#ef4444', bg: 'rgba(239,68,68,0.15)' }, }; const ALL_ROLES = Object.keys(ROLE_META); const ROLE_SECTION_PERMS = { propietario: { crm:'manage',scheduling:'manage',builder:'manage',playground:'manage',deployment:'manage',integrations:'manage',billing:'manage',settings:'manage',runs:'manage',analytics:'manage',admin:'manage',library:'manage' }, administrador: { crm:'edit',scheduling:'edit',builder:'edit',playground:'edit',deployment:'edit',integrations:'edit',billing:'view',settings:'edit',runs:'view',analytics:'view',admin:'edit',library:'edit' }, constructor: { builder:'edit',playground:'edit',deployment:'edit',library:'view',integrations:'edit' }, revisor: { crm:'view',scheduling:'view',runs:'view',analytics:'view',library:'view',playground:'view',integrations:'view' }, lector: { runs:'view',analytics:'view',library:'view',playground:'view',integrations:'view' }, operador_sensible: { ops_finance:'manage',ops_sales:'manage',ops_marketing:'manage',ops_operations:'manage',ops_hr:'manage',ops_support:'manage',billing:'view',integrations:'view',runs:'view',analytics:'view' }, }; const PERM_LEVEL = { view: 1, edit: 2, manage: 3 }; const PERM_LABEL = { view: 'ver', edit: 'editar', manage: 'total' }; function computeUserSections(user) { if (user?.is_admin) return { playground: 'manage', integrations: 'manage' }; const merged = {}; const roles = Array.isArray(user?.roles) ? user.roles : []; for (const role of roles) { for (const [section, level] of Object.entries(ROLE_SECTION_PERMS[role] || {})) { if (!merged[section] || PERM_LEVEL[level] > PERM_LEVEL[merged[section]]) { merged[section] = level; } } } return merged; } function computeSectionsFromRoles(roles) { const merged = {}; const safeRoles = Array.isArray(roles) ? roles : []; for (const role of safeRoles) { for (const [section, level] of Object.entries(ROLE_SECTION_PERMS[role] || {})) { if (!merged[section] || PERM_LEVEL[level] > PERM_LEVEL[merged[section]]) { merged[section] = level; } } } return merged; } const AdminPanel = () => { const [adminTab, setAdminTab] = React.useState('users'); const [users, setUsers] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(''); const [search, setSearch] = React.useState(''); const [playgroundFilter, setPlaygroundFilter] = React.useState('all'); const [integrationsFilter, setIntegrationsFilter] = React.useState('all'); // Edit modal state const [editUser, setEditUser] = React.useState(null); const [editName, setEditName] = React.useState(''); const [editEmail, setEditEmail] = React.useState(''); const [editPhone, setEditPhone] = React.useState(''); const [editCompany, setEditCompany] = React.useState(''); const [editIsActive, setEditIsActive] = React.useState(true); const [editIsAdmin, setEditIsAdmin] = React.useState(false); const [editSaving, setEditSaving] = React.useState(false); const [editError, setEditError] = React.useState(''); // Password reset modal state const [passwordUser, setPasswordUser] = React.useState(null); const [newPassword, setNewPassword] = React.useState(''); const [newPasswordConfirm, setNewPasswordConfirm] = React.useState(''); const [passwordSaving, setPasswordSaving] = React.useState(false); const [passwordError, setPasswordError] = React.useState(''); // Agents drawer state const [agentsUser, setAgentsUser] = React.useState(null); const [agentsList, setAgentsList] = React.useState([]); const [agentsLoading, setAgentsLoading] = React.useState(false); // Roles drawer state const [rolesUser, setRolesUser] = React.useState(null); const [userRoles, setUserRoles] = React.useState([]); const [initialRolesSnapshot, setInitialRolesSnapshot] = React.useState([]); const [rolesLoading, setRolesLoading] = React.useState(false); const [addingRole, setAddingRole] = React.useState(''); const [rolesSaving, setRolesSaving] = React.useState(false); // Register modal state const [showRegister, setShowRegister] = React.useState(false); // Subscriptions tab state const [subs, setSubs] = React.useState([]); const [subsLoading, setSubsLoading] = React.useState(false); const [subsFilter, setSubsFilter] = React.useState('all'); const [subsError, setSubsError] = React.useState(''); const [globalIntegrations, setGlobalIntegrations] = React.useState([]); const [globalIntegrationsLoading, setGlobalIntegrationsLoading] = React.useState(false); const [globalIntegrationsError, setGlobalIntegrationsError] = React.useState(''); const [globalSyncRunning, setGlobalSyncRunning] = React.useState(false); const [globalForm, setGlobalForm] = React.useState({ slug: 'twenty', account_name: '', api_key: '', webhook_url: '', }); const getToken = () => sessionStorage.getItem('af_token') || ''; const loadUsers = React.useCallback(async () => { setLoading(true); setError(''); try { const res = await fetch('/api/v1/admin/users', { headers: { Authorization: 'Bearer ' + getToken() }, }); if (!res.ok) { const d = await res.json().catch(() => ({})); setError(d.detail || 'Error al cargar usuarios.'); return; } const data = await res.json(); setUsers(Array.isArray(data) ? data : (data.items || [])); } catch (_) { setError('Error de red.'); } finally { setLoading(false); } }, []); React.useEffect(() => { loadUsers(); }, [loadUsers]); const loadSubscriptions = React.useCallback(async (f) => { setSubsLoading(true); setSubsError(''); const filt = f || subsFilter; try { const res = await fetch('/api/v1/admin/subscriptions?filter=' + filt, { headers: { Authorization: 'Bearer ' + getToken() }, }); if (!res.ok) { setSubsError('Error al cargar suscripciones.'); return; } const data = await res.json(); setSubs(Array.isArray(data) ? data : []); } catch (_) { setSubsError('Error de red.'); } finally { setSubsLoading(false); } }, [subsFilter]); React.useEffect(() => { if (adminTab === 'subs') loadSubscriptions(); }, [adminTab]); const loadGlobalIntegrations = React.useCallback(async () => { setGlobalIntegrationsLoading(true); setGlobalIntegrationsError(''); try { const res = await fetch('/api/v1/admin/integrations/global', { headers: { Authorization: 'Bearer ' + getToken() }, }); if (!res.ok) { const d = await res.json().catch(() => ({})); setGlobalIntegrationsError(d.detail || 'Error al cargar integraciones globales.'); return; } const data = await res.json(); setGlobalIntegrations(Array.isArray(data) ? data : []); } catch (_) { setGlobalIntegrationsError('Error de red.'); } finally { setGlobalIntegrationsLoading(false); } }, []); React.useEffect(() => { if (adminTab === 'integrations') loadGlobalIntegrations(); }, [adminTab, loadGlobalIntegrations]); const saveGlobalIntegration = async () => { const slug = (globalForm.slug || '').trim().toLowerCase(); if (!slug) { window.alert('Debes seleccionar un slug.'); return; } const payload = { slug, account_name: globalForm.account_name.trim() || null, api_key: globalForm.api_key.trim() || null, webhook_url: globalForm.webhook_url.trim() || null, }; const res = await fetch('/api/v1/admin/integrations/global/' + slug, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken(), }, body: JSON.stringify(payload), }); if (!res.ok) { const d = await res.json().catch(() => ({})); window.alert(d.detail || 'No se pudo guardar la integración global.'); return; } window.afNotify && window.afNotify('Integración global guardada'); setGlobalForm((prev) => ({ ...prev, api_key: '' })); await loadGlobalIntegrations(); }; const removeGlobalIntegration = async (slug) => { if (!window.confirm(`¿Eliminar la integración global "${slug}"?`)) return; const res = await fetch('/api/v1/admin/integrations/global/' + slug, { method: 'DELETE', headers: { Authorization: 'Bearer ' + getToken() }, }); if (!res.ok) { const d = await res.json().catch(() => ({})); window.alert(d.detail || 'No se pudo eliminar la integración.'); return; } window.afNotify && window.afNotify('Integración eliminada'); await loadGlobalIntegrations(); }; const runTwentySync = async () => { setGlobalSyncRunning(true); try { const res = await fetch('/api/v1/leads/sync/twenty', { method: 'POST', headers: { Authorization: 'Bearer ' + getToken() }, }); const d = await res.json().catch(() => ({})); if (!res.ok) { window.alert(d.detail || 'Error en sincronización.'); return; } window.afNotify && window.afNotify(`Sync twenty: ${d.synced} ok / ${d.skipped} omitidos`); await loadGlobalIntegrations(); } catch (_) { window.alert('Error de red en sincronización.'); } finally { setGlobalSyncRunning(false); } }; const freezeSub = async (subId) => { const res = await fetch('/api/v1/admin/subscriptions/' + subId + '/freeze', { method: 'PATCH', headers: { Authorization: 'Bearer ' + getToken() }, }); if (res.ok) { window.afNotify && window.afNotify('Suscripción congelada'); loadSubscriptions(); } else { const d = await res.json().catch(() => ({})); window.alert(d.detail || 'Error al congelar.'); } }; const unfreezeSub = async (subId) => { const res = await fetch('/api/v1/admin/subscriptions/' + subId + '/unfreeze', { method: 'PATCH', headers: { Authorization: 'Bearer ' + getToken() }, }); if (res.ok) { window.afNotify && window.afNotify('Suscripción reactivada'); loadSubscriptions(); } else { const d = await res.json().catch(() => ({})); window.alert(d.detail || 'Error al reactivar.'); } }; const remindSub = async (subId) => { const res = await fetch('/api/v1/admin/subscriptions/' + subId + '/remind', { method: 'POST', headers: { Authorization: 'Bearer ' + getToken() }, }); if (res.ok) { window.afNotify && window.afNotify('Email de recordatorio enviado'); } else { const d = await res.json().catch(() => ({})); window.alert(d.detail || 'Error al enviar email.'); } }; const toggleActive = async (user) => { try { const res = await fetch('/api/v1/admin/users/' + user.id, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() }, body: JSON.stringify({ is_active: !user.is_active }), }); if (!res.ok) return; const updated = await res.json(); setUsers(prev => prev.map(u => u.id === user.id ? { ...u, ...updated } : u)); } catch (_) {} }; const deleteUser = async (user) => { if (!window.confirm('¿Eliminar a ' + (user.full_name || user.email) + '? Esta acción no se puede deshacer.')) return; try { const res = await fetch('/api/v1/admin/users/' + user.id, { method: 'DELETE', headers: { Authorization: 'Bearer ' + getToken() }, }); if (!res.ok) { let reason = `HTTP ${res.status}`; try { const payload = await res.json(); reason = payload?.detail || reason; } catch (_) {} window.alert('No se pudo eliminar el usuario: ' + reason); return; } setUsers(prev => prev.filter(u => u.id !== user.id)); window.afNotify && window.afNotify('Usuario eliminado'); } catch (_) {} }; const toggleMfaByAdmin = async (user) => { const currentlyEnabled = !!user.mfa_enabled; const targetEnabled = !currentlyEnabled; const actionLabel = targetEnabled ? 'habilitar' : 'deshabilitar'; if (!window.confirm(`¿Seguro que deseas ${actionLabel} MFA para ${user.full_name || user.email}?`)) return; try { const res = await fetch('/api/v1/admin/users/' + user.id + '/mfa', { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() }, body: JSON.stringify({ enabled: targetEnabled, reset_secret: targetEnabled ? false : true, }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); window.alert(d.detail || 'No se pudo cambiar el estado de MFA.'); return; } const d = await res.json().catch(() => ({})); setUsers(prev => prev.map(u => u.id === user.id ? { ...u, mfa_enabled: targetEnabled } : u)); if (window.afNotify) { window.afNotify( targetEnabled ? 'MFA activado. El usuario deberá configurarlo en su próximo login.' : 'MFA desactivado para el usuario.' ); } } catch (_) { window.alert('Error de red al cambiar MFA.'); } }; const openEdit = (user) => { setEditUser(user); setEditName(user.full_name || ''); setEditEmail(user.email || ''); setEditPhone(user.phone || ''); setEditCompany(user.company || ''); setEditIsActive(!!user.is_active); setEditIsAdmin(!!user.is_admin); setEditError(''); }; const saveEdit = async () => { if (!editUser) return; setEditSaving(true); setEditError(''); try { const res = await fetch('/api/v1/admin/users/' + editUser.id, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() }, body: JSON.stringify({ full_name: editName.trim(), email: editEmail.trim(), phone: editPhone.trim() || null, company: editCompany.trim() || null, is_active: editIsActive, is_admin: editIsAdmin, }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); setEditError(d.detail || 'Error al guardar.'); return; } const updated = await res.json(); setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...updated } : u)); setEditUser(null); } catch (_) { setEditError('Error de red.'); } finally { setEditSaving(false); } }; const openPasswordReset = (user) => { setPasswordUser(user); setNewPassword(''); setNewPasswordConfirm(''); setPasswordError(''); }; const savePasswordReset = async () => { if (!passwordUser) return; if (newPassword.length < 8) { setPasswordError('La contraseña debe tener al menos 8 caracteres.'); return; } if (newPassword !== newPasswordConfirm) { setPasswordError('Las contraseñas no coinciden.'); return; } setPasswordSaving(true); setPasswordError(''); try { const res = await fetch('/api/v1/admin/users/' + passwordUser.id + '/password', { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() }, body: JSON.stringify({ password: newPassword }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); setPasswordError(d.detail || 'No se pudo cambiar la contraseña.'); return; } setPasswordUser(null); window.afNotify && window.afNotify('Contraseña actualizada'); } catch (_) { setPasswordError('Error de red.'); } finally { setPasswordSaving(false); } }; const openAgents = async (user) => { setAgentsUser(user); setAgentsList([]); setAgentsLoading(true); try { const res = await fetch('/api/v1/admin/users/' + user.id + '/agents', { headers: { Authorization: 'Bearer ' + getToken() }, }); if (res.ok) { const data = await res.json(); setAgentsList(Array.isArray(data) ? data : (data.items || [])); } } catch (_) {} setAgentsLoading(false); }; const openRoles = async (user) => { setRolesUser(user); setUserRoles(user.roles || []); setInitialRolesSnapshot(Array.isArray(user.roles) ? user.roles : []); setAddingRole(''); setRolesLoading(true); try { const res = await fetch('/api/v1/admin/users/' + user.id + '/roles', { headers: { Authorization: 'Bearer ' + getToken() }, }); if (res.ok) { const data = await res.json(); const loadedRoles = Array.isArray(data) ? data : []; setUserRoles(loadedRoles); setInitialRolesSnapshot(loadedRoles); } } catch (_) {} setRolesLoading(false); }; const addRole = async () => { if (!addingRole || !rolesUser) return; setRolesSaving(true); try { const res = await fetch('/api/v1/admin/users/' + rolesUser.id + '/roles', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() }, body: JSON.stringify({ role: addingRole }), }); if (res.ok) { const updated = await res.json(); setUserRoles(updated); setUsers(prev => prev.map(u => u.id === rolesUser.id ? { ...u, roles: updated } : u)); setAddingRole(''); } } catch (_) {} setRolesSaving(false); }; const removeRole = async (role) => { if (!rolesUser) return; setRolesSaving(true); try { const res = await fetch('/api/v1/admin/users/' + rolesUser.id + '/roles/' + role, { method: 'DELETE', headers: { Authorization: 'Bearer ' + getToken() }, }); if (res.ok) { const updated = await res.json(); setUserRoles(updated); setUsers(prev => prev.map(u => u.id === rolesUser.id ? { ...u, roles: updated } : u)); } } catch (_) {} setRolesSaving(false); }; const filteredUsers = React.useMemo(() => { const q = search.toLowerCase().trim(); return users.filter((u) => { const matchesQuery = !q || (u.email || '').toLowerCase().includes(q) || (u.full_name || '').toLowerCase().includes(q); if (!matchesQuery) return false; const sections = computeUserSections(u); const hasPlayground = !!sections.playground; const hasIntegrations = !!sections.integrations; const playgroundOk = playgroundFilter === 'all' || (playgroundFilter === 'with' && hasPlayground) || (playgroundFilter === 'without' && !hasPlayground); if (!playgroundOk) return false; const integrationsOk = integrationsFilter === 'all' || (integrationsFilter === 'with' && hasIntegrations) || (integrationsFilter === 'without' && !hasIntegrations); return integrationsOk; }); }, [users, search, playgroundFilter, integrationsFilter]); const totalUsers = users.length; const activeUsers = users.filter(u => u.is_active).length; const adminUsers = users.filter(u => u.is_admin).length; // Styles const pageStyle = { fontFamily: '"Inter Tight", "Inter", sans-serif', color: '#e5e5e5' }; const statCardStyle = { background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: 10, padding: '14px 20px', minWidth: 100 }; const tableWrapStyle = { background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: 10, overflow: 'hidden', marginTop: 16 }; const thStyle = { padding: '10px 14px', textAlign: 'left', fontSize: 11, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.06em', background: '#111', borderBottom: '1px solid #2a2a2a', whiteSpace: 'nowrap' }; const tdStyle = (extra = {}) => ({ padding: '10px 14px', fontSize: 13, verticalAlign: 'middle', borderBottom: '1px solid #1e1e1e', color: '#e5e5e5', ...extra }); const inputStyle = { background: '#111', border: '1px solid #2a2a2a', borderRadius: 7, padding: '8px 12px', fontSize: 13, color: '#e5e5e5', fontFamily: 'inherit', outline: 'none', width: 260 }; const badgeStyle = (color, bg) => ({ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 5, fontSize: 11.5, fontWeight: 600, color, background: bg, whiteSpace: 'nowrap' }); const actionBtnStyle = { background: 'none', border: '1px solid #2a2a2a', borderRadius: 5, padding: '4px 10px', fontSize: 12, color: '#888', cursor: 'pointer', fontFamily: 'inherit' }; const actionBtnDangerStyle = { ...actionBtnStyle, borderColor: 'rgba(255,80,80,0.2)', color: '#ff6b6b' }; const actionBtnPrimaryStyle = { ...actionBtnStyle, borderColor: 'rgba(255,91,31,0.3)', color: '#ff5b1f' }; const actionBtnVioletStyle = { ...actionBtnStyle, borderColor: 'rgba(124,58,237,0.3)', color: '#a78bfa' }; const overlayStyle = { position: 'fixed', inset: 0, zIndex: 900, background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center' }; const modalCardStyle = { background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: 10, padding: '28px 24px', width: 360, fontFamily: '"Inter Tight", "Inter", sans-serif', color: '#e5e5e5', boxShadow: '0 16px 48px rgba(0,0,0,0.5)' }; const modalLabelStyle = { display: 'block', fontSize: 11.5, fontWeight: 600, color: '#888', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 5 }; const modalInputStyle = { width: '100%', boxSizing: 'border-box', background: '#111', border: '1px solid #2a2a2a', borderRadius: 6, padding: '8px 11px', fontSize: 13, color: '#e5e5e5', fontFamily: 'inherit', outline: 'none' }; const drawerStyle = { position: 'fixed', top: 0, right: 0, bottom: 0, zIndex: 950, width: 380, background: '#1a1a1a', borderLeft: '1px solid #2a2a2a', display: 'flex', flexDirection: 'column', fontFamily: '"Inter Tight", "Inter", sans-serif', color: '#e5e5e5', boxShadow: '-8px 0 32px rgba(0,0,0,0.4)' }; const formatDate = (val) => { if (!val) return '—'; try { return new Date(val).toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' }); } catch (_) { return val; } }; const availableRolesToAdd = ALL_ROLES.filter(r => !userRoles.includes(r)); const rolesImpact = React.useMemo(() => { const before = computeSectionsFromRoles(initialRolesSnapshot); const after = computeSectionsFromRoles(userRoles); const watched = ['playground', 'integrations']; const changes = watched .map((section) => ({ section, before: before[section] || null, after: after[section] || null, })) .filter((x) => x.before !== x.after); return { changed: changes.length > 0, changes, }; }, [initialRolesSnapshot, userRoles]); return (

Administración

panel {adminTab === 'users' ? 'usuarios' : adminTab === 'subs' ? 'suscripciones' : adminTab === 'integrations' ? 'integraciones globales' : 'configuración'}
{[['users','Usuarios'],['subs','Suscripciones'],['integrations','Integraciones globales'],['settings','Configuración']].map(([id, lbl]) => ( ))}
{adminTab === 'users' && <> }
{adminTab === 'settings' && (
{typeof window.SettingsInner !== 'undefined' ? React.createElement(window.SettingsInner) : null}
)} {adminTab === 'users' &&
{/* Stats */}
{[ { label: 'Total', value: totalUsers, color: '#e5e5e5' }, { label: 'Activos', value: activeUsers, color: '#4ade80' }, { label: 'Admins', value: adminUsers, color: '#ff5b1f' }, ].map(s => (
{s.label}
{loading ? '—' : s.value}
))}
{/* Search */}
setSearch(e.target.value)} />
{search && {filteredUsers.length} resultado{filteredUsers.length !== 1 ? 's' : ''}}
{error &&
{error}
} {/* Table */}
{loading && } {!loading && filteredUsers.length === 0 && ( )} {!loading && filteredUsers.map(u => ( (() => { const sections = computeUserSections(u); const playgroundAccess = sections.playground || null; const integrationsAccess = sections.integrations || null; return ( e.currentTarget.style.background = u.mfa_enabled ? 'rgba(52,211,153,0.12)' : '#1f1f1f'} onMouseLeave={e => e.currentTarget.style.background = u.mfa_enabled ? 'rgba(52,211,153,0.06)' : 'transparent'} > ); })() ))}
Usuario Perfil Roles Pruebas Integraciones Estado MFA Registro Acciones
Cargando usuarios...
{search ? 'Sin resultados para "' + search + '"' : 'No hay usuarios.'}
{u.full_name || '—'}
{u.email}
{u.company &&
{u.company}
}
{u.is_admin ? ⊕ Admin : ↑ Usuario }
{(u.roles || []).length === 0 ? : (u.roles || []).map(r => { const m = ROLE_META[r] || { label: r, color: '#888', bg: 'rgba(255,255,255,0.05)' }; return {m.label}; }) }
{playgroundAccess ? {PERM_LABEL[playgroundAccess] || playgroundAccess} : sin acceso } {integrationsAccess ? {PERM_LABEL[integrationsAccess] || integrationsAccess} : sin acceso } {u.is_active ? Activo : Congelado } {u.mfa_enabled ? MFA activo : MFA inactivo } {formatDate(u.created_at)}
} {adminTab === 'subs' && (
{/* Stats */} {(() => { const total = subs.length; const activas = subs.filter(s => s.status === 'active' || s.status === 'trial').length; const vencidas = subs.filter(s => s.days_overdue > 0 && s.status !== 'frozen').length; const congeladas = subs.filter(s => s.status === 'frozen').length; return (
{[ { label: 'Total', value: total, color: '#e5e5e5' }, { label: 'Activas', value: activas, color: '#4ade80' }, { label: 'Vencidas', value: vencidas, color: '#f59e0b' }, { label: 'Congeladas', value: congeladas, color: '#60a5fa' }, ].map(s => (
{s.label}
{subsLoading ? '—' : s.value}
))}
); })()} {/* Filtros */}
{[['all','Todas'],['active','Activas'],['overdue','Vencidas'],['frozen','Congeladas']].map(([id, lbl]) => ( ))}
{subsError &&
{subsError}
} {/* Tabla */}
{['Cliente','Agente','Plan','Estado','Período','Vencida','Acciones'].map(h => ( ))} {subsLoading && } {!subsLoading && subs.length === 0 && } {!subsLoading && subs.map(sub => { const isOverdue = sub.days_overdue > 0 && sub.status !== 'frozen'; const isFrozen = sub.status === 'frozen'; const statusColor = isFrozen ? '#60a5fa' : isOverdue ? '#f59e0b' : sub.status === 'active' ? '#4ade80' : '#888'; const statusBg = isFrozen ? 'rgba(96,165,250,0.1)' : isOverdue ? 'rgba(245,158,11,0.1)' : sub.status === 'active' ? 'rgba(74,222,128,0.1)' : 'rgba(255,255,255,0.04)'; const statusLabel = isFrozen ? '❄ Congelada' : isOverdue ? '⚠ Vencida' : sub.status === 'active' ? '● Activa' : sub.status === 'trial' ? '◌ Trial' : sub.status; return ( ); })}
{h}
Cargando...
No hay suscripciones.
{sub.customer_name}
{sub.customer_email}
{sub.agent_name}
{sub.agent_price_cents > 0 &&
{sub.agent_price_cents / 100} €/mes
}
{sub.plan} {statusLabel}
{sub.current_period_end ? new Date(sub.current_period_end).toLocaleDateString('es-ES') : '—'}
{sub.days_overdue > 0 ? {sub.days_overdue}d : }
{!isFrozen && } {isFrozen && } {(isOverdue || isFrozen) && sub.customer_email && ( )}
)} {adminTab === 'integrations' && (
Workspace integrations (globales)
setGlobalForm((p) => ({ ...p, account_name: e.target.value }))} style={{ background: '#111', border: '1px solid #2a2a2a', color: '#e5e5e5', borderRadius: 6, padding: '8px 10px' }} /> setGlobalForm((p) => ({ ...p, api_key: e.target.value }))} style={{ background: '#111', border: '1px solid #2a2a2a', color: '#e5e5e5', borderRadius: 6, padding: '8px 10px' }} /> setGlobalForm((p) => ({ ...p, webhook_url: e.target.value }))} style={{ background: '#111', border: '1px solid #2a2a2a', color: '#e5e5e5', borderRadius: 6, padding: '8px 10px' }} />
{globalIntegrationsError &&
{globalIntegrationsError}
}
Leads ↔ CRM Sync

Ejecuta sincronización de leads cualificados hacia Twenty CRM usando la conexión global.

{globalIntegrationsLoading && ( )} {!globalIntegrationsLoading && globalIntegrations.length === 0 && ( )} {!globalIntegrationsLoading && globalIntegrations.map((row) => ( ))}
Slug Estado Cuenta API key Último test Acciones
Cargando...
Sin integraciones globales aún.
{row.slug} {row.status || '—'} {row.account_name || '—'} {row.api_key_masked || '—'} {row.last_tested_at ? new Date(row.last_tested_at).toLocaleString('es-ES') : 'Nunca'}
)} {/* Edit modal */} {editUser && (
{ if (e.target === e.currentTarget) setEditUser(null); }}>

Editar usuario

setEditName(e.target.value)} autoFocus />
setEditEmail(e.target.value)} />
setEditPhone(e.target.value)} />
setEditCompany(e.target.value)} />
{editError &&
{editError}
}
)} {/* Password reset modal */} {passwordUser && (
{ if (e.target === e.currentTarget && !passwordSaving) setPasswordUser(null); }}>

Cambiar contraseña

Usuario: {passwordUser.full_name || passwordUser.email}

{ setNewPassword(e.target.value); setPasswordError(''); }} autoComplete="new-password" autoFocus />
{ setNewPasswordConfirm(e.target.value); setPasswordError(''); }} autoComplete="new-password" />
{passwordError &&
{passwordError}
}
)} {/* Roles drawer */} {rolesUser && (
{ if (e.target === e.currentTarget) setRolesUser(null); }}>
Gestión de roles
{rolesUser.full_name || rolesUser.email}
{rolesLoading ? (
Cargando...
) : ( <>
Roles asignados
{userRoles.length === 0 ?
Sin roles asignados. Este usuario solo tiene acceso a las secciones públicas.
: (
{userRoles.map(r => { const m = ROLE_META[r] || { label: r, color: '#888', bg: 'rgba(255,255,255,0.05)' }; return (
{m.label}
); })}
) }
Añadir rol
{availableRolesToAdd.length === 0 ?
Todos los roles están asignados.
: (
) } {rolesImpact.changed && (
Impacto de permisos
{rolesImpact.changes.map((change) => (
{change.section} {' '} {PERM_LABEL[change.before] || 'sin acceso'} {' -> '} {PERM_LABEL[change.after] || 'sin acceso'}
))}
)} {/* Secciones accesibles */} {userRoles.length > 0 && (
Secciones accesibles
{(() => { const LEVEL_LABEL = { view: 'solo ver', edit: 'editar', manage: 'gestión total' }; const LEVEL_COLOR = { view: '#60a5fa', edit: '#34d399', manage: '#f472b6' }; const merged = {}; for (const role of userRoles) { for (const [sec, lvl] of Object.entries(ROLE_SECTION_PERMS[role] || {})) { if (!merged[sec] || PERM_LEVEL[lvl] > PERM_LEVEL[merged[sec]]) merged[sec] = lvl; } } const entries = Object.entries(merged); if (entries.length === 0) return
; return (
{entries.map(([sec, lvl]) => (
{sec} {LEVEL_LABEL[lvl]}
))}
); })()}
)} )}
)} {/* Agents drawer */} {agentsUser && (
{ if (e.target === e.currentTarget) setAgentsUser(null); }}>
Agentes comprados
{agentsUser.full_name || agentsUser.email}
{agentsLoading &&
Cargando agentes...
} {!agentsLoading && agentsList.length === 0 &&
Este usuario no tiene agentes comprados.
} {!agentsLoading && agentsList.map((agent, i) => (
{agent.name || agent.agent_name || agent.id}
{agent.purchased_at &&
Comprado el {formatDate(agent.purchased_at)}
} {agent.status &&
{agent.status}
}
))}
)} {/* Register modal */} {showRegister && ( setShowRegister(false)} onSuccess={() => { setShowRegister(false); loadUsers(); }} /> )}
); }; window.AdminPanel = AdminPanel;