diff --git a/admin/DashboardController.php b/admin/DashboardController.php index e346bf8..d82aad5 100644 --- a/admin/DashboardController.php +++ b/admin/DashboardController.php @@ -51,26 +51,9 @@ class DashboardController http_response_code(200); header('Content-Type: text/html; charset=utf-8'); + echo Layout::open('Live Feed', 'live', $user['name'] ?? 'Admin'); echo << - - - - - Live — Palmas360 - - - -
-
-

Palmas360 Live Feed

-
-
- 👤 {$userName} - Salir -
-
- - +
- Conectado — actualizando cada 3s + Conectando... + 0 eventos

Esperando eventos en tiempo real...

@@ -153,55 +107,68 @@ class DashboardController const ICONS = {text:'💬',image:'📷',audio:'🎵',video:'🎬',document:'📄',sticker:'🌞',reaction:'👍',location:'📍',interactive:'🔘',button:'🔲',status:'📊'}; const BADGE = t => 'b-'+(ICONS[t]?t:'other'); -let lastId = 0; -let paused = false; -let count = 0; -let interval = null; +let paused = false; +let count = 0; +let es = null; function togglePause() { paused = !paused; document.getElementById('btnPause').innerHTML = paused ? '▶ Reanudar' : '▮▮ Pausar'; document.getElementById('dot').className = 'dot' + (paused ? ' paused' : ''); - document.getElementById('status').textContent = paused ? 'En pausa' : 'Conectado \u2014 actualizando cada 3s'; + if (paused) { document.getElementById('status').textContent = 'En pausa'; es && es.close(); } + else { connect(); } } -async function poll() { - if (paused) return; - try { - const r = await fetch('/admin/webhook/stream?after=' + lastId); - if (!r.ok) return; - const rows = await r.json(); - if (!rows.length) return; - lastId = rows[rows.length - 1].id; - const feed = document.getElementById('feed'); - const empty = feed.querySelector('.empty'); - if (empty) empty.remove(); - rows.forEach(row => { - count++; - const icon = ICONS[row.message_type] || '📡'; - const badge = BADGE(row.message_type); - const card = document.createElement('div'); - card.className = 'card'; - card.innerHTML = ` -
\${icon}
-
-
- \${esc(row.from_number)} - \${esc(row.contact_name)} - \${esc(row.message_type)} - \${esc(row.received_at.substr(11,8))} - -
-
\${esc(row.message_preview)}
-
`; - feed.insertBefore(card, feed.firstChild); - }); - document.getElementById('counter').textContent = count + ' evento' + (count !== 1 ? 's' : ''); - } catch(e) {} +function addCards(rows) { + if (!rows.length) return; + const feed = document.getElementById('feed'); + const empty = feed.querySelector('.empty'); + if (empty) empty.remove(); + rows.forEach(row => { + count++; + const icon = ICONS[row.message_type] || '📡'; + const card = document.createElement('div'); + card.className = 'feed-card'; + card.innerHTML = ` +
\${icon}
+
+
+ \${esc(row.from_number)} + \${esc(row.contact_name)} + \${esc(row.message_type)} + \${esc(row.received_at.substr(11,8))} + +
+
\${esc(row.message_preview)}
+
`; + feed.insertBefore(card, feed.firstChild); + }); + document.getElementById('counter').textContent = count + ' evento' + (count !== 1 ? 's' : ''); + if (document.getElementById('chkSound').checked) { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const osc = ctx.createOscillator(); const g = ctx.createGain(); + osc.connect(g); g.connect(ctx.destination); + osc.frequency.value = 880; g.gain.setValueAtTime(0.08, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.12); + osc.start(); osc.stop(ctx.currentTime + 0.12); + } +} + +function connect() { + es = new EventSource('/admin/webhook/stream/sse'); + es.onopen = () => { + document.getElementById('dot').className = 'dot'; + document.getElementById('status').textContent = 'Conectado β€” tiempo real (SSE)'; + }; + es.onmessage = e => { if (!paused) try { addCards(JSON.parse(e.data)); } catch(_) {} }; + es.onerror = () => { + document.getElementById('dot').className = 'dot paused'; + document.getElementById('status').textContent = 'Reconectando...'; + }; } function esc(s) { - return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + return String(s||'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'})[c]); } async function showRaw(id) { @@ -209,17 +176,13 @@ async function showRaw(id) { document.getElementById('modal').style.display = 'flex'; try { const r = await fetch('/admin/webhook/raw?id=' + id); - const j = await r.json(); - document.getElementById('mpre').textContent = JSON.stringify(j, null, 2); + document.getElementById('mpre').textContent = JSON.stringify(await r.json(), null, 2); } catch(e) { document.getElementById('mpre').textContent = 'Error.'; } } function closeModal() { document.getElementById('modal').style.display = 'none'; } document.addEventListener('keydown', e => { if(e.key==='Escape') closeModal(); }); -// Arrancar: obtener el ΓΊltimo ID y luego empezar a hacer poll -fetch('/admin/webhook/stream?after=0&init=1').then(r=>r.json()).then(rows=>{ - if(rows.length) lastId = rows[rows.length-1].id; -}).finally(() => { interval = setInterval(poll, 3000); }); +connect(); @@ -227,6 +190,57 @@ HTML; exit; } + // ─── GET /admin/webhook/stream/sse β€” Server-Sent Events ───────────────── + + public static function streamSse(): void + { + SessionAuth::require(); + + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('X-Accel-Buffering: no'); // disable nginx buffering + header('Connection: keep-alive'); + + set_time_limit(0); + ignore_user_abort(false); + + $lastId = max(0, (int)($_GET['after'] ?? 0)); + + // Anchor to latest ID if no after param + if ($lastId === 0) { + try { + $row = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1')->fetch(); + $lastId = $row ? (int)$row['id'] : 0; + } catch (\PDOException $e) {} + } + + while (!connection_aborted()) { + try { + $stmt = db()->prepare( + 'SELECT id, event_field, from_number, contact_name, + message_type, message_preview, received_at + FROM webhook_logs WHERE id > ? ORDER BY id ASC LIMIT 20' + ); + $stmt->execute([$lastId]); + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + if ($rows) { + $lastId = (int)end($rows)['id']; + echo 'data: ' . json_encode($rows, JSON_UNESCAPED_UNICODE) . "\n\n"; + } else { + echo ": heartbeat\n\n"; // keep connection alive + } + } catch (\PDOException $e) { + echo ": db-error\n\n"; + } + + ob_flush(); + flush(); + sleep(2); + } + exit; + } + // ─── GET /admin/webhook/stream?after=N ─────────────────────────────────── public static function stream(): void @@ -427,12 +441,6 @@ HTML; $showCompany = empty($v['companyId']); $companyTh = $showCompany ? 'Empresa' : ''; - // Active nav link - $navDashboard = 'active'; - $navLive = ''; - $navPending = ''; - $navCompanies = ''; - // Filas de la tabla $rows = ''; if (empty($v['logs'])) { @@ -475,38 +483,20 @@ HTML; $pager .= ''; } + echo Layout::open('Admin', 'dashboard', $v['user']['name'] ?? 'Admin', $sPending); echo << - - - - - Admin — Palmas360 - - - - -
-
-

Palmas360 Palmas360

- Somos19D -
-
- πŸ‘€ {$userName} - Salir -
-
- - + /* Drawer cards */ + .dp-card{background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:14px;transition:opacity .3s} + .dp-top{display:flex;align-items:center;gap:8px;margin-bottom:6px} + .dp-phone{font-family:monospace;font-size:12px;font-weight:600;color:#3d4552} + .dp-name{font-size:12px;color:#5f6b7a;flex:1} + .dp-time{font-size:11px;color:#a0a8b8} + .dp-in{font-size:12px;color:#5f6b7a;background:#fff;border-radius:6px;padding:6px 10px;margin-bottom:4px;border:1px solid #eef1f5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .dp-resp{font-size:12px;color:#1a7a3a;background:#e8f8f0;border-radius:6px;padding:6px 10px;margin-bottom:10px;border:1px solid #a3e4bc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .dp-actions{display:flex;gap:8px} + .dp-approve{flex:1;background:#166534;color:#fff;border:none;border-radius:8px;padding:8px;font-size:12px;font-weight:700;cursor:pointer;transition:background .15s} + .dp-approve:hover{background:#1a7a3a} + .dp-reject{flex:1;background:#991b1b;color:#fff;border:none;border-radius:8px;padding:8px;font-size:12px;font-weight:700;cursor:pointer;transition:background .15s} + .dp-reject:hover{background:#b91c1c} + .dp-approve:disabled,.dp-reject:disabled{opacity:.5;cursor:not-allowed} + @media(max-width:768px){ + .stats{grid-template-columns:repeat(2,1fr);padding:14px 16px 0} + .fbar,.stats,.tw{padding-left:12px;padding-right:12px;margin-left:0;margin-right:0} + .tw{margin:8px 12px 16px} + th:nth-child(4),td:nth-child(4){display:none} + #drawer{width:100vw} + } +
-
{$sTotal}
πŸ“‘ Total hoy
-
{$sMsgs}
πŸ’¬ Mensajes texto
-
{$sMedia}
πŸ“Ž Multimedia
-
{$sStatuses}
πŸ“Š Estados
-
{$sPending}
⏳ Pendientes aprobación
-
{$companyCount}
🏒 Empresas
+
{$sTotal}
πŸ“‘ Total hoy
+
{$sMsgs}
πŸ’¬ Mensajes texto
+
{$sMedia}
πŸ“Ž Multimedia
+
{$sStatuses}
πŸ“Š Estados
+
{$sPending}
⏳ Pendientes (clic para ver)
+
{$companyCount}
🏒 Empresas
+
+ + + +
@@ -670,7 +643,63 @@ async function showRaw(id) { function closeModal() { document.getElementById('modal').style.display = 'none'; } -document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); +document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal(); closeDrawer(); } }); + +// ── Drawer de pendientes ───────────────────────────────────────────────────── +function openDrawer() { + document.getElementById('drawer').style.display = 'flex'; + document.getElementById('drawerOverlay').style.display = 'block'; + refreshDrawer(); +} +function closeDrawer() { + document.getElementById('drawer').style.display = 'none'; + document.getElementById('drawerOverlay').style.display = 'none'; +} +async function refreshDrawer() { + const body = document.getElementById('drawerBody'); + body.innerHTML = '
Cargando...
'; + try { + const r = await fetch('/admin/pending-list'); + const { items } = await r.json(); + if (!items || !items.length) { + body.innerHTML = '
βœ… Sin pendientes
'; + return; + } + body.innerHTML = items.map(it => ` +
+
+ ${esc(it.phone)} + ${esc(it.contact_name || '')} + ${(it.created_at||'').substr(11,5)} +
+
↩ ${esc(it.incoming_message || '')}
+
πŸ€– ${esc(getPreview(it.bot_response))}
+
+ + +
+
`).join(''); + } catch(e) { + body.innerHTML = '
Error al cargar
'; + } +} +function getPreview(resp) { + if (!resp) return ''; + try { const p = JSON.parse(resp); return p.payload || p.text || JSON.stringify(p).substring(0,80); } catch(_) { return String(resp).substring(0,80); } +} +async function pendingAction(id, action, btn) { + btn.disabled = true; + const card = document.getElementById('dp-' + id); + try { + const r = await fetch('/admin/pending-' + action, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id}) }); + const j = await r.json(); + if (j.status === action + 'd' || j.status) { + card.style.opacity = '0'; + card.style.transition = 'opacity .3s'; + setTimeout(() => card.remove(), 300); + } else { btn.disabled = false; } + } catch(e) { btn.disabled = false; alert('Error de conexiΓ³n'); } +} (function () { const titleStyle = 'color: #1e88e5; font-size: 14px; font-weight: 700;'; @@ -710,6 +739,15 @@ HTML; // ─── GET /admin/pending ──────────────────────────────────────────────── + public static function pendingList(): void + { + SessionAuth::require(); + header('Content-Type: application/json'); + $items = PendingApproval::findAll('pending'); + echo json_encode(['items' => $items], JSON_UNESCAPED_UNICODE); + exit; + } + public static function pending(): void { SessionAuth::require(); @@ -719,37 +757,13 @@ HTML; $items = $companyId ? PendingApproval::findByCompany($companyId, 'pending') : PendingApproval::findAll('pending'); $companies = CompanyRepository::findAll(); + $sPending = PendingApproval::countPending(null); http_response_code(200); header('Content-Type: text/html; charset=utf-8'); + echo Layout::open('Pendientes de AprobaciΓ³n', 'pending', $user['name'] ?? 'Admin', $sPending); echo << - - - - - Pendientes — Palmas360 - - - -
-
-

Palmas360 Pendientes de AprobaciΓ³n

-
-
- 👤 {$userName} - Salir -
-
- +
@@ -880,84 +869,17 @@ HTML; http_response_code(200); header('Content-Type: text/html; charset=utf-8'); + echo Layout::open('Empresas', 'companies', $user['name'] ?? 'Admin'); echo << - - - - - Empresas — Palmas360 - - - -
-
-

Palmas360 Empresas

-
-
- 👤 {$userName} - Salir -
-
- +
{$toastHtml}
@@ -1046,70 +968,16 @@ HTML; http_response_code(200); header('Content-Type: text/html; charset=utf-8'); + echo Layout::open($pageTitle, 'companies', $user['name'] ?? 'Admin'); echo << - - - - - {$pageTitle} — Palmas360 - - - -
-
-

Palmas360 {$pageTitle}

-
-
- 👤 {$userName} - Salir -
-
- +

{$pageTitle}

@@ -1204,61 +1072,12 @@ HTML; http_response_code(200); header('Content-Type: text/html; charset=utf-8'); + echo Layout::open('Sincronizar Empresas', 'companies', $user['name'] ?? 'Admin'); echo << - - - - - Sincronizar — Palmas360 - - - -
-
-

Palmas360 Sincronizar Empresas

-
-
- 👤 {$userName} - Salir -
-
- +
{$icon}
@@ -1291,60 +1110,12 @@ HTML; http_response_code(200); header('Content-Type: text/html; charset=utf-8'); + echo Layout::open('Procesar Cola de Mensajes', 'dashboard', $user['name'] ?? 'Admin'); echo << - - - - - Procesar Cola — Palmas360 - - - -
-
-

Palmas360 Procesar Cola de Mensajes

-
-
- 👤 {$userName} - Salir -
-
- +
{$icon}
@@ -1397,30 +1168,10 @@ HTML; http_response_code(200); header('Content-Type: text/html; charset=utf-8'); + echo Layout::open('ConfiguraciΓ³n', 'settings', $user['name'] ?? 'Admin'); echo << - - - - - ConfiguraciΓ³n — Palmas360 - - - -
-
-

Palmas360 ConfiguraciΓ³n

-
-
- 👤 {$userName} - Salir -
-
- +
{$toastHtml} @@ -1525,26 +1248,10 @@ HTML; http_response_code(200); header('Content-Type: text/html; charset=utf-8'); + echo Layout::open('Chat', 'chat', $user['name'] ?? 'Admin'); echo << - - - - - Chat — Palmas360 - - - -
-
-

Palmas360 Chat

-
-
- 👤 {$userName} - Salir -
-
- + @media(max-width:768px){ + .conv-sidebar{width:100%;min-width:auto} + .chat-main{display:none} + .chat-main.mobile-open{display:flex;position:fixed;inset:0;z-index:200;top:0} + .conv-sidebar.mobile-hidden{display:none} + .back-btn{display:flex} + } + .back-btn{display:none;background:none;border:none;color:#0b3d91;font-size:20px;cursor:pointer;padding:4px 8px;border-radius:6px;line-height:1} + .back-btn:hover{background:#f0f4ff} +
-
+
+
@@ -1690,9 +1383,8 @@ async function loadConvs() { // ─── Select conversation ───────────────────────────────────────────────────── function selectConv(phone, name, company) { activePhone = phone; - document.querySelectorAll('.conv-item').forEach(el => el.classList.remove('active')); document.querySelectorAll('.conv-item').forEach(el => { - if (el.querySelector('.conv-phone')?.textContent === phone) el.classList.add('active'); + el.classList.toggle('active', el.querySelector('.conv-phone')?.textContent === phone); }); document.getElementById('chatHeader').classList.add('show'); document.getElementById('chatEmpty').classList.remove('show'); @@ -1701,10 +1393,20 @@ function selectConv(phone, name, company) { document.getElementById('chatName').textContent = name; document.getElementById('chatPhone').textContent = phone; document.getElementById('chatCompanyTag').textContent = company; + // mobile: hide sidebar, show chat fullscreen + if (window.innerWidth <= 768) { + document.getElementById('convSidebar').classList.add('mobile-hidden'); + document.getElementById('chatMain').classList.add('mobile-open'); + } loadMessages(phone); document.getElementById('msgInput').focus(); } +function closeMobileChat() { + document.getElementById('convSidebar').classList.remove('mobile-hidden'); + document.getElementById('chatMain').classList.remove('mobile-open'); +} + // ─── Load messages ─────────────────────────────────────────────────────────── async function loadMessages(phone) { const r = await fetch('/admin/chat/messages?phone=' + encodeURIComponent(phone)); @@ -1821,6 +1523,13 @@ function formatDate(d) { return dt.getDate().toString().padStart(2,'0')+'/'+(dt.getMonth()+1).toString().padStart(2,'0')+'/'+dt.getFullYear(); } +// ─── Auto-resize textarea ──────────────────────────────────────────────────── +const msgInput = document.getElementById('msgInput'); +msgInput.addEventListener('input', function() { + this.style.height = 'auto'; + this.style.height = Math.min(this.scrollHeight, 120) + 'px'; +}); + loadConvs(); setInterval(loadConvs, 15000); @@ -1968,49 +1677,16 @@ HTML; http_response_code(200); header('Content-Type: text/html; charset=utf-8'); + echo Layout::open('Configurar Bot', 'bot-config', $user['name'] ?? 'Admin'); echo << - - - - -Configurar Bot — Palmas360 - - -
-

Palmas360 Configurar Bot

-
👤 {$userName}Salir
-
-
{$toastHtml}
@@ -2273,28 +1931,51 @@ HTML;
βš™οΈ General
-
-
- - -
Primer mensaje que recibe un usuario nuevo
+
+
+
+ + +
Primer mensaje que recibe un usuario nuevo
+
+
+ + +
Cuando el bot no entiende el mensaje
+
+
+ + +
URL a la que se notificarΓ‘ cuando se requiera aprobaciΓ³n
+
+
+ + +
NΓΊmeros que comiencen con estos prefijos no recibirΓ‘n respuestas del bot
+
-
- - -
Cuando el bot no entiende el mensaje
+ +
+
Vista previa
+
+
+
{$cName[0]}
+
{$cName}
en lΓ­nea
+
+
+
+
{$greetingVal}
+
12:00
+
+
+
{$fallbackVal}
+
fallback
+
+
+
+
Vista previa aproximada
-
- - -
URL a la que se notificarΓ‘ cuando se requiera aprobaciΓ³n
-
-
- - -
NΓΊmeros que comiencen con estos prefijos no recibirΓ‘n respuestas del bot
-
@@ -2392,6 +2073,15 @@ function flowTypeChange(sel) { card.querySelector('.flow-function').style.display = sel.value === 'function' ? '' : 'none'; card.querySelector('.flow-menu').style.display = sel.value === 'menu' ? '' : 'none'; } + +function updatePreview() { + const g = document.getElementById('prevGreeting')?.value || ''; + const f = document.getElementById('prevFallback')?.value || ''; + const gd = document.getElementById('prevGreetingDisplay'); + const fd = document.getElementById('prevFallbackDisplay'); + if (gd) gd.textContent = g || '(vacΓ­o)'; + if (fd) fd.textContent = f || '(vacΓ­o)'; +} @@ -2424,29 +2114,11 @@ HTML; http_response_code(200); header('Content-Type: text/html; charset=utf-8'); + echo Layout::open('Hilo Conductor', 'conv-flow', $user['name'] ?? 'Admin'); echo << - - - - -Hilo Conductor — Palmas360 - - -
-

Palmas360 Hilo Conductor

-
👤 {$userName}Salir
-
-
{$toastHtml}
diff --git a/admin/Layout.php b/admin/Layout.php new file mode 100644 index 0000000..63e244c --- /dev/null +++ b/admin/Layout.php @@ -0,0 +1,81 @@ + $group) { + if ($gi > 0) { + $navHtml .= ''; + } + foreach ($group as $item) { + [$key, $href, $icon, $label] = $item; + $badge = isset($item[4]) && $item[4] > 0 + ? '' . (int)$item[4] . '' + : ''; + $cls = $active === $key ? ' active' : ''; + $navHtml .= "{$icon} {$label}{$badge}"; + } + } + + $navHtml .= ''; + $navHtml .= 'πŸ”„'; + $navHtml .= 'πŸ“¨'; + + return << + + + + + {$t} — Palmas360 + + + +
+
+ +

Palmas360 {$t}

+
+
+ 👤 {$u} + Salir +
+
+ +HTML; + } +} diff --git a/assets/app.css b/assets/app.css new file mode 100644 index 0000000..5304cf8 --- /dev/null +++ b/assets/app.css @@ -0,0 +1,120 @@ +/* ── Reset ─────────────────────────────────────────────────────────────── */ +*{box-sizing:border-box;margin:0;padding:0} +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:#f5f7fb;color:#1a1d23;font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased} + +/* ── Topbar ────────────────────────────────────────────────────────────── */ +.topbar{background:linear-gradient(135deg,#0a2e6e,#0b3d91,#1565c0);color:#fff;padding:12px 28px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 2px 12px rgba(0,0,0,.12)} +.topbar h1{font-size:15px;font-weight:700;letter-spacing:.02em;display:flex;align-items:center;gap:8px} +.topbar small{font-size:11px;opacity:.8;display:block;margin-top:2px} +.topbar-r{display:flex;align-items:center;gap:10px} +.ubadge{background:rgba(255,255,255,.15);border-radius:20px;padding:4px 14px;font-size:12px;font-weight:500;backdrop-filter:blur(4px)} +.btn-out{background:rgba(255,255,255,.15);color:#fff;border:1px solid rgba(255,255,255,.2);border-radius:8px;padding:6px 16px;font-size:13px;font-weight:500;cursor:pointer;text-decoration:none;transition:all .15s;backdrop-filter:blur(4px)} +.btn-out:hover{background:rgba(255,255,255,.25);border-color:rgba(255,255,255,.35)} + +/* ── Nav ───────────────────────────────────────────────────────────────── */ +.nav-bar{background:#fff;padding:10px 28px;display:flex;gap:2px;flex-wrap:wrap;align-items:center;border-bottom:1px solid #eef1f5;box-shadow:0 1px 3px rgba(0,0,0,.04);position:sticky;top:56px;z-index:99} +.nav-link{padding:7px 14px;border-radius:10px;font-size:13px;font-weight:500;color:#5f6b7a;text-decoration:none;transition:all .2s ease;display:inline-flex;align-items:center;gap:5px;letter-spacing:.01em;white-space:nowrap} +.nav-link:hover{background:#f0f4ff;color:#0b3d91;transform:translateY(-1px)} +.nav-link.active{background:#0b3d91;color:#fff;box-shadow:0 2px 8px rgba(11,61,145,.25)} +.nav-link.nav-action{padding:7px 10px;font-size:15px} +.nav-sep{display:inline-block;width:1px;height:20px;background:#eef1f5;margin:0 6px;flex-shrink:0} +.nav-badge{display:inline-flex;align-items:center;justify-content:center;min-width:17px;height:17px;border-radius:9px;background:#e74c3c;color:#fff;font-size:10px;font-weight:700;padding:0 4px;margin-left:3px} + +/* ── Badges ────────────────────────────────────────────────────────────── */ +.badge{display:inline-flex;align-items:center;padding:4px 10px;border-radius:20px;font-size:11px;font-weight:600;gap:4px} +.badge-blue{background:#dbeafe;color:#1d4ed8} +.badge-green{background:#dcfce7;color:#166534} +.badge-teal{background:#ccfbf1;color:#0f766e} +.badge-red{background:#fee2e2;color:#991b1b} +.badge-gray{background:#f3f4f6;color:#6b7280} + +/* ── Buttons ───────────────────────────────────────────────────────────── */ +.btn-primary{background:#0b3d91;color:#fff;border:none;border-radius:10px;padding:9px 20px;font-size:13px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:all .15s} +.btn-primary:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)} +.btn-secondary{background:#f0f4ff;color:#0b3d91;border:1px solid #d6e4ff;border-radius:8px;padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;text-decoration:none;transition:all .15s;display:inline-flex;align-items:center;gap:4px} +.btn-secondary:hover{background:#dbeafe;border-color:#0b3d91} +.btn-sm{background:#f0f4ff;color:#0b3d91;border:1px solid #d6e4ff;border-radius:7px;padding:5px 13px;font-size:12px;font-weight:600;cursor:pointer;text-decoration:none;transition:all .15s;display:inline-flex;align-items:center;gap:4px} +.btn-sm:hover{background:#dbeafe;border-color:#0b3d91} +.btn-danger{background:#fef2f2;color:#991b1b;border:1px solid #fecaca;border-radius:7px;padding:5px 13px;font-size:12px;font-weight:600;cursor:pointer;text-decoration:none;transition:all .15s;display:inline-flex;align-items:center;gap:4px} +.btn-danger:hover{background:#fee2e2;border-color:#991b1b} +.btn-danger-sm{background:#fef2f2;color:#991b1b;border:1px solid #fecaca;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;transition:all .15s} +.btn-danger-sm:hover{background:#fee2e2} + +/* ── Card ──────────────────────────────────────────────────────────────── */ +.card{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03);overflow:hidden;border:1px solid #eef1f5} +.card-h{background:linear-gradient(135deg,#0b3d91,#1565c0);color:#fff;padding:14px 20px;font-size:14px;font-weight:700} +.card-b{padding:20px} + +/* ── Table ─────────────────────────────────────────────────────────────── */ +table{width:100%;border-collapse:separate;border-spacing:0} +thead{background:#f8f9fd} +th{padding:12px 16px;text-align:left;font-size:11px;font-weight:700;color:#0b3d91;text-transform:uppercase;letter-spacing:.4px;border-bottom:2px solid #e8eaf6} +td{padding:11px 16px;border-bottom:1px solid #f0f2f5;vertical-align:middle;font-size:13px} +tr:last-child td{border-bottom:none} +tr:hover td{background:#f8faff} +.id-col{color:#a0a8b8;font-size:12px;white-space:nowrap;font-family:monospace} +.empty{text-align:center;padding:56px 24px;color:#a0a8b8;font-size:15px} +.empty a{color:#0b3d91;text-decoration:none;font-weight:600} +.empty a:hover{text-decoration:underline} + +/* ── Modal ─────────────────────────────────────────────────────────────── */ +.modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:999;align-items:center;justify-content:center;padding:16px;backdrop-filter:blur(4px)} +.mc{background:#fff;border-radius:14px;width:100%;max-width:740px;max-height:88vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 16px 48px rgba(0,0,0,.2)} +.mh{background:linear-gradient(135deg,#0a2e6e,#0b3d91);color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:center;font-weight:700;font-size:14px} +.mx{background:none;border:none;color:rgba(255,255,255,.7);font-size:22px;cursor:pointer;line-height:1;padding:0 4px;transition:color .15s} +.mx:hover{color:#fff} +.mb{padding:20px;overflow-y:auto;flex:1} + +/* ── Code / Pre ────────────────────────────────────────────────────────── */ +pre{background:#1a1d23;color:#e4e7ed;padding:20px;border-radius:10px;font-size:12px;line-height:1.7;overflow-x:auto;white-space:pre-wrap;word-break:break-all;font-family:'SF Mono',Monaco,monospace} +code{background:#f3f4f6;padding:2px 6px;border-radius:5px;font-size:12px;color:#3d4552;font-family:'SF Mono',Monaco,monospace} + +/* ── Toast ─────────────────────────────────────────────────────────────── */ +.toast{padding:12px 18px;border-radius:10px;font-size:13px;font-weight:600;margin-bottom:16px;text-align:center} +.toast-success{background:#dcfce7;color:#166534;border:1px solid #a3e4bc} +.toast-error{background:#fee2e2;color:#991b1b;border:1px solid #fecaca} + +/* ── Container ─────────────────────────────────────────────────────────── */ +.wrap{padding:24px;margin:0 auto} + +/* ── Form ──────────────────────────────────────────────────────────────── */ +.form-group{margin-bottom:14px} +.form-group label{display:block;font-size:13px;font-weight:600;color:#3d4552;margin-bottom:5px} +.form-group input,.form-group select,.form-group textarea{width:100%;padding:10px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;font-family:inherit;background:#fafbfc;transition:all .15s;color:#1a1d23} +.form-group input:focus,.form-group select:focus,.form-group textarea:focus{border-color:#1565c0;background:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)} +.form-group .hint{font-size:11px;color:#7a8291;margin-top:4px} +.form-row{display:flex;gap:16px;flex-wrap:wrap} +.form-row .form-group{flex:1;min-width:200px} +.form-group.checkbox{display:flex;align-items:center;gap:10px;padding:6px 0;margin-bottom:8px} +.form-group.checkbox input[type=checkbox]{width:16px;height:16px;accent-color:#0b3d91} +.form-group.checkbox label{margin:0;cursor:pointer;font-size:13px;color:#3d4552;font-weight:500} + +/* ── Tags ──────────────────────────────────────────────────────────────── */ +.tag{display:inline-flex;align-items:center;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:600;gap:4px} +.tag-hybrid{background:#f0f4ff;color:#0b3d91;border:1px solid #d6e4ff} +.tag-ai{background:#e8f8f0;color:#1e8449;border:1px solid #a3e4bc} +.tag-normal{background:#fef5e7;color:#b85e0e;border:1px solid #f9e79f} +.tag-yes,.tag-off{background:#fee2e2;color:#991b1b;border:1px solid #fecaca} +.tag-no,.tag-on{background:#dcfce7;color:#166534;border:1px solid #a3e4bc} + +/* ── Result card (sync/queue pages) ───────────────────────────────────── */ +.result-card{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03);padding:32px;border:1px solid #eef1f5} +.result-icon{font-size:52px;text-align:center;margin-bottom:16px;line-height:1} +.result-title{font-size:18px;font-weight:700;text-align:center;margin-bottom:24px} +.result-title.success{color:#166534} +.result-title.error{color:#991b1b} + +/* ── Hamburger ─────────────────────────────────────────────────────────── */ +.nav-hamburger{display:none;background:none;border:none;color:#fff;font-size:20px;cursor:pointer;padding:4px 8px;border-radius:6px;line-height:1;transition:background .15s} +.nav-hamburger:hover{background:rgba(255,255,255,.15)} + +/* ── Responsive ────────────────────────────────────────────────────────── */ +@media(max-width:768px){ + .topbar{padding:10px 16px} + .nav-bar{padding:8px 12px;top:52px;flex-direction:column;align-items:flex-start;gap:0;max-height:0;overflow:hidden;padding-top:0;padding-bottom:0;transition:max-height .25s ease,padding .2s ease;position:static} + .nav-bar.open{max-height:600px;padding:8px 12px} + .nav-hamburger{display:inline-flex} + .nav-link{width:100%;padding:10px 12px;border-radius:8px} + .nav-sep{display:block;width:100%;height:1px;margin:4px 0} + .wrap{padding:12px} +} diff --git a/public/index.php b/public/index.php index b3b8e3d..658610f 100644 --- a/public/index.php +++ b/public/index.php @@ -4,6 +4,7 @@ declare(strict_types=1); require_once __DIR__ . '/../config/env.php'; require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../services/Settings.php'; +require_once __DIR__ . '/../admin/Layout.php'; // Merge DB settings over .env defaults (so env() always returns the latest) (function () { @@ -86,8 +87,9 @@ $routes = [ // ─── Dashboard protegido ───────────────────────────────────────────────── ['GET', '/admin/dashboard', fn() => DashboardController::index()], ['GET', '/admin/live', fn() => DashboardController::live()], - ['GET', '/admin/webhook/stream', fn() => DashboardController::stream()], - ['GET', '/admin/webhook/raw', fn() => DashboardController::getRaw()], + ['GET', '/admin/webhook/stream/sse', fn() => DashboardController::streamSse()], + ['GET', '/admin/webhook/stream', fn() => DashboardController::stream()], + ['GET', '/admin/webhook/raw', fn() => DashboardController::getRaw()], ['GET', '/admin/chat', fn() => DashboardController::chat()], ['GET', '/admin/chat/conversations', fn() => DashboardController::chatConversations()], ['GET', '/admin/chat/messages', fn() => DashboardController::chatMessages()], @@ -262,6 +264,7 @@ $routes = [ ['GET', '/admin/process-queue', fn() => DashboardController::processQueue()], // ─── Admin: listar pendientes de aprobaciΓ³n ───────────────────────────── + ['GET', '/admin/pending-list', fn() => DashboardController::pendingList()], ['GET', '/admin/pending', fn() => DashboardController::pending()], // ─── Admin: aprobar pendiente ───────────────────────────────────────────