/* ===================================================================== Trascend · Portal de Cliente — App React ===================================================================== */ const { useState, useEffect, useRef, useCallback } = React; /* ---- constantes de dominio ---------------------------------------- */ const CH_COLOR = { whatsapp:'#1FA855', email:'#2F5FC4' }; const CH_LABEL = { whatsapp:'WA', email:'✉' }; /* ---- conexiones de canales (por cliente, centralizadas en el portal) ---- */ const CONN_STATUS = { connected:{label:'Conectado',cls:'pb-ok'}, pending:{label:'Sin conectar',cls:'pb-warn'}, off:{label:'Desactivado',cls:'pb-dim'} }; const CONN_CATALOG = [ { channel:'whatsapp', name:'WhatsApp Business', color:'#1FA855', short:'WA', desc:'AGE atiende y responde tus chats de WhatsApp.', fields:[{k:'account_ref',label:'Número / Phone Number ID',ph:'+598 99 000 000'},{k:'token',label:'Token de acceso',secret:true}] }, { channel:'instagram', name:'Instagram — Mensajes directos', color:'#C13584', short:'IG', desc:'Responder los DMs de tu cuenta de Instagram.', fields:[{k:'account_ref',label:'Usuario / ID de cuenta',ph:'@tucuenta'},{k:'token',label:'Token (autorización Meta)',secret:true}] }, { channel:'facebook', name:'Facebook Messenger', color:'#1877F2', short:'FB', desc:'Atender el Messenger de tu página de Facebook.', fields:[{k:'account_ref',label:'Page ID',ph:'ID de la página'},{k:'token',label:'Token de página',secret:true}] }, { channel:'email', name:'Email', color:'#2F5FC4', short:'✉', desc:'Responder las consultas que llegan por correo.', fields:[{k:'account_ref',label:'Casilla de correo',ph:'ventas@tuempresa.com'},{k:'token',label:'Contraseña de app / SMTP',secret:true}] }, ]; /* Lo que Trascend vende hoy. Si algo no está en el sitio, no va acá. */ const CROSS_SELL = [ { title:'Otra automatización', desc:'Sacamos otra tarea repetitiva de las manos de tu equipo. Una por vez, con precio cerrado.' }, { title:'Una función de IA', desc:'Clasificar consultas, resumir mensajes o extraer datos de documentos, sobre lo que ya corre.' }, { title:'Revisión QA', desc:'Probamos tu web o sistema de punta a punta y te dejamos los errores ordenados por impacto.' }, { title:'AGE · atención con IA',desc:'Responde por WhatsApp, web y email a cualquier hora, y deriva a una persona cuando no sabe.' }, ]; /* Etapas vistas desde el cliente: lo que Trascend llama pipeline, para él es el avance de su proyecto. Mismos estados, nombres que le sirven a él. */ const STAGE_LABEL = { prospecto:'Por arrancar', contactado:'En relevamiento', propuesta:'Propuesta enviada', negociacion:'En revisión', ganado:'Entregado', perdido:'No avanzó' }; const STAGE_CLS = { prospecto:'pb-dim', contactado:'pb-info', propuesta:'pb-violet', negociacion:'pb-warn', ganado:'pb-ok', perdido:'pb-danger' }; const STAGE_ORD = ['prospecto','contactado','propuesta','negociacion','ganado','perdido']; const REQ_TYPE_LABEL = { consulta:'Consulta', aprobacion:'Aprobación', cambio:'Cambio', material:'Accesos' }; const REQ_TYPE_CLS = { consulta:'pb-info', aprobacion:'pb-violet', cambio:'pb-warn', material:'pb-dim' }; const REQ_STATUS_LABEL = { nueva:'Nueva', en_proceso:'En proceso', resuelta:'Resuelta' }; const REQ_STATUS_CLS = { nueva:'pb-warn', en_proceso:'pb-info', resuelta:'pb-ok' }; const INV_STATUS_LABEL = { pendiente:'Pendiente', pagada:'Pagada', vencida:'Vencida' }; const INV_STATUS_CLS = { pendiente:'pb-warn', pagada:'pb-ok', vencida:'pb-danger' }; const QUOTE_STATUS_LABEL = { borrador:'Borrador', enviado:'Enviado', aceptado:'Aceptado', rechazado:'Rechazado' }; const QUOTE_STATUS_CLS = { borrador:'pb-dim', enviado:'pb-info', aceptado:'pb-ok', rechazado:'pb-danger' }; const CUR = { USD:'US$', ARS:'AR$', UYU:'$U' }; const fmtMoney = (v,c) => `${CUR[c]||c} ${Number(v||0).toLocaleString('es-UY',{maximumFractionDigits:0})}`; const fmtDate = (iso) => { if (!iso) return '—'; const d = new Date(iso.length===10?iso+'T12:00:00':iso); return d.toLocaleDateString('es-UY',{day:'2-digit',month:'short'}); }; const fmtDT = (iso) => { if (!iso) return '—'; const d = new Date(iso); return d.toLocaleDateString('es-UY',{day:'2-digit',month:'short'})+' · '+d.toLocaleTimeString('es-UY',{hour:'2-digit',minute:'2-digit'}); }; const inits = (n) => String(n||'?').split(/\s+/).map(w=>w[0]).slice(0,2).join('').toUpperCase(); /* ---- iconos --------------------------------------------------------- */ const IB = (p) => ; const PI = { home: p=>, deals: p=>, invoice: p=>, request: p=>, message: p=>, logout: p=>, arrow: p=>, plus: p=>, check: p=>, dl: p=>, plug: p=>, spark: p=>, lock: p=>, }; /* ---- primitivas ----------------------------------------------------- */ function useToast() { const [msg, setMsg] = useState(null); const t = useRef(null); const show = useCallback(m => { setMsg(m); clearTimeout(t.current); t.current = setTimeout(()=>setMsg(null),2700); }, []); return [show, msg ?
{msg}
: null]; } function PBadge({ cls, label }) { return {label}; } function PAvatar({ name, small }) { return
{inits(name)}
; } /* ===================================================================== LOGIN ===================================================================== */ function LoginScreen({ onLogin }) { const [email, setEmail] = useState(''); const [pass, setPass] = useState(''); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); function submit(e) { e.preventDefault(); setErr(null); setBusy(true); PortalAuth.login(email, pass).then((r) => { setBusy(false); if (r && r.ok) { onLogin(r.portalUser); return; } var msg = (r && r.error) || ''; var isNet = /conect|network|servidor|conexión|http\s*5\d\d|\b50\d\b|502|503|504/i.test(msg); setErr(isNet ? 'No se pudo conectar con el servidor. Reintentá en unos segundos.' : 'Dato ingresado incorrecto, porfavor revise'); }).catch(() => { setBusy(false); setErr('No se pudo conectar con el servidor. Reintentá en unos segundos.'); }); } return (
TrascendPortal de cliente

Accedé a tu portal

Seguí tu operación, mirá tus facturas y envianos lo que necesites.

{err &&
{err}
}
setEmail(e.target.value)} placeholder="tu@empresa.com" />
setPass(e.target.value)} placeholder="••••••••" />
); } /* ===================================================================== SHELL ===================================================================== */ /* ===================================================================== Mi operación · lo que el cliente puede supervisar sin preguntar: qué automatizaciones tiene corriendo, cuántas horas le devuelven y en qué estado está la verificación de su sistema. ===================================================================== */ const OP_FSTATUS = { produccion:{l:'Funcionando',c:'p-ok'}, pruebas:{l:'En pruebas',c:'p-info'}, pausado:{l:'Pausado',c:'p-warn'}, diseno:{l:'En preparación',c:'p-dim'}, retirado:{l:'Retirado',c:'p-dim'} }; const OP_SEV = { critica:{l:'Crítica',c:'p-danger'}, alta:{l:'Alta',c:'p-warn'}, media:{l:'Media',c:'p-info'}, baja:{l:'Baja',c:'p-dim'} }; const OP_FIND = { abierto:{l:'Lo estamos viendo',c:'p-warn'}, cliente:{l:'Esperando tu equipo',c:'p-info'}, reprueba:{l:'A reprobar',c:'p-info'}, cerrado:{l:'Resuelto',c:'p-ok'} }; function OperacionPage({ user, setRoute }) { const flows = PortalData.getFlows(user.clientId); const runs = PortalData.getFlowRuns(user.clientId); const suites = PortalData.getQaSuites(user.clientId); const qaRuns = PortalData.getQaRuns(user.clientId); const findings = PortalData.getQaFindings(user.clientId); const live = flows.filter(f => f.status === 'produccion'); const savedH = live.reduce((a, f) => a + (Number(f.saved_h_month) || 0), 0); const okRuns = runs.filter(r => r.status !== 'error').length; const okRate = runs.length ? Math.round((okRuns / runs.length) * 100) : 100; const openF = findings.filter(f => f.status !== 'cerrado'); const critical = openF.filter(f => f.severity === 'critica').length; const fmt = (d) => { try { return new Date(d).toLocaleString('es-UY', { day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' }); } catch(e) { return String(d).slice(0,16).replace('T',' '); } }; if (!flows.length && !suites.length) { return (

Todavía no hay nada publicado acá

Cuando pongamos en marcha tu primera automatización o verificación, vas a poder ver acá su estado en tiempo real, sin tener que preguntarnos.

); } return (
Horas que te devolvemos
{savedH} h
por mes, estimadas sobre tu proceso
Automatizaciones activas
{live.length}
de {flows.length} configuradas
Ejecuciones sin error
{okRate}%
sobre las últimas {runs.length}
Cosas por resolver
{openF.length}
{critical ? critical + ' crítica(s)' : 'ninguna urgente'}
{!!flows.length &&

Tus automatizaciones

Qué está corriendo ahora mismo
{flows.map(f => { const rs = runs.filter(r => r.flow_id === f.id); const last = rs[0]; const st = OP_FSTATUS[f.status] || OP_FSTATUS.diseno; return (
{f.name} {last ? 'Última vez: ' + fmt(last.ran_at) : 'Sin ejecuciones todavía'}
{f.saved_h_month} h/mes {st.l}
); })}
} {!!suites.length &&

Verificación de tu sistema

Lo probamos por vos, con esta frecuencia
{suites.map(s => { const last = qaRuns.filter(r => r.suite_id === s.id)[0]; const op = openF.filter(f => f.suite_id === s.id).length; return (
{s.name} {last ? 'Última verificación: ' + fmt(last.started_at) + ' · ' + last.passed + ' de ' + (last.passed + last.failed) + ' correctos' : 'Todavía sin verificar'}
{op ? op + ' pendiente(s)' : 'Todo en orden'}
); })}
} {!!openF.length &&

Qué encontramos

Ordenado por lo que más te afecta
{openF.slice().sort((a,b) => (OP_SEV[b.severity]? Object.keys(OP_SEV).indexOf(a.severity) - Object.keys(OP_SEV).indexOf(b.severity) : 0)).map(f => { const sv = OP_SEV[f.severity] || OP_SEV.baja; const st = OP_FIND[f.status] || OP_FIND.abierto; return (
{f.title} {f.impact && {f.impact}}
{sv.l} {st.l}
); })}
} {!!runs.length &&

Actividad reciente

Cada vez que una automatización trabajó por vos
{runs.slice(0, 12).map(r => { const fl = flows.find(f => f.id === r.flow_id) || {}; const bad = r.status === 'error'; return (
{fl.name || '—'} {fmt(r.ran_at)}{r.items ? ' · ' + r.items + ' ítem(s) procesado(s)' : ''}{bad ? ' · ' + r.error : ''}
{bad ? 'Con error' : 'OK'}
); })}
}
); } const TITLES = { inicio:'Inicio', operacion:'Mi operación', proyectos:'Mis proyectos', facturas:'Facturas', peticiones:'Peticiones', mensajes:'Mensajes', conexiones:'Canales & conexiones' }; const NAV = [ { key:'inicio', label:'Inicio', Icon:PI.home }, { key:'operacion', label:'Mi operación', Icon:PI.spark }, { key:'proyectos', label:'Mis proyectos', Icon:PI.deals }, { key:'conexiones', label:'Conexiones', Icon:PI.plug }, { key:'facturas', label:'Facturas', Icon:PI.invoice }, { key:'peticiones', label:'Peticiones', Icon:PI.request }, { key:'mensajes', label:'Mensajes', Icon:PI.message }, ]; function Shell({ user, route, setRoute, onLogout, pendingReq, children }) { return (

{TITLES[route]||route}

Portal del cliente · demo
{children}
); } /* ===================================================================== HOME ===================================================================== */ /* Guía de bienvenida · se muestra hasta que el cliente la cierra */ const P_GUIDE_KEY = 'trascend_portal_guide_v1'; const P_SEEN_KEY = 'trascend_portal_seen_v1'; /* Onboarding del cliente · no es un texto para leer: son cuatro pasos que se marcan solos con el estado real de su cuenta. Cuando los cuatro están hechos, la tarjeta se apaga y no vuelve. */ function PortalGuide({ user, setRoute }) { const cid = user.clientId; const kSeen = P_SEEN_KEY + ':' + cid; const kOff = P_GUIDE_KEY + ':' + cid; const [dismissed, setDismissed] = React.useState(() => { try { return localStorage.getItem(kOff) === 'off'; } catch (e) { return false; } }); const [seen, setSeen] = React.useState(() => { try { return JSON.parse(localStorage.getItem(kSeen) || '[]'); } catch (e) { return []; } }); const conns = PortalData.getConnections(cid) || []; const requests = PortalData.getRequests(cid) || []; const flows = PortalData.getFlows(cid) || []; const runs = PortalData.getFlowRuns(cid) || []; const mark = (key, route) => { const next = seen.includes(key) ? seen : seen.concat(key); setSeen(next); try { localStorage.setItem(kSeen, JSON.stringify(next)); } catch (e) {} setRoute(route); }; const steps = [ { k: 'conexiones', r: 'conexiones', t: 'Conectá tus canales', d: 'Vinculás tu WhatsApp y tus redes para que trabajemos sobre tus cuentas, no sobre las nuestras.', done: conns.some((c) => c.status === 'connected'), hint: 'Todo queda a tu nombre y podés revocarlo cuando quieras.' }, { k: 'operacion', r: 'operacion', t: 'Mirá qué está corriendo', d: 'En Mi operación ves las automatizaciones activas y las horas que te devolvieron este mes.', done: seen.includes('operacion'), hint: flows.length ? flows.length + ' automatización(es) en tu cuenta' : 'Todavía no hay nada instalado' }, { k: 'peticiones', r: 'peticiones', t: 'Dejá tu primer pedido', d: 'Lo que necesites queda registrado con su estado. Sin cadenas de mails ni recordarle a nadie.', done: requests.length > 0, hint: requests.length ? requests.length + ' pedido(s) registrados' : 'Probalo con cualquier cosa: se responde igual' }, { k: 'proyectos', r: 'proyectos', t: 'Seguí tu proyecto', d: 'El avance de lo que nos encargaste, con su alcance acordado y la fecha estimada de entrega.', done: seen.includes('proyectos'), hint: runs.length ? runs.length + ' ejecución(es) registradas' : 'Sin ejecuciones todavía' }, ]; const doneN = steps.filter((s) => s.done).length; const complete = doneN === steps.length; if (dismissed) return null; const close = () => { setDismissed(true); try { localStorage.setItem(kOff, 'off'); } catch (e) {} }; return (
{complete ? 'Ya está todo listo' : 'Poner tu cuenta en marcha'}
{complete ? 'Completaste los cuatro pasos. Podés cerrar esto: tu portal queda igual.' : 'Cuatro pasos. Se marcan solos a medida que los hacés.'}
{doneN}/{steps.length}
{steps.map((it, i) => ( ))}
); } function HomePage({ user, setRoute }) { const cid = user.clientId; const deals = PortalData.getDeals(cid); const invoices = PortalData.getInvoices(cid); const requests = PortalData.getRequests(cid); const flows = PortalData.getFlows(cid) || []; const runs = PortalData.getFlowRuns(cid) || []; const activeDeals = deals.filter(d=>!['ganado','perdido'].includes(d.stage)).length; const liveFlows = flows.filter(f=>f.status==='produccion').length; const savedH = flows.reduce((a,f)=>a+Number(f.hours_saved_month||0),0); const pendInvoices = invoices.filter(i=>['pendiente','vencida'].includes(i.status)).length; const openRequests = requests.filter(r=>r.status!=='resuelta').length; /* Lo último que hizo su operación: la prueba de que corre sin que él mire. */ const lastRuns = runs.slice().sort((a,b)=>String(b.ran_at).localeCompare(String(a.ran_at))).slice(0,4); const flowName = (id) => (flows.find(f=>f.id===id)||{}).name || 'Automatización'; const recentReqs = requests.slice(0,3); return (

Hola, {user.contactName.split(' ')[0]} 👋

Resumen del trabajo que estamos haciendo para {user.client.name}.

setRoute('operacion')}>
Horas devueltas por mes
{savedH}
setRoute('operacion')}>
Automatizaciones corriendo
{liveFlows}
0?' is-danger':'')} onClick={()=>setRoute('facturas')}>
Facturas pendientes
{pendInvoices}
setRoute('peticiones')}>
Solicitudes abiertas
{openRequests}

Lo último que corrió

{lastRuns.length===0 ?
Todavía no hay ejecucionesCuando tu primera automatización entre en producción, aparecen acá.
: lastRuns.map(r=>(
{r.status==='ok'?'✓':'!'}
{flowName(r.flow_id)}
{r.status==='ok' ? (Number(r.items||0)+' elemento(s) procesados sin error') : (r.error || 'Terminó con error, lo estamos viendo')}
{fmtDT(r.ran_at)}
)) }

Mis solicitudes

{recentReqs.length===0 ?
Sin solicitudes aún
: recentReqs.map(r=>(
{fmtDate(r.created_at)}
{r.title}
{r.team_note &&
Trascend: {r.team_note}
}
)) }
); } /* ===================================================================== PROYECTOS — el avance de lo que nos encargó ===================================================================== */ function ProyectosPage({ user }) { const deals = PortalData.getDeals(user.clientId); return (

Tus proyectos

Alcance y precio se acuerdan por escrito antes de arrancar
{deals.length===0 ?
Sin proyectos registradosCuando arranquemos el primero, su avance aparece acá.
: ( {deals.map(d=>{ const idx = STAGE_ORD.indexOf(d.stage); const pct = d.stage==='perdido' ? 0 : d.stage==='ganado' ? 100 : Math.round(((idx+1)/5)*100); return ( ); })}
ProyectoValorAvanceEstadoEntrega est.
{d.title} {fmtMoney(d.value,d.currency)} {!['ganado','perdido'].includes(d.stage) &&
}
{STAGE_LABEL[d.stage]}
{fmtDate(d.expected_close)}
)}
); } /* ===================================================================== FACTURAS Y PRESUPUESTOS — lo que se le cobró y lo que se le propuso ===================================================================== */ function FacturasPage({ user }) { const invoices = PortalData.getInvoices(user.clientId); const quotes = PortalData.getQuotes(user.clientId); const pend = invoices.filter(i=>['pendiente','vencida'].includes(i.status)); return (

Facturas

{pend.length > 0 && }
{invoices.length===0 ?
Sin facturas todavíaCuando emitamos la primera, aparece acá con su estado.
: ( {invoices.slice().sort((a,b)=>String(b.issued_at||'').localeCompare(String(a.issued_at||''))).map(i=>( ))}
NúmeroConceptoEmitidaVenceImporteEstado
{i.number || '—'} {i.concept} {fmtDate(i.issued_at)} {fmtDate(i.due_at)} {fmtMoney(i.amount,i.currency)}
)}

Presupuestos

Alcance y precio cerrados antes de arrancar
{quotes.length===0 ?
Sin presupuestosLos que te enviemos quedan acá, con su estado.
: ( {quotes.slice().sort((a,b)=>String(b.valid_until||'').localeCompare(String(a.valid_until||''))).map(q=>( ))}
NúmeroConceptoVálido hastaImporteEstado
{q.number || '—'} {q.concept} {fmtDate(q.valid_until)} {fmtMoney(q.amount,q.currency)}
)}
); } /* ===================================================================== PETICIONES — lo que necesita, registrado y con estado ===================================================================== */ function PeticionesPage({ user }) { const [reqs, setReqs] = useState(()=>PortalData.getRequests(user.clientId)); const [sf, setSF] = useState(false); const [toast, toastNode] = useToast(); const [f, setF] = useState({ title:'', type:'consulta', description:'' }); const set = (k) => (e) => setF({ ...f, [k]: e.target.value }); const reload = ()=>setReqs(PortalData.getRequests(user.clientId)); async function send() { if (f.title.trim().length < 3) { toast('Ponele un título para saber de qué se trata'); return; } await PortalData.createRequest(user.clientId, user.contactId, f); setF({ title:'', type:'consulta', description:'' }); setSF(false); reload(); toast('Pedido registrado — te respondemos por acá'); } return (

Mis solicitudes

Lo que necesites queda registrado con su estado. Sin cadenas de mails ni recordarle a nadie.

{sf && (