591 lines
27 KiB
PHP
591 lines
27 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
class DashboardController
|
|
{
|
|
private const PER_PAGE = 25;
|
|
|
|
// ─── GET /admin/dashboard ─────────────────────────────────────────────────
|
|
|
|
public static function index(): void
|
|
{
|
|
SessionAuth::require();
|
|
|
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
|
$filter = trim($_GET['type'] ?? '');
|
|
$search = trim($_GET['search'] ?? '');
|
|
$date = trim($_GET['date'] ?? date('Y-m-d'));
|
|
$user = SessionAuth::user();
|
|
|
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
|
$date = date('Y-m-d');
|
|
}
|
|
|
|
try {
|
|
$db = db();
|
|
$stats = self::getStats($db);
|
|
[$logs, $total] = self::getLogs($db, $page, $filter, $search, $date);
|
|
} catch (\PDOException $e) {
|
|
$stats = ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0];
|
|
$logs = [];
|
|
$total = 0;
|
|
}
|
|
|
|
$pages = $total > 0 ? (int)ceil($total / self::PER_PAGE) : 1;
|
|
self::render(compact('stats', 'logs', 'total', 'page', 'pages', 'filter', 'search', 'date', 'user'));
|
|
}
|
|
|
|
// ─── GET /admin/live ────────────────────────────────────────────────────
|
|
|
|
public static function live(): void
|
|
{
|
|
SessionAuth::require();
|
|
$user = SessionAuth::user();
|
|
$userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8');
|
|
|
|
http_response_code(200);
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
echo <<<HTML
|
|
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Live — Palmas360</title>
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#1c1e21;font-size:14px}
|
|
.topbar{background:linear-gradient(135deg,#0b3d91,#1565c0);color:#fff;padding:13px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,.15)}
|
|
.topbar h1{font-size:17px;font-weight:700}
|
|
.topbar-r{display:flex;align-items:center;gap:10px}
|
|
.ubadge{background:rgba(255,255,255,.18);border-radius:20px;padding:3px 12px;font-size:12px}
|
|
.btn-nav{background:rgba(255,255,255,.2);color:#fff;border:none;border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer;text-decoration:none}
|
|
.btn-nav:hover{background:rgba(255,255,255,.32)}
|
|
.toolbar{padding:14px 24px;display:flex;align-items:center;gap:12px;background:#fff;margin:14px 24px 0;border-radius:10px;box-shadow:0 1px 5px rgba(0,0,0,.06)}
|
|
.dot{width:10px;height:10px;border-radius:50%;background:#e74c3c;animation:pulse 1.2s infinite}
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
|
.dot.paused{background:#aaa;animation:none}
|
|
#status{font-size:13px;color:#555}
|
|
.btn-pause{background:#0b3d91;color:#fff;border:none;border-radius:7px;padding:6px 16px;font-size:13px;cursor:pointer}
|
|
.btn-pause:hover{background:#1565c0}
|
|
.counter{margin-left:auto;font-size:12px;color:#888}
|
|
.feed{padding:0 24px 24px;margin-top:12px;display:flex;flex-direction:column;gap:6px}
|
|
.card{background:#fff;border-radius:10px;padding:13px 16px;box-shadow:0 1px 4px rgba(0,0,0,.07);display:flex;align-items:flex-start;gap:12px;animation:slidein .3s ease}
|
|
@keyframes slidein{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
|
|
.card-icon{font-size:22px;line-height:1;flex-shrink:0;margin-top:2px}
|
|
.card-body{flex:1;min-width:0}
|
|
.card-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
.card-phone{font-family:monospace;font-size:13px;font-weight:600;color:#0b3d91}
|
|
.card-name{font-size:12px;color:#888}
|
|
.card-time{margin-left:auto;font-size:11px;color:#bbb;white-space:nowrap}
|
|
.card-preview{font-size:13px;color:#444;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
|
|
.b-text{background:#e8f4fd;color:#1565c0}
|
|
.b-image{background:#fce8fd;color:#7b1fa2}
|
|
.b-audio{background:#e8fdf0;color:#1b5e20}
|
|
.b-video{background:#fdf3e8;color:#e65100}
|
|
.b-document{background:#e8eaf6;color:#283593}
|
|
.b-sticker{background:#fff8e1;color:#f57f17}
|
|
.b-reaction{background:#fce4ec;color:#880e4f}
|
|
.b-location{background:#e0f2f1;color:#004d40}
|
|
.b-interactive{background:#ede7f6;color:#4527a0}
|
|
.b-button{background:#e3f2fd;color:#0d47a1}
|
|
.b-status{background:#f3f4f6;color:#6b7280}
|
|
.b-other{background:#f5f5f5;color:#555}
|
|
.empty{text-align:center;padding:60px;color:#ccc;font-size:15px}
|
|
#modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:999;align-items:center;justify-content:center;padding:16px}
|
|
.mc{background:#fff;border-radius:12px;width:100%;max-width:740px;max-height:88vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,.25)}
|
|
.mh{background:#0b3d91;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:center;font-weight:700}
|
|
.mx{background:none;border:none;color:#fff;font-size:22px;cursor:pointer}
|
|
.mb{padding:16px;overflow-y:auto;flex:1}
|
|
pre{background:#1e1e2e;color:#cdd6f4;padding:16px;border-radius:8px;font-size:12px;line-height:1.7;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="topbar">
|
|
<div>
|
|
<h1><img src="https://app.palmas360.com/Test/imgLogin/logo.png" alt="Palmas360" style="height:32px;vertical-align:middle;margin-right:8px;filter:brightness(0) invert(1)"> Live Feed</h1>
|
|
</div>
|
|
<div class="topbar-r">
|
|
<a href="/admin/dashboard" class="btn-nav">← Dashboard</a>
|
|
<span class="ubadge">👤 {$userName}</span>
|
|
<a href="/logout" class="btn-nav">Salir</a>
|
|
</div>
|
|
</div>
|
|
<div class="toolbar">
|
|
<span class="dot" id="dot"></span>
|
|
<span id="status">Conectado — actualizando cada 3s</span>
|
|
<button class="btn-pause" id="btnPause" onclick="togglePause()">▮▮ Pausar</button>
|
|
<span class="counter" id="counter">0 eventos</span>
|
|
</div>
|
|
<div class="feed" id="feed"><p class="empty">Esperando eventos en tiempo real...</p></div>
|
|
|
|
<div id="modal" onclick="if(event.target===this)closeModal()">
|
|
<div class="mc">
|
|
<div class="mh"><span>📦 Payload JSON</span><button class="mx" onclick="closeModal()">✕</button></div>
|
|
<div class="mb"><pre id="mpre">Cargando...</pre></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
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;
|
|
|
|
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';
|
|
}
|
|
|
|
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 = `
|
|
<div class="card-icon">\${icon}</div>
|
|
<div class="card-body">
|
|
<div class="card-top">
|
|
<span class="card-phone">\${esc(row.from_number)}</span>
|
|
<span class="card-name">\${esc(row.contact_name)}</span>
|
|
<span class="badge \${badge}">\${esc(row.message_type)}</span>
|
|
<span class="card-time">\${esc(row.received_at.substr(11,8))}</span>
|
|
<button onclick="showRaw(\${row.id})" style="margin-left:4px;background:#e8eaf6;color:#0b3d91;border:none;border-radius:5px;padding:2px 8px;font-size:11px;font-weight:600;cursor:pointer">JSON</button>
|
|
</div>
|
|
<div class="card-preview">\${esc(row.message_preview)}</div>
|
|
</div>`;
|
|
feed.insertBefore(card, feed.firstChild);
|
|
});
|
|
document.getElementById('counter').textContent = count + ' evento' + (count !== 1 ? 's' : '');
|
|
} catch(e) {}
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
async function showRaw(id) {
|
|
document.getElementById('mpre').textContent = 'Cargando...';
|
|
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);
|
|
} 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); });
|
|
</script>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
|
|
// ─── GET /admin/webhook/stream?after=N ───────────────────────────────────
|
|
|
|
public static function stream(): void
|
|
{
|
|
SessionAuth::require();
|
|
$afterId = max(0, (int)($_GET['after'] ?? 0));
|
|
$isInit = isset($_GET['init']);
|
|
|
|
try {
|
|
if ($isInit) {
|
|
// En init devolvemos solo el último ID (sin datos) para anclar
|
|
$stmt = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1');
|
|
$row = $stmt->fetch();
|
|
$afterId = $row ? (int)$row['id'] : 0;
|
|
// Devolvemos array vacío — solo queremos anclar lastId en el cliente
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode([]);
|
|
exit;
|
|
}
|
|
|
|
$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 50
|
|
");
|
|
$stmt->execute([$afterId]);
|
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (\PDOException $e) {
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo '[]';
|
|
exit;
|
|
}
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode($rows, JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
// ─── GET /admin/webhook/raw?id=N ─────────────────────────────────────────
|
|
|
|
public static function getRaw(): void
|
|
{
|
|
SessionAuth::require();
|
|
$id = max(0, (int)($_GET['id'] ?? 0));
|
|
if ($id === 0) {
|
|
jsonResponse(400, ['error' => 'ID inválido']);
|
|
}
|
|
try {
|
|
$stmt = db()->prepare('SELECT raw_payload FROM webhook_logs WHERE id = ? LIMIT 1');
|
|
$stmt->execute([$id]);
|
|
$row = $stmt->fetch();
|
|
} catch (\PDOException $e) {
|
|
jsonResponse(500, ['error' => 'Error de base de datos']);
|
|
}
|
|
if (!$row) {
|
|
jsonResponse(404, ['error' => 'Registro no encontrado']);
|
|
}
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo $row['raw_payload'];
|
|
exit;
|
|
}
|
|
|
|
// ─── Consultas ────────────────────────────────────────────────────────────
|
|
|
|
private static function getStats(PDO $db): array
|
|
{
|
|
$stmt = $db->query("
|
|
SELECT
|
|
COUNT(*) AS total,
|
|
COALESCE(SUM(event_field='messages' AND message_type='text'),0) AS msgs,
|
|
COALESCE(SUM(event_field='messages' AND message_type<>'text'),0) AS media,
|
|
COALESCE(SUM(event_field='statuses'),0) AS statuses
|
|
FROM webhook_logs
|
|
WHERE DATE(received_at) = CURDATE()
|
|
");
|
|
return $stmt->fetch() ?: ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0];
|
|
}
|
|
|
|
private static function getLogs(PDO $db, int $page, string $filter, string $search, string $date): array
|
|
{
|
|
$where = ['DATE(received_at) = ?'];
|
|
$params = [$date];
|
|
|
|
if ($filter !== '') {
|
|
$where[] = 'event_field = ?';
|
|
$params[] = $filter;
|
|
}
|
|
if ($search !== '') {
|
|
$where[] = '(from_number LIKE ? OR contact_name LIKE ? OR message_preview LIKE ?)';
|
|
$like = '%' . $search . '%';
|
|
array_push($params, $like, $like, $like);
|
|
}
|
|
|
|
$w = implode(' AND ', $where);
|
|
|
|
$cnt = $db->prepare("SELECT COUNT(*) FROM webhook_logs WHERE $w");
|
|
$cnt->execute($params);
|
|
$total = (int)$cnt->fetchColumn();
|
|
|
|
$limit = self::PER_PAGE;
|
|
$offset = ($page - 1) * $limit;
|
|
|
|
$stmt = $db->prepare("
|
|
SELECT id, event_field, from_number, contact_name,
|
|
message_type, message_preview, received_at
|
|
FROM webhook_logs
|
|
WHERE $w
|
|
ORDER BY received_at DESC
|
|
LIMIT $limit OFFSET $offset
|
|
");
|
|
$stmt->execute($params);
|
|
return [$stmt->fetchAll(), $total];
|
|
}
|
|
|
|
// ─── Helpers de vista ─────────────────────────────────────────────────────
|
|
|
|
private static function h(mixed $v): string
|
|
{
|
|
return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
}
|
|
|
|
private static function typeLabel(string $field, string $type): string
|
|
{
|
|
if ($field === 'statuses') {
|
|
switch ($type) {
|
|
case 'sent': $cls = 'badge-blue'; break;
|
|
case 'delivered': $cls = 'badge-green'; break;
|
|
case 'read': $cls = 'badge-teal'; break;
|
|
case 'failed': $cls = 'badge-red'; break;
|
|
default: $cls = 'badge-gray'; break;
|
|
}
|
|
return '<span class="badge ' . $cls . '">' . self::h($type) . '</span>';
|
|
}
|
|
switch ($type) {
|
|
case 'text': return '💬 Texto';
|
|
case 'image': return '📷 Imagen';
|
|
case 'audio': return '🎵 Audio';
|
|
case 'video': return '🎬 Video';
|
|
case 'document': return '📄 Documento';
|
|
case 'location': return '📍 Ubicación';
|
|
case 'interactive': return '🔘 Interactivo';
|
|
case 'button': return '🔲 Botón';
|
|
default: return self::h($type);
|
|
}
|
|
}
|
|
|
|
// ─── Render ───────────────────────────────────────────────────────────────
|
|
|
|
private static function render(array $v): void
|
|
{
|
|
http_response_code(200);
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
|
|
// Variables para el heredoc
|
|
$userName = self::h($v['user']['name'] ?? 'Admin');
|
|
$dateVal = self::h($v['date']);
|
|
$maxDate = date('Y-m-d');
|
|
$filterVal = self::h($v['filter']);
|
|
$searchVal = self::h($v['search']);
|
|
$totalStr = number_format((int)$v['total']);
|
|
$selMessages = $v['filter'] === 'messages' ? 'selected' : '';
|
|
$selStatuses = $v['filter'] === 'statuses' ? 'selected' : '';
|
|
$sTotal = (int)$v['stats']['total'];
|
|
$sMsgs = (int)$v['stats']['msgs'];
|
|
$sMedia = (int)$v['stats']['media'];
|
|
$sStatuses = (int)$v['stats']['statuses'];
|
|
|
|
// Filas de la tabla
|
|
$rows = '';
|
|
if (empty($v['logs'])) {
|
|
$rows = '<tr><td colspan="7" class="empty">Sin eventos para esta fecha.</td></tr>';
|
|
} else {
|
|
foreach ($v['logs'] as $log) {
|
|
$id = (int)$log['id'];
|
|
$time = self::h(substr($log['received_at'] ?? '', 11, 8));
|
|
$from = self::h($log['from_number'] ?? '—');
|
|
$name = self::h($log['contact_name'] ?? '—');
|
|
$type = self::typeLabel($log['event_field'] ?? '', $log['message_type'] ?? '');
|
|
$preview = self::h(mb_substr($log['message_preview'] ?? '—', 0, 70));
|
|
$rows .= "<tr>"
|
|
. "<td class='id-col'>#{$id}</td>"
|
|
. "<td>{$time}</td>"
|
|
. "<td class='phone'>{$from}</td>"
|
|
. "<td>{$name}</td>"
|
|
. "<td>{$type}</td>"
|
|
. "<td class='preview'>{$preview}</td>"
|
|
. "<td><button class='btn-json' onclick='showRaw({$id})'>JSON</button></td>"
|
|
. "</tr>\n";
|
|
}
|
|
}
|
|
|
|
// Paginación
|
|
$pager = '';
|
|
if ($v['pages'] > 1) {
|
|
$pager = '<div class="pager">';
|
|
for ($i = 1; $i <= min((int)$v['pages'], 20); $i++) {
|
|
$qs = '?page=' . $i
|
|
. '&type=' . urlencode($v['filter'])
|
|
. '&search=' . urlencode($v['search'])
|
|
. '&date=' . urlencode($v['date']);
|
|
$active = $i === (int)$v['page'] ? ' active' : '';
|
|
$pager .= "<a href='{$qs}' class='pg{$active}'>{$i}</a>";
|
|
}
|
|
$pager .= '</div>';
|
|
}
|
|
|
|
echo <<<HTML
|
|
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Admin — Palmas360</title>
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#1c1e21;font-size:14px}
|
|
/* Topbar */
|
|
.topbar{background:linear-gradient(135deg,#0b3d91,#1565c0);color:#fff;padding:13px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,.15)}
|
|
.topbar h1{font-size:17px;font-weight:700}
|
|
.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,.18);border-radius:20px;padding:3px 12px;font-size:12px}
|
|
.btn-out{background:rgba(255,255,255,.2);color:#fff;border:none;border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer;text-decoration:none}
|
|
.btn-out:hover{background:rgba(255,255,255,.32)}
|
|
/* Stats */
|
|
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:18px 24px 0}
|
|
.sc{background:#fff;border-radius:10px;padding:16px;box-shadow:0 1px 5px rgba(0,0,0,.06);border-left:4px solid #1565c0}
|
|
.sc .v{font-size:28px;font-weight:700;color:#0b3d91}
|
|
.sc .l{font-size:12px;color:#888;margin-top:3px}
|
|
.sc.g{border-color:#27ae60}.sc.g .v{color:#1e8449}
|
|
.sc.o{border-color:#e67e22}.sc.o .v{color:#ca6f1e}
|
|
.sc.s{border-color:#7f8c8d}.sc.s .v{color:#616a6b}
|
|
/* Filters */
|
|
.fbar{padding:14px 24px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;background:#fff;margin:14px 24px 0;border-radius:10px;box-shadow:0 1px 5px rgba(0,0,0,.06)}
|
|
.fbar input,.fbar select{padding:7px 11px;border:1px solid #ddd;border-radius:7px;font-size:13px;outline:none}
|
|
.fbar input:focus,.fbar select:focus{border-color:#1565c0}
|
|
.bf{background:#0b3d91;color:#fff;border:none;border-radius:7px;padding:7px 16px;font-size:13px;cursor:pointer}
|
|
.bf:hover{background:#1565c0}
|
|
.rc{margin-left:auto;font-size:12px;color:#888}
|
|
/* Table */
|
|
.tw{margin:12px 24px 24px;background:#fff;border-radius:10px;box-shadow:0 1px 5px rgba(0,0,0,.06);overflow:hidden}
|
|
table{width:100%;border-collapse:collapse}
|
|
thead{background:#f8f9fd}
|
|
th{padding:11px 14px;text-align:left;font-size:11px;font-weight:700;color:#0b3d91;text-transform:uppercase;letter-spacing:.4px;border-bottom:2px solid #e8eaf6}
|
|
td{padding:10px 14px;border-bottom:1px solid #f0f0f0;vertical-align:middle}
|
|
tr:hover td{background:#fafbff}
|
|
tr:last-child td{border-bottom:none}
|
|
.id-col{color:#bbb;font-size:12px;white-space:nowrap}
|
|
.phone{font-family:monospace;font-size:13px}
|
|
.preview{color:#555;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.empty{text-align:center;padding:48px;color:#aaa;font-size:15px}
|
|
/* Badges */
|
|
.badge{padding:3px 9px;border-radius:12px;font-size:11px;font-weight:600}
|
|
.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}
|
|
/* JSON button */
|
|
.btn-json{background:#e8eaf6;color:#0b3d91;border:none;border-radius:5px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer}
|
|
.btn-json:hover{background:#c5cae9}
|
|
/* Paginación */
|
|
.pager{display:flex;justify-content:center;padding:14px;gap:5px;flex-wrap:wrap}
|
|
.pg{padding:5px 12px;border:1px solid #ddd;border-radius:6px;text-decoration:none;color:#0b3d91;font-size:13px;background:#fff}
|
|
.pg.active{background:#0b3d91;color:#fff;border-color:#0b3d91}
|
|
.pg:hover:not(.active){background:#f0f2f5}
|
|
/* Modal */
|
|
.modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:999;align-items:center;justify-content:center;padding:16px}
|
|
.mc{background:#fff;border-radius:12px;width:100%;max-width:740px;max-height:88vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,.25)}
|
|
.mh{background:#0b3d91;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:center;font-weight:700}
|
|
.mx{background:none;border:none;color:#fff;font-size:22px;cursor:pointer;line-height:1;padding:0 4px}
|
|
.mb{padding:16px;overflow-y:auto;flex:1}
|
|
pre{background:#1e1e2e;color:#cdd6f4;padding:16px;border-radius:8px;font-size:12px;line-height:1.7;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
|
|
@media(max-width:768px){
|
|
.stats{grid-template-columns:repeat(2,1fr)}
|
|
.fbar,.stats,.tw{padding-left:12px;padding-right:12px;margin-left:0;margin-right:0}
|
|
.tw{margin:8px 0 16px}
|
|
th:nth-child(4),td:nth-child(4){display:none}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="topbar">
|
|
<div>
|
|
<h1><img src="https://app.palmas360.com/Test/imgLogin/logo.png" alt="Palmas360" style="height:52px;vertical-align:middle;margin-right:12px;filter:brightness(0) invert(1)"> Palmas360 · Admin</h1>
|
|
<small>Somos19D</small>
|
|
</div>
|
|
<div class="topbar-r">
|
|
<span class="ubadge">👤 {$userName}</span>
|
|
<a href="/logout" class="btn-out">Salir</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats">
|
|
<div class="sc"> <div class="v">{$sTotal}</div> <div class="l">📡 Total hoy</div></div>
|
|
<div class="sc g"><div class="v">{$sMsgs}</div> <div class="l">💬 Mensajes texto</div></div>
|
|
<div class="sc o"><div class="v">{$sMedia}</div> <div class="l">📎 Multimedia</div></div>
|
|
<div class="sc s"><div class="v">{$sStatuses}</div> <div class="l">📊 Estados</div></div>
|
|
</div>
|
|
|
|
<div class="fbar">
|
|
<form method="GET" action="/admin/dashboard" style="display:flex;gap:8px;flex-wrap:wrap;width:100%;align-items:center">
|
|
<input type="date" name="date" value="{$dateVal}" max="{$maxDate}">
|
|
<select name="type">
|
|
<option value="">Todos los tipos</option>
|
|
<option value="messages" {$selMessages}>Mensajes</option>
|
|
<option value="statuses" {$selStatuses}>Estados</option>
|
|
</select>
|
|
<input type="text" name="search" value="{$searchVal}" placeholder="Número, nombre, texto..." style="min-width:185px">
|
|
<button type="submit" class="bf">🔍 Filtrar</button>
|
|
<span class="rc">{$totalStr} resultado(s)</span>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="tw">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Hora</th>
|
|
<th>Número</th>
|
|
<th>Nombre</th>
|
|
<th>Tipo</th>
|
|
<th>Preview</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{$rows}</tbody>
|
|
</table>
|
|
{$pager}
|
|
</div>
|
|
|
|
<!-- Modal payload -->
|
|
<div id="modal" class="modal" onclick="if(event.target===this)closeModal()">
|
|
<div class="mc">
|
|
<div class="mh">
|
|
<span>📦 Payload JSON</span>
|
|
<button class="mx" onclick="closeModal()">✕</button>
|
|
</div>
|
|
<div class="mb">
|
|
<pre id="mpre">Cargando...</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
async function showRaw(id) {
|
|
document.getElementById('mpre').textContent = 'Cargando...';
|
|
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);
|
|
} catch (e) {
|
|
document.getElementById('mpre').textContent = 'Error al cargar el payload.';
|
|
}
|
|
}
|
|
function closeModal() {
|
|
document.getElementById('modal').style.display = 'none';
|
|
}
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
|
|
|
|
(function () {
|
|
const titleStyle = 'color: #1e88e5; font-size: 14px; font-weight: 700;';
|
|
const subtitleStyle = 'color: #90caf9; font-size: 12px;';
|
|
const warningStyle = 'color: #ffb300; font-size: 12px;';
|
|
|
|
console.log('%cDesarrollado por U-site', titleStyle);
|
|
console.log('%cAlgo cool para tu consola: esto es un panel seguro y sólo para gestión.', subtitleStyle);
|
|
console.log('%cNo deberías pegar scripts en esta parte del navegador, amigo. Mantén la consola limpia y segura.', warningStyle);
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
}
|