/* Trascend CRM · Primitivas UI compartidas (iconos, modal, etc.) */ const { useState, useEffect, useMemo, useRef, useCallback } = React; const U = window.TrascendUtil; const API = window.TrascendAPI; /* ------------------------------------------------ iconos (stroke simple) */ const IconBase = ({ children, ...p }) => ( ); const Icons = { dashboard: (p) => , clients: (p) => , pipeline: (p) => , social: (p) => , calendar: (p) => , inbox: (p) => , finance: (p) => , settings: (p) => , plus: (p) => , search: (p) => , arrow: (p) => , whatsapp: (p) => , mail: (p) => , edit: (p) => , trash: (p) => , external: (p) => , chevL: (p) => , chevR: (p) => , logout: (p) => , send: (p) => , check: (p) => , funnel: (p) => , activity: (p) => , zap: (p) => , plug: (p) => , report: (p) => , bell: (p) => , clock: (p) => , target: (p) => , eye: (p) => , tag: (p) => , form: (p) => , download: (p) => , upload: (p) => , cursor: (p) => , shield: (p) => , shieldCheck: (p) => , lock: (p) => , key: (p) => , users: (p) => , link: (p) => , refresh: (p) => , alert: (p) => , copy: (p) => , history: (p) => , ban: (p) => , dots: (p) => , qr: (p) => , building: (p) => , filter: (p) => , sliders: (p) => , menu: (p) => , info: (p) => , }; /* ------------------------------------------------ toast */ function useToast() { const [toast, setToast] = useState(null); const timer = useRef(null); const show = useCallback((msg) => { setToast(msg); clearTimeout(timer.current); timer.current = setTimeout(() => setToast(null), 2600); }, []); const node = toast ?
{toast}
: null; return [show, node]; } /* ------------------------------------------------ modal */ function Modal({ title, onClose, children, foot, width }) { useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); return (
{ if (e.target === e.currentTarget) onClose(); }}>

{title}

{children}
{foot ?
{foot}
: null}
); } /* ------------------------------------------------ form helpers */ function Field({ label, children }) { return
{children}
; } function Avatar({ name, small }) { return {U.initials(name)}; } function NetChip({ network }) { const n = U.NETWORKS[network] || { short: network, color: '#999' }; return {n.short}; } function StatusBadge({ map, value }) { const m = map[value] || { label: value, cls: 'badge-dim' }; return {m.label}; } const CLIENT_STATUS = { activo: { label: 'Activo', cls: 'badge-ok' }, pausado: { label: 'Pausado', cls: 'badge-warn' }, prospecto: { label: 'Prospecto', cls: 'badge-info' }, finalizado: { label: 'Finalizado', cls: 'badge-dim' }, }; const INVOICE_STATUS = { pendiente: { label: 'Pendiente', cls: 'badge-warn' }, pagada: { label: 'Pagada', cls: 'badge-ok' }, vencida: { label: 'Vencida', cls: 'badge-danger' }, }; const QUOTE_STATUS = { borrador: { label: 'Borrador', cls: 'badge-dim' }, enviado: { label: 'Enviado', cls: 'badge-info' }, aceptado: { label: 'Aceptado', cls: 'badge-ok' }, rechazado: { label: 'Rechazado', cls: 'badge-danger' }, }; const STAGE_BADGE = { prospecto: { label: 'Prospecto identificado', cls: 'badge-dim' }, contactado: { label: 'Contactado', cls: 'badge-info' }, respondio: { label: 'Respondió', cls: 'badge-info' }, reunion: { label: 'Reunión agendada', cls: '' }, diagnostico: { label: 'Diagnóstico realizado', cls: '' }, propuesta: { label: 'Propuesta enviada', cls: 'badge-warn' }, negociacion: { label: 'Negociación', cls: 'badge-warn' }, ganado: { label: 'Ganado', cls: 'badge-ok' }, perdido: { label: 'Perdido', cls: 'badge-danger' }, }; const POST_BADGE = { idea: { label: 'Idea', cls: 'badge-dim' }, diseno: { label: 'Diseño', cls: 'badge-info' }, aprobacion: { label: 'Aprobación', cls: 'badge-warn' }, programado: { label: 'Programado', cls: '' }, publicado: { label: 'Publicado', cls: 'badge-ok' }, }; /* ------------------------------------------------ mini charts (SVG) */ function BarChart({ series, labels, height = 160, colors }) { // series: [{name, values:[...]}], labels: ['Ene',...] const max = Math.max(1, ...series.flatMap((s) => s.values)); const n = labels.length; const groupW = 100 / n; const barW = Math.min(14, (groupW * 0.7) / series.length); return (
{[0.25, 0.5, 0.75].map((f) => ( ))} {labels.map((_, i) => series.map((s, j) => { const h = (s.values[i] / max) * (height / 3 - 6); const x = i * groupW + groupW / 2 - (series.length * barW) / 2 + j * barW; return ; }) )}
{labels.map((l) => {l})}
); } function Donut({ parts, size = 120 }) { // parts: [{value, color, label}] const total = Math.max(1, parts.reduce((a, p) => a + p.value, 0)); const r = 15.9155; let acc = 0; return ( {parts.map((p, i) => { const frac = p.value / total; const el = ( ); acc += frac; return el; })} ); } /* ------------------------------------------------ barra de progreso */ function ProgressBar({ value, max = 100, color }) { const pct = Math.max(0, Math.min(100, (Number(value) / (Number(max) || 1)) * 100)); return
; } /* ------------------------------------------------ hooks de datos */ function useCollection(name) { const [rows, setRows] = useState(null); const reload = useCallback(() => { API[name].list().then(setRows); }, [name]); useEffect(reload, [reload]); return [rows, reload]; } /* ------------------------------------------------ confirmación global (¿estás seguro?) */ let _confirmResolve = null; function ConfirmHost() { const [state, setState] = useState(null); const [typed, setTyped] = useState(''); const finish = useCallback((val) => { setState(null); setTyped(''); if (_confirmResolve) { _confirmResolve(val); _confirmResolve = null; } }, []); useEffect(() => { window.trascendConfirm = (opts) => new Promise((resolve) => { _confirmResolve = resolve; setTyped(''); setState(opts || {}); }); const onKey = (e) => { if (e.key === 'Escape' && _confirmResolve) finish(false); }; window.addEventListener('keydown', onKey); return () => { window.removeEventListener('keydown', onKey); delete window.trascendConfirm; }; }, [finish]); if (!state) return null; const danger = state.danger !== false; const needType = !!state.requireText; const ready = !needType || typed.trim().toUpperCase() === String(state.requireText).toUpperCase(); return (
{ if (e.target === e.currentTarget) finish(false); }}>
{danger ? : }

{state.title || '¿Confirmás la acción?'}

{state.message ?

{state.message}

: null} {needType ? (
setTyped(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && ready) finish(true); }} placeholder={state.requireText} />
) : null}
); } /** Abre el diálogo y resuelve true/false. confirmDialog('texto') o confirmDialog({title,message,...}). */ async function confirmDialog(opts) { if (typeof opts === 'string') opts = { message: opts }; if (!window.trascendConfirm) return true; return await window.trascendConfirm({ danger: false, ...opts }); } /** Confirmación de borrado (rojo). Para datos sensibles pasar requireText:'BORRAR'. */ async function confirmDelete(opts) { if (typeof opts === 'string') opts = { message: opts }; if (!window.trascendConfirm) return true; return await window.trascendConfirm({ danger: true, confirmLabel: 'Sí, borrar', ...opts }); } /* ------------------------------------------------ switch on/off (React) */ function Toggle({ on, onChange, title, disabled }) { return ; } /* ------------------------------------------------ medidor de fuerza de contraseña */ function PwdMeter({ value, settings }) { const s = U.pwdStrength(value, settings); if (!value) return null; const labels = ['Muy débil', 'Débil', 'Aceptable', 'Buena', 'Fuerte']; const colors = ['var(--danger)', 'var(--danger)', 'var(--warn)', 'var(--ok)', 'var(--ok)']; return (
{[0, 1, 2, 3].map((i) => )}
{labels[s.score]}{!s.ok ? ' · mín. ' + s.minLen + (s.needMix ? ', con mayús, minús y número' : ' caracteres') : ''}
); } /* ------------------------------------------------ tick para relojes / códigos vivos */ function useTick(ms) { const [, setN] = useState(0); useEffect(() => { const id = setInterval(() => setN((n) => n + 1), ms || 1000); return () => clearInterval(id); }, [ms]); } /* ------------------------------------------------ botón (i) con explicación de sección */ function InfoButton({ title, children }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; }, [open]); return ( {open ? (
{title ?
{title}
: null}
{children}
) : null}
); } /* ------------------------------------------------ alta de 2FA por el propio usuario */ function randomSecret() { const ch = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let s = ''; for (let i = 0; i < 16; i++) s += ch[Math.floor(Math.random() * ch.length)]; return s; } function TwoFASetup({ user, forced, onClose, onSaved }) { const enabled = !!user.twofa_enabled; const [secret] = useState(() => user.twofa_secret || randomSecret()); const [code, setCode] = useState(''); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); useTick(1000); const demo = window.TRASCEND_BACKEND !== 'mysql'; const current = U.totpCode(secret); const secsLeft = 30 - Math.floor((Date.now() / 1000) % 30); const otpauth = 'otpauth://totp/Trascend:' + encodeURIComponent(user.email) + '?secret=' + secret + '&issuer=Trascend&period=30&digits=6'; const qrUrl = useMemo(() => { try { if (typeof window.qrcode !== 'function') return null; const qr = window.qrcode(0, 'M'); qr.addData(otpauth); qr.make(); return qr.createDataURL(5, 4); } catch (e) { return null; } }, [otpauth]); async function enable() { if (String(code).trim().length !== 6) { setErr('Ingresá los 6 dígitos.'); return; } if (demo && String(code).trim() !== current) { setErr('El código no coincide. Usá el que se muestra arriba.'); return; } setBusy(true); try { await TrascendAPI.users.update(user.id, { twofa_enabled: 1, twofa_secret: secret }); onSaved && onSaved(); onClose(); } catch (e) { setErr(e.message || 'No se pudo activar.'); setBusy(false); } } async function disable() { setBusy(true); try { await TrascendAPI.users.update(user.id, { twofa_enabled: 0, twofa_secret: '' }); onSaved && onSaved(); onClose(); } catch (e) { setErr(e.message || 'No se pudo desactivar.'); setBusy(false); } } return ( {!forced ? : null} ) : ( {!forced ? : null} )}> {enabled ? (
La verificación en 2 pasos está activa en tu cuenta. Al iniciar sesión te pediremos un código de 6 dígitos.
{demo ?
Tu código ahora (demo){current}{secsLeft}s
: null}
) : (

{forced ? 'Tu organización exige 2FA. Configurala para entrar. ' : 'Sumá una capa extra de seguridad. '} Escaneá o ingresá esta clave en una app de autenticación (Google Authenticator, Authy, 1Password…).

{qrUrl ?
Código QR para vincular 2FA
:
}
Escaneá el código Abrí tu app de autenticación y escaneá este QR para vincular la cuenta.
¿No podés escanear? Ingresá la clave manualmente
Clave para tu app
{secret}
{demo ? (
Código actual (modo demo){current}{secsLeft}s
) : null} { setCode(e.target.value.replace(/\D/g, '')); setErr(null); }} placeholder="000000" style={{ letterSpacing: '0.3em', textAlign: 'center', fontSize: '1.1rem' }} /> {err ?
{err}
: null}
)}
); } /* ------------------------------------------------ banner global de error de API Escucha 'trascend:apierror' (lo dispara data/db.js cuando falla un guardado) y lo muestra en pantalla, así un error al guardar deja de fallar en silencio. */ function ApiErrorHost() { const [msg, setMsg] = useState(null); const timer = useRef(null); useEffect(() => { const onErr = (e) => { const m = (e && e.detail && e.detail.message) || 'No se pudo guardar. Reintentá.'; setMsg(m); clearTimeout(timer.current); timer.current = setTimeout(() => setMsg(null), 8000); }; window.addEventListener('trascend:apierror', onErr); return () => { window.removeEventListener('trascend:apierror', onErr); clearTimeout(timer.current); }; }, []); if (!msg) return null; return (
{msg}
); } /* ------------------------------------------------ Importar / Exportar CSV Botón + modal reutilizable. `table` es la clave whitelisted en api.php. Al importar, muestra un resumen (creados / salteados). Al exportar, descarga el CSV en una pestaña nueva. Acepta también .xlsx: si el navegador selecciona uno, se carga SheetJS on-demand y se convierte a CSV antes de enviarlo. */ let __sheetJSPromise = null; function loadSheetJS() { if (window.XLSX) return Promise.resolve(window.XLSX); if (__sheetJSPromise) return __sheetJSPromise; __sheetJSPromise = new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = 'https://unpkg.com/xlsx@0.18.5/dist/xlsx.full.min.js'; s.onload = () => resolve(window.XLSX); s.onerror = () => reject(new Error('No se pudo cargar el motor de Excel (revisá tu conexión).')); document.head.appendChild(s); }); return __sheetJSPromise; } function ImportExportCSV({ table, label, onImported }) { const [open, setOpen] = React.useState(false); const [csv, setCsv] = React.useState(''); const [file, setFile] = React.useState(null); const [busy, setBusy] = React.useState(false); const [result, setResult] = React.useState(null); const [parsing, setParsing] = React.useState(false); const [mode, setMode] = React.useState('skip'); async function pick(e) { const f = e.target.files && e.target.files[0]; if (!f) return; setFile(f); setResult(null); const isExcel = /\.xlsx?$/i.test(f.name) || (f.type && f.type.indexOf('spreadsheet') !== -1); if (isExcel) { setParsing(true); try { const XLSX = await loadSheetJS(); const buf = await f.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const sheetName = wb.SheetNames[0]; if (!sheetName) throw new Error('El Excel no tiene ninguna hoja.'); const csvOut = XLSX.utils.sheet_to_csv(wb.Sheets[sheetName], { FS: ',' }); setCsv(csvOut); } catch (err) { setResult({ error: 'No pudimos leer el Excel: ' + err.message }); setCsv(''); } setParsing(false); } else { const fr = new FileReader(); fr.onload = () => setCsv(String(fr.result || '')); fr.readAsText(f, 'utf-8'); } } async function doImport() { if (!csv.trim()) return; setBusy(true); try { const r = await TrascendAPI.csv.import(table, csv, mode); setResult(r); if (onImported) onImported(r); } catch (e) { setResult({ error: e.message }); } setBusy(false); } return (
{open ? ( setOpen(false)} foot={ }>

Podés subir .csv o .xlsx (Excel se convierte automáticamente). La cabecera debe usar los nombres de columna reales. La plantilla con las columnas correctas la bajás desde Exportar (sale aunque la tabla esté vacía).

{parsing ? Convirtiendo Excel a CSV… : null} {file && !parsing ? {file.name} · {Math.ceil(file.size / 1024)} KB{csv ? ' · ' + (csv.split('\n').length - 1) + ' filas detectadas' : ''} : null}