2386 lines
139 KiB
PHP
2386 lines
139 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'));
|
|
$companyId = isset($_GET['company_id']) ? (int)$_GET['company_id'] : null;
|
|
$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, $companyId);
|
|
$pending = PendingApproval::countPending($companyId);
|
|
$companies = CompanyRepository::findAll();
|
|
[$logs, $total] = self::getLogs($db, $page, $filter, $search, $date, $companyId);
|
|
} catch (\PDOException $e) {
|
|
$stats = ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0];
|
|
$pending = 0;
|
|
$companies = [];
|
|
$logs = [];
|
|
$total = 0;
|
|
}
|
|
|
|
$pages = $total > 0 ? (int)ceil($total / self::PER_PAGE) : 1;
|
|
$companyCount = count($companies);
|
|
self::render(compact('stats', 'pending', 'companies', 'companyCount', 'companyId', '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:-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{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-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-nav{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}
|
|
.btn-nav:hover{background:rgba(255,255,255,.25);border-color:rgba(255,255,255,.35)}
|
|
.nav-bar{background:#fff;padding:8px 24px;display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid #e8eaf6;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
|
.nav-link{padding:6px 14px;border-radius:8px;font-size:13px;font-weight:500;color:#444;text-decoration:none;transition:all .15s}
|
|
.nav-link:hover{background:#e8eaf6;color:#0b3d91}
|
|
.nav-link.active{background:rgba(255,255,255,.2);color:#fff}
|
|
.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">
|
|
<span class="ubadge">👤 {$userName}</span>
|
|
<a href="/logout" class="btn-nav">Salir</a>
|
|
</div>
|
|
</div>
|
|
|
|
<nav class="nav-bar">
|
|
<a href="/admin/dashboard" class="nav-link">📊 Dashboard</a>
|
|
<a href="/admin/live" class="nav-link active">🔴 Live Feed</a>
|
|
<a href="/admin/pending" class="nav-link">⏳ Pendientes</a>
|
|
<a href="/admin/chat" class="nav-link">💬 Chat</a>
|
|
<a href="/admin/companies" class="nav-link">🏢 Empresas</a>
|
|
<a href="/admin/sync-companies" class="nav-link" onclick="return confirm('¿Sincronizar empresas desde el ERP?')">🔄 Sincronizar</a>
|
|
<a href="/admin/process-queue" class="nav-link" onclick="return confirm('¿Procesar cola de mensajes?')">📨 Procesar Cola</a>
|
|
<a href="/admin/bot-config" class="nav-link">🤖 Bot Config</a>
|
|
<a href="/admin/settings" class="nav-link">⚙️ Configuración</a>
|
|
</nav>
|
|
<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, ?int $companyId = null): array
|
|
{
|
|
if ($companyId !== null) {
|
|
$stmt = $db->prepare("
|
|
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() AND company_id = ?
|
|
");
|
|
$stmt->execute([$companyId]);
|
|
} else {
|
|
$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, ?int $companyId = null): array
|
|
{
|
|
$where = ['DATE(received_at) = ?'];
|
|
$params = [$date];
|
|
|
|
if ($companyId !== null) {
|
|
$where[] = 'company_id = ?';
|
|
$params[] = $companyId;
|
|
}
|
|
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 wh.id, wh.event_field, wh.from_number, wh.contact_name,
|
|
wh.message_type, wh.message_preview, wh.received_at,
|
|
c.name AS company_name
|
|
FROM webhook_logs wh
|
|
LEFT JOIN companies c ON c.id = wh.company_id
|
|
WHERE $w
|
|
ORDER BY wh.received_at DESC
|
|
LIMIT $limit OFFSET $offset
|
|
");
|
|
$stmt->execute($params);
|
|
return [$stmt->fetchAll(), $total];
|
|
}
|
|
|
|
// ─── Helpers de vista ─────────────────────────────────────────────────────
|
|
|
|
private static function h($v): string
|
|
{
|
|
return htmlspecialchars((string)$v, ENT_QUOTES, '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'];
|
|
$sPending = (int)($v['pending'] ?? 0);
|
|
$companyCount = (int)($v['companyCount'] ?? 0);
|
|
|
|
// Opciones del filtro de empresa
|
|
$companyOptions = '<option value="">Todas las empresas</option>';
|
|
foreach ($v['companies'] ?? [] as $c) {
|
|
$sel = (int)($v['companyId'] ?? 0) === (int)$c['id'] ? 'selected' : '';
|
|
$companyOptions .= "<option value=\"{$c['id']}\" {$sel}>" . self::h($c['name'] ?? $c['display_name'] ?? '') . '</option>';
|
|
}
|
|
$showCompany = empty($v['companyId']);
|
|
$companyTh = $showCompany ? '<th>Empresa</th>' : '';
|
|
|
|
// Active nav link
|
|
$navDashboard = 'active';
|
|
$navLive = '';
|
|
$navPending = '';
|
|
$navCompanies = '';
|
|
|
|
// Filas de la tabla
|
|
$rows = '';
|
|
if (empty($v['logs'])) {
|
|
$colspan = $showCompany ? 8 : 7;
|
|
$rows = "<tr><td colspan=\"{$colspan}\" 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));
|
|
$company = self::h($log['company_name'] ?? '—');
|
|
$rows .= "<tr>"
|
|
. "<td class='id-col'>#{$id}</td>"
|
|
. "<td>{$time}</td>"
|
|
. ($showCompany ? "<td class='company-cell'>{$company}</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:-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;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:8px 18px;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:6px;letter-spacing:.01em}
|
|
.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)}
|
|
/* Stats - Glass cards */
|
|
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;padding:20px 28px 0}
|
|
.sc{background:#fff;border-radius:12px;padding:18px 20px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03);border-left:4px solid #1565c0;position:relative;overflow:hidden}
|
|
.sc::after{content:'';position:absolute;inset:0;opacity:.03;background:linear-gradient(135deg,#1565c0,transparent 60%)}
|
|
.sc .v{font-size:28px;font-weight:800;color:#0b3d91;letter-spacing:-.03em;line-height:1.1}
|
|
.sc .l{font-size:12px;color:#7a8291;margin-top:4px;font-weight:500}
|
|
.sc.g{border-color:#27ae60}.sc.g::after{background:linear-gradient(135deg,#27ae60,transparent 60%)}.sc.g .v{color:#1a7a3a}
|
|
.sc.o{border-color:#e67e22}.sc.o::after{background:linear-gradient(135deg,#e67e22,transparent 60%)}.sc.o .v{color:#b85e0e}
|
|
.sc.s{border-color:#5f6b7a}.sc.s::after{background:linear-gradient(135deg,#5f6b7a,transparent 60%)}.sc.s .v{color:#3d4552}
|
|
/* Filters */
|
|
.fbar{padding:14px 24px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;background:#fff;margin:16px 28px 0;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03)}
|
|
.fbar input,.fbar select{padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;background:#fafbfc;transition:all .15s;color:#1a1d23}
|
|
.fbar input:focus,.fbar select:focus{border-color:#1565c0;background:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)}
|
|
.bf{background:#0b3d91;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s}
|
|
.bf:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)}
|
|
.rc{margin-left:auto;font-size:12px;color:#7a8291}
|
|
/* Table */
|
|
.tw{margin:14px 28px 28px;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}
|
|
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}
|
|
.company-cell{font-weight:500;color:#0b3d91;font-size:12px}
|
|
.phone{font-family:'SF Mono',Monaco,monospace;font-size:13px;color:#3d4552}
|
|
.preview{color:#5f6b7a;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.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}
|
|
.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}
|
|
.btn-json{background:#f0f4ff;color:#0b3d91;border:1px solid #d6e4ff;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;transition:all .15s}
|
|
.btn-json:hover{background:#dbeafe;border-color:#0b3d91}
|
|
.pager{display:flex;justify-content:center;padding:16px;gap:4px;flex-wrap:wrap}
|
|
.pg{padding:6px 14px;border:1px solid #e2e5ea;border-radius:8px;text-decoration:none;color:#0b3d91;font-size:13px;background:#fff;transition:all .15s;font-weight:500}
|
|
.pg.active{background:#0b3d91;color:#fff;border-color:#0b3d91;box-shadow:0 2px 8px rgba(11,61,145,.2)}
|
|
.pg:hover:not(.active){background:#f0f4ff;border-color:#0b3d91}
|
|
.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}
|
|
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}
|
|
@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}
|
|
}
|
|
.erp-bar{display:flex;align-items:center;gap:12px;padding:10px 28px;flex-wrap:wrap}
|
|
.erp-label{font-size:11px;font-weight:700;color:#7a8291;text-transform:uppercase;letter-spacing:.5px}
|
|
.erp-load{font-size:12px;color:#7a8291;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
.erp-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:3px;box-shadow:0 0 4px rgba(0,0,0,.1)}
|
|
.erp-dot.up{background:#27ae60}
|
|
.erp-dot.down{background:#e74c3c}
|
|
.erp-dot.degraded{background:#e67e22}
|
|
.erp-dot.unknown{background:#95a5a6}
|
|
.erp-badge{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:20px;font-size:11px;font-weight:600;margin:2px 4px}
|
|
.erp-badge.up{background:#e8f8f0;color:#1e8449;border:1px solid #a3e4bc}
|
|
.erp-badge.down{background:#fdecea;color:#c0392b;border:1px solid #f5b7b1}
|
|
.erp-badge.degraded{background:#fef5e7;color:#ca6f1e;border:1px solid #f9e79f}
|
|
.erp-badge.unknown{background:#f3f4f6;color:#616a6b;border:1px solid #e2e5ea}
|
|
</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)"> Palmas360</h1>
|
|
<small>Somos19D</small>
|
|
</div>
|
|
<div class="topbar-r">
|
|
<span class="ubadge">👤 {$userName}</span>
|
|
<a href="/logout" class="btn-out">Salir</a>
|
|
</div>
|
|
</div>
|
|
|
|
<nav class="nav-bar">
|
|
<a href="/admin/dashboard" class="nav-link {$navDashboard}">📊 Dashboard</a>
|
|
<a href="/admin/live" class="nav-link {$navLive}">🔴 Live Feed</a>
|
|
<a href="/admin/pending" class="nav-link {$navPending}">⏳ Pendientes ({$sPending})</a>
|
|
<a href="/admin/companies" class="nav-link {$navCompanies}">🏢 Empresas</a>
|
|
<a href="/admin/sync-companies" class="nav-link" onclick="return confirm('¿Sincronizar empresas desde el ERP?')">🔄 Sincronizar</a>
|
|
<a href="/admin/process-queue" class="nav-link" onclick="return confirm('¿Procesar cola de mensajes?')">📨 Procesar Cola</a>
|
|
<a href="/admin/bot-config" class="nav-link">🤖 Bot Config</a>
|
|
<a href="/admin/settings" class="nav-link">⚙️ Configuración</a>
|
|
</nav>
|
|
|
|
<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 class="sc" style="border-left-color:#e74c3c"><div class="v" style="color:#e74c3c">{$sPending}</div> <div class="l">⏳ Pendientes aprobación</div></div>
|
|
<div class="sc" style="border-left-color:#0b3d91"><div class="v" style="color:#0b3d91">{$companyCount}</div> <div class="l">🏢 Empresas</div></div>
|
|
</div>
|
|
|
|
<div class="erp-bar" id="erpBar">
|
|
<span class="erp-label">📡 Estado ERP</span>
|
|
<span class="erp-load" id="erpLoad">Cargando...</span>
|
|
</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>
|
|
<select name="company_id">{$companyOptions}</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>
|
|
{$companyTh}
|
|
<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);
|
|
})();
|
|
|
|
// ─── ERP Health ─────────────────────────────────────────────────────────────
|
|
(async function loadErpHealth() {
|
|
try {
|
|
const r = await fetch('/admin/erp-health');
|
|
if (!r.ok) { document.getElementById('erpLoad').textContent = 'Error al cargar'; return; }
|
|
const data = await r.json();
|
|
const bar = document.getElementById('erpBar');
|
|
let html = '';
|
|
(data.results || []).forEach(erp => {
|
|
const st = erp.status || 'unknown';
|
|
const ms = erp.latency_ms != null ? erp.latency_ms + 'ms' : '—';
|
|
html += '<span class=\"erp-badge ' + st + '\"><span class=\"erp-dot ' + st + '\"></span>' + esc(erp.company_name) + ' (' + ms + ')</span>';
|
|
});
|
|
document.getElementById('erpLoad').innerHTML = html || 'Sin empresas configuradas';
|
|
} catch(e) {
|
|
document.getElementById('erpLoad').textContent = 'Error de conexión';
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
|
|
// ─── GET /admin/pending ────────────────────────────────────────────────
|
|
|
|
public static function pending(): void
|
|
{
|
|
SessionAuth::require();
|
|
$user = SessionAuth::user();
|
|
$userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8');
|
|
$companyId = isset($_GET['company_id']) ? (int)$_GET['company_id'] : null;
|
|
$items = $companyId ? PendingApproval::findByCompany($companyId, 'pending') : PendingApproval::findAll('pending');
|
|
$companies = CompanyRepository::findAll();
|
|
|
|
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>Pendientes — Palmas360</title>
|
|
<style>
|
|
*{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{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-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-bar{background:#fff;padding:10px 28px;display:flex;gap:2px;flex-wrap:wrap;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:8px 18px;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:6px;letter-spacing:.01em}
|
|
.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)}
|
|
.wrap{padding:24px;max-width:1000px;margin:0 auto}
|
|
.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}
|
|
table{width:100%;border-collapse:separate;border-spacing:0}
|
|
th{background:#f8f9fd;text-align:left;padding:12px 16px;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;font-size:13px;vertical-align:middle}
|
|
tr:last-child td{border-bottom:none}
|
|
tr:hover td{background:#f8faff}
|
|
.empty{text-align:center;padding:56px 24px;color:#a0a8b8;font-size:15px}
|
|
.badge{display:inline-flex;align-items:center;padding:4px 10px;border-radius:20px;font-size:11px;font-weight:600;gap:4px}
|
|
.badge-pending{background:#fef3c7;color:#92400e;border:1px solid #f9e79f}
|
|
.fbar{display:flex;gap:12px;align-items:center;padding:16px 0;flex-wrap:wrap}
|
|
.fbar select,.fbar button{padding:8px 14px;border-radius:8px;border:1px solid #e2e5ea;font-size:13px;outline:none}
|
|
.fbar select{background:#fafbfc}
|
|
.fbar select:focus{border-color:#1565c0;background:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)}
|
|
.fbar button{background:#0b3d91;color:#fff;border:none;cursor:pointer;font-weight:600;transition:all .15s}
|
|
.fbar button:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)}
|
|
.preview{max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#5f6b7a}
|
|
.btn-sm{background:#0b3d91;color:#fff;border:none;border-radius:7px;padding:6px 14px;font-size:12px;font-weight:600;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:4px}
|
|
.btn-sm:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)}
|
|
.btn-sm.rej{background:#991b1b}
|
|
.btn-sm.rej:hover{background:#b91c1c;box-shadow:0 2px 8px rgba(153,27,27,.2)}
|
|
.msg{display:inline-block;padding:7px 14px;background:#f0f4ff;border-radius:10px;max-width:300px;font-size:13px;word-break:break-word;color:#1a1d23}
|
|
@media(max-width:768px){.wrap{padding:12px;overflow-x:auto}}
|
|
</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)"> Pendientes de Aprobación</h1>
|
|
</div>
|
|
<div class="topbar-r">
|
|
<span class="ubadge">👤 {$userName}</span>
|
|
<a href="/logout" class="btn-out">Salir</a>
|
|
</div>
|
|
</div>
|
|
<nav class="nav-bar">
|
|
<a href="/admin/dashboard" class="nav-link">📊 Dashboard</a>
|
|
<a href="/admin/live" class="nav-link">🔴 Live Feed</a>
|
|
<a href="/admin/pending" class="nav-link active">⏳ Pendientes</a>
|
|
<a href="/admin/chat" class="nav-link">💬 Chat</a>
|
|
<a href="/admin/companies" class="nav-link">🏢 Empresas</a>
|
|
<a href="/admin/sync-companies" class="nav-link" onclick="return confirm('¿Sincronizar empresas desde el ERP?')">🔄 Sincronizar</a>
|
|
<a href="/admin/process-queue" class="nav-link" onclick="return confirm('¿Procesar cola de mensajes?')">📨 Procesar Cola</a>
|
|
<a href="/admin/bot-config" class="nav-link">🤖 Bot Config</a>
|
|
<a href="/admin/settings" class="nav-link">⚙️ Configuración</a>
|
|
</nav>
|
|
<div class="wrap">
|
|
<form class="fbar" method="GET">
|
|
<label style="font-size:13px;color:#555">Filtrar por empresa:</label>
|
|
<select name="company_id">
|
|
<option value="">Todas</option>
|
|
HTML;
|
|
foreach ($companies as $c) {
|
|
$sel = ($companyId && (int)$c['id'] === $companyId) ? ' selected' : '';
|
|
$cn = htmlspecialchars($c['name'] ?? '', ENT_QUOTES, 'UTF-8');
|
|
$cd = htmlspecialchars($c['display_name'] ?? '', ENT_QUOTES, 'UTF-8');
|
|
echo "<option value=\"{$c['id']}\"{$sel}>{$cn} — {$cd}</option>";
|
|
}
|
|
echo '<button type="submit">Filtrar</button></form>';
|
|
|
|
if (empty($items)) {
|
|
echo '<div class="card"><div class="empty">✅ No hay mensajes pendientes de aprobación.</div></div>';
|
|
} else {
|
|
echo '<div class="card"><table><thead><tr>
|
|
<th>ID</th><th>Empresa</th><th>De</th><th>Mensaje recibido</th><th>Respuesta</th><th>Estado</th><th>Acción</th>
|
|
</tr></thead><tbody>';
|
|
foreach ($items as $item) {
|
|
$id = (int)$item['id'];
|
|
$company = htmlspecialchars($item['company_name'] ?? '-', ENT_QUOTES, 'UTF-8');
|
|
$from = htmlspecialchars($item['from_phone'] ?? '-', ENT_QUOTES, 'UTF-8');
|
|
$inMsg = htmlspecialchars(mb_substr($item['incoming_message'] ?? '', 0, 60), ENT_QUOTES, 'UTF-8');
|
|
$reply = htmlspecialchars(mb_substr($item['reply_body'] ?? '', 0, 60), ENT_QUOTES, 'UTF-8');
|
|
$status = $item['status'] ?? 'pending';
|
|
echo "<tr>
|
|
<td><code>{$id}</code></td>
|
|
<td>{$company}</td>
|
|
<td><code>{$from}</code></td>
|
|
<td><span class=\"msg\">{$inMsg}</span></td>
|
|
<td><span class=\"msg\">{$reply}</span></td>
|
|
<td><span class=\"badge badge-pending\">{$status}</span></td>
|
|
<td style='white-space:nowrap'>
|
|
<button class=\"btn-sm\" onclick=\"doApprove({$id})\">✓ Aprobar</button>
|
|
<button class=\"btn-sm rej\" onclick=\"doReject({$id})\">✗ Rechazar</button>
|
|
</td>
|
|
</tr>";
|
|
}
|
|
echo '</tbody></table></div>';
|
|
}
|
|
echo <<<HTML
|
|
</div>
|
|
<script>
|
|
async function doApprove(id) {
|
|
if (!confirm('¿Aprobar respuesta? Se encolará para envío.')) return;
|
|
const r = await fetch('/admin/pending-approve', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id}) });
|
|
const j = await r.json();
|
|
if (j.status === 'approved') { location.reload(); } else { alert('Error: ' + (j.error || j.message)); }
|
|
}
|
|
async function doReject(id) {
|
|
if (!confirm('¿Rechazar respuesta? No se enviará.')) return;
|
|
const r = await fetch('/admin/pending-reject', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id}) });
|
|
const j = await r.json();
|
|
if (j.status === 'rejected') { location.reload(); } else { alert('Error: ' + (j.error || j.message)); }
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
|
|
// ─── GET /admin/companies ──────────────────────────────────────────────
|
|
|
|
public static function companies(): void
|
|
{
|
|
SessionAuth::require();
|
|
$user = SessionAuth::user();
|
|
$userName = self::h($user['name'] ?? 'Admin');
|
|
$companies = CompanyRepository::findAll(true);
|
|
$companyCount = count($companies);
|
|
|
|
$msg = $_GET['msg'] ?? '';
|
|
$toastHtml = '';
|
|
if ($msg !== '') {
|
|
$map = [
|
|
'created' => 'Empresa creada exitosamente.',
|
|
'updated' => 'Empresa actualizada exitosamente.',
|
|
'deleted' => 'Empresa eliminada.',
|
|
'error' => 'Ocurrió un error. Intente de nuevo.',
|
|
];
|
|
$text = self::h($map[$msg] ?? '');
|
|
$cls = in_array($msg, ['created', 'updated']) ? 'toast-success' : 'toast-error';
|
|
if ($text !== '') {
|
|
$toastHtml = '<div class="toast ' . $cls . '">' . $text . '</div>';
|
|
}
|
|
}
|
|
|
|
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>Empresas — Palmas360</title>
|
|
<style>
|
|
*{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{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-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-bar{background:#fff;padding:10px 28px;display:flex;gap:2px;flex-wrap:wrap;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:8px 18px;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:6px;letter-spacing:.01em}
|
|
.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)}
|
|
.wrap{padding:24px;max-width:1200px;margin:0 auto}
|
|
.toolbar-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:10px}
|
|
.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-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}
|
|
.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}
|
|
table{width:100%;border-collapse:separate;border-spacing:0}
|
|
th{background:#f8f9fd;text-align:left;padding:12px 16px;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;font-size:13px;vertical-align:middle}
|
|
tr:last-child td{border-bottom:none}
|
|
tr:hover td{background:#f8faff}
|
|
.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{background:#fee2e2;color:#991b1b;border:1px solid #fecaca}
|
|
.tag-no{background:#dcfce7;color:#166534;border:1px solid #a3e4bc}
|
|
.tag-on{background:#dcfce7;color:#166534;border:1px solid #a3e4bc}
|
|
.tag-off{background:#fee2e2;color:#991b1b;border:1px solid #fecaca}
|
|
.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}
|
|
code{background:#f3f4f6;padding:2px 6px;border-radius:5px;font-size:12px;color:#3d4552;font-family:'SF Mono',Monaco,monospace}
|
|
.count-row{margin-top:14px;font-size:12px;color:#7a8291;text-align:center}
|
|
.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}
|
|
@media(max-width:768px){
|
|
.wrap{padding:12px;overflow-x:auto}
|
|
th:nth-child(7),td:nth-child(7){display:none}
|
|
}
|
|
</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)"> Empresas</h1>
|
|
</div>
|
|
<div class="topbar-r">
|
|
<span class="ubadge">👤 {$userName}</span>
|
|
<a href="/logout" class="btn-out">Salir</a>
|
|
</div>
|
|
</div>
|
|
<nav class="nav-bar">
|
|
<a href="/admin/dashboard" class="nav-link">📊 Dashboard</a>
|
|
<a href="/admin/live" class="nav-link">🔴 Live Feed</a>
|
|
<a href="/admin/pending" class="nav-link">⏳ Pendientes</a>
|
|
<a href="/admin/chat" class="nav-link">💬 Chat</a>
|
|
<a href="/admin/companies" class="nav-link active">🏢 Empresas</a>
|
|
<a href="/admin/sync-companies" class="nav-link" onclick="return confirm('¿Sincronizar empresas desde el ERP?')">🔄 Sincronizar</a>
|
|
<a href="/admin/process-queue" class="nav-link" onclick="return confirm('¿Procesar cola de mensajes?')">📨 Procesar Cola</a>
|
|
<a href="/admin/bot-config" class="nav-link">🤖 Bot Config</a>
|
|
<a href="/admin/settings" class="nav-link">⚙️ Configuración</a>
|
|
</nav>
|
|
<div class="wrap">
|
|
{$toastHtml}
|
|
<div class="toolbar-row">
|
|
<span style="font-size:14px;font-weight:600;color:#333">Listado de Empresas</span>
|
|
<a href="/admin/company/edit" class="btn-primary">➕ Agregar Empresa</a>
|
|
</div>
|
|
<div class="card">
|
|
HTML;
|
|
if (empty($companies)) {
|
|
echo '<div class="empty">No hay empresas registradas. <a href="/admin/sync-companies">Sincronizar desde el ERP</a></div>';
|
|
} else {
|
|
echo '<table><thead><tr>
|
|
<th>ID</th><th>Nombre</th><th>WhatsApp Phone ID</th><th>Teléfono</th><th>Bot Type</th><th>Aprueba</th><th>Activo</th><th>API URL</th><th>Acciones</th>
|
|
</tr></thead><tbody>';
|
|
foreach ($companies as $c) {
|
|
$id = (int)$c['id'];
|
|
$name = self::h($c['name'] ?? '');
|
|
$dname = self::h($c['display_name'] ?? '');
|
|
$pid = self::h((string)($c['phone_number_id'] ?? ''));
|
|
$phone = self::h($c['display_phone'] ?? '');
|
|
$bot = $c['bot_type'] ?? 'normal';
|
|
$apr = !empty($c['requires_approval']);
|
|
$act = !empty($c['is_active']);
|
|
$url = self::h($c['api_base_url'] ?? '');
|
|
$btag = match($bot) { 'hybrid' => 'hybrid', 'ai' => 'ai', default => 'normal' };
|
|
$eName = self::h($c['name'] ?? '');
|
|
echo "<tr>
|
|
<td><code>{$id}</code></td>
|
|
<td><strong>{$name}</strong><br><small style='color:#888'>{$dname}</small></td>
|
|
<td><code>{$pid}</code></td>
|
|
<td>{$phone}</td>
|
|
<td><span class=\"tag tag-{$btag}\">{$bot}</span></td>
|
|
<td><span class=\"tag " . ($apr ? 'tag-yes' : 'tag-no') . '">' . ($apr ? 'Sí' : 'No') . "</span></td>
|
|
<td><span class=\"tag " . ($act ? 'tag-on' : 'tag-off') . '">' . ($act ? 'Sí' : 'No') . "</span></td>
|
|
<td style='max-width:200px;overflow:hidden;text-overflow:ellipsis'><code>{$url}</code></td>
|
|
<td style='white-space:nowrap'>
|
|
<a href=\"/admin/company/edit?id={$id}\" class=\"btn-sm\">✎ Editar</a>
|
|
<a href=\"/admin/company/delete?id={$id}\" class=\"btn-danger\" onclick=\"return confirm('¿Eliminar empresa {$eName}?')\">🗑 Eliminar</a>
|
|
</td>
|
|
</tr>";
|
|
}
|
|
echo '</tbody></table>';
|
|
}
|
|
echo <<<HTML
|
|
</div>
|
|
<div class="count-row">Total: {$companyCount} empresa(s)</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
|
|
// ─── GET /admin/company/edit ─────────────────────────────────────────────
|
|
|
|
public static function companyEdit(): void
|
|
{
|
|
SessionAuth::require();
|
|
$user = SessionAuth::user();
|
|
$userName = self::h($user['name'] ?? 'Admin');
|
|
$id = (int)($_GET['id'] ?? 0);
|
|
$isEdit = $id > 0;
|
|
$company = $isEdit ? CompanyRepository::findById($id) : null;
|
|
|
|
$cv = fn(string $k, string $d = '') => self::h($company[$k] ?? $d);
|
|
$name = $cv('name');
|
|
$display_name = $cv('display_name');
|
|
$phone_number_id = $cv('phone_number_id');
|
|
$display_phone = $cv('display_phone');
|
|
$api_base_url = $cv('api_base_url');
|
|
$api_key = $cv('api_key');
|
|
$config_json = $cv('config_json');
|
|
$bot_type = $company['bot_type'] ?? 'normal';
|
|
$requires_approval = !empty($company['requires_approval']);
|
|
$is_active = !empty($company['is_active']);
|
|
$reqAprChecked = $requires_approval ? 'checked' : '';
|
|
$isActChecked = $is_active ? 'checked' : '';
|
|
|
|
$botOptions = '';
|
|
foreach (['normal' => 'Normal', 'ai' => 'AI', 'hybrid' => 'Híbrido'] as $val => $label) {
|
|
$sel = $bot_type === $val ? 'selected' : '';
|
|
$botOptions .= "<option value=\"{$val}\" {$sel}>{$label}</option>\n";
|
|
}
|
|
|
|
$pageTitle = $isEdit ? 'Editar Empresa' : 'Nueva Empresa';
|
|
|
|
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>{$pageTitle} — Palmas360</title>
|
|
<style>
|
|
*{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{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-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-bar{background:#fff;padding:10px 28px;display:flex;gap:2px;flex-wrap:wrap;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:8px 18px;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:6px;letter-spacing:.01em}
|
|
.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)}
|
|
.wrap{padding:24px;max-width:800px;margin:0 auto}
|
|
.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;padding:28px;border:1px solid #eef1f5}
|
|
.card h2{font-size:18px;margin-bottom:22px;color:#0b3d91;font-weight:700}
|
|
.form-group{margin-bottom:18px}
|
|
.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 textarea{min-height:120px;resize:vertical;font-family:'SF Mono',Monaco,monospace;font-size:12px;line-height:1.6}
|
|
.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:auto;margin:0;width:16px;height:16px;accent-color:#0b3d91}
|
|
.form-group.checkbox label{margin:0;cursor:pointer;font-size:13px;color:#3d4552;font-weight:500}
|
|
.btn-row{display:flex;gap:12px;margin-top:28px;padding-top:20px;border-top:1px solid #eef1f5}
|
|
.btn-primary{background:#0b3d91;color:#fff;border:none;border-radius:10px;padding:10px 24px;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;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:10px;padding:10px 24px;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;transition:all .15s}
|
|
.btn-secondary:hover{background:#dbeafe;border-color:#0b3d91}
|
|
@media(max-width:768px){.wrap{padding:12px}.card{padding:16px}.form-row .form-group{min-width:100%}}
|
|
</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)"> {$pageTitle}</h1>
|
|
</div>
|
|
<div class="topbar-r">
|
|
<span class="ubadge">👤 {$userName}</span>
|
|
<a href="/logout" class="btn-out">Salir</a>
|
|
</div>
|
|
</div>
|
|
<nav class="nav-bar">
|
|
<a href="/admin/dashboard" class="nav-link">📊 Dashboard</a>
|
|
<a href="/admin/live" class="nav-link">🔴 Live Feed</a>
|
|
<a href="/admin/pending" class="nav-link">⏳ Pendientes</a>
|
|
<a href="/admin/chat" class="nav-link">💬 Chat</a>
|
|
<a href="/admin/companies" class="nav-link active">🏢 Empresas</a>
|
|
<a href="/admin/sync-companies" class="nav-link" onclick="return confirm('¿Sincronizar empresas desde el ERP?')">🔄 Sincronizar</a>
|
|
<a href="/admin/process-queue" class="nav-link" onclick="return confirm('¿Procesar cola de mensajes?')">📨 Procesar Cola</a>
|
|
<a href="/admin/bot-config" class="nav-link">🤖 Bot Config</a>
|
|
<a href="/admin/settings" class="nav-link">⚙️ Configuración</a>
|
|
</nav>
|
|
<div class="wrap">
|
|
<div class="card">
|
|
<h2>{$pageTitle}</h2>
|
|
<form method="POST" action="/admin/company/save">
|
|
HTML;
|
|
if ($isEdit) {
|
|
echo '<input type="hidden" name="id" value="' . $id . '">';
|
|
}
|
|
echo <<<HTML
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="name">Nombre *</label>
|
|
<input type="text" id="name" name="name" value="{$name}" required placeholder="Ej: Comercializadora ABC">
|
|
<div class="hint">Nombre interno de la empresa</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="display_name">Nombre mostrado</label>
|
|
<input type="text" id="display_name" name="display_name" value="{$display_name}" placeholder="Ej: ABC S.A. de C.V.">
|
|
<div class="hint">Nombre visible en reportes (opcional)</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="phone_number_id">WhatsApp Phone Number ID *</label>
|
|
<input type="text" id="phone_number_id" name="phone_number_id" value="{$phone_number_id}" required placeholder="Ej: 123456789012345">
|
|
<div class="hint">ID numérico del número de teléfono de WhatsApp Business</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="display_phone">Teléfono mostrado</label>
|
|
<input type="text" id="display_phone" name="display_phone" value="{$display_phone}" placeholder="Ej: +521234567890">
|
|
<div class="hint">Número telefónico visible (opcional)</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="api_base_url">API Base URL *</label>
|
|
<input type="text" id="api_base_url" name="api_base_url" value="{$api_base_url}" required placeholder="Ej: https://erp.ejemplo.com/api/">
|
|
<div class="hint">Endpoint base del ERP para esta empresa</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="api_key">API Key</label>
|
|
<input type="text" id="api_key" name="api_key" value="{$api_key}" placeholder="Token de autenticación">
|
|
<div class="hint">Clave API para autenticación ERP ↔ Bot (opcional)</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="bot_type">Tipo de Bot</label>
|
|
<select id="bot_type" name="bot_type">{$botOptions}</select>
|
|
<div class="hint">Comportamiento del bot: normal (reglas), AI (inteligente), híbrido (combinado)</div>
|
|
</div>
|
|
<div class="form-group checkbox">
|
|
<input type="checkbox" id="requires_approval" name="requires_approval" value="1" {$reqAprChecked}>
|
|
<label for="requires_approval">Requiere aprobación manual</label>
|
|
</div>
|
|
<div class="form-group checkbox">
|
|
<input type="checkbox" id="is_active" name="is_active" value="1" {$isActChecked}>
|
|
<label for="is_active">Activo</label>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="config_json">Configuración JSON</label>
|
|
<textarea id="config_json" name="config_json" placeholder='{"key": "value", ...}'>{$config_json}</textarea>
|
|
<div class="hint">Configuración adicional del bot en formato JSON</div>
|
|
</div>
|
|
<div class="btn-row">
|
|
<button type="submit" class="btn-primary">💾 Guardar</button>
|
|
<a href="/admin/companies" class="btn-secondary">Cancelar</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
|
|
// ─── GET /admin/sync-companies ──────────────────────────────────────────
|
|
|
|
public static function syncCompanies(): void
|
|
{
|
|
SessionAuth::require();
|
|
$user = SessionAuth::user();
|
|
$userName = self::h($user['name'] ?? 'Admin');
|
|
$result = ErpSync::sync();
|
|
$hasError = isset($result['error']);
|
|
$icon = $hasError ? '❌' : '✅';
|
|
$title = $hasError ? 'Error en Sincronización' : 'Sincronización Exitosa';
|
|
$titleClass = $hasError ? 'error' : 'success';
|
|
$resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
|
|
|
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>Sincronizar — Palmas360</title>
|
|
<style>
|
|
*{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{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-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-bar{background:#fff;padding:10px 28px;display:flex;gap:2px;flex-wrap:wrap;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:8px 18px;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:6px;letter-spacing:.01em}
|
|
.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)}
|
|
.wrap{padding:24px;max-width:800px;margin:0 auto}
|
|
.wrap{padding:24px;max-width:800px;margin:0 auto}
|
|
.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);overflow:hidden;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}
|
|
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;margin-top:16px;font-family:'SF Mono',Monaco,monospace;box-shadow:inset 0 2px 8px rgba(0,0,0,.2)}
|
|
.btn-row{text-align:center;margin-top:24px}
|
|
.btn-primary{background:#0b3d91;color:#fff;border:none;border-radius:10px;padding:10px 24px;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block;transition:all .15s}
|
|
.btn-primary:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)}
|
|
</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)"> Sincronizar Empresas</h1>
|
|
</div>
|
|
<div class="topbar-r">
|
|
<span class="ubadge">👤 {$userName}</span>
|
|
<a href="/logout" class="btn-out">Salir</a>
|
|
</div>
|
|
</div>
|
|
<nav class="nav-bar">
|
|
<a href="/admin/dashboard" class="nav-link">📊 Dashboard</a>
|
|
<a href="/admin/live" class="nav-link">🔴 Live Feed</a>
|
|
<a href="/admin/pending" class="nav-link">⏳ Pendientes</a>
|
|
<a href="/admin/chat" class="nav-link">💬 Chat</a>
|
|
<a href="/admin/companies" class="nav-link active">🏢 Empresas</a>
|
|
<a href="/admin/sync-companies" class="nav-link" onclick="return confirm('¿Sincronizar empresas desde el ERP?')">🔄 Sincronizar</a>
|
|
<a href="/admin/process-queue" class="nav-link" onclick="return confirm('¿Procesar cola de mensajes?')">📨 Procesar Cola</a>
|
|
<a href="/admin/bot-config" class="nav-link">🤖 Bot Config</a>
|
|
<a href="/admin/settings" class="nav-link">⚙️ Configuración</a>
|
|
</nav>
|
|
<div class="wrap">
|
|
<div class="result-card">
|
|
<div class="result-icon">{$icon}</div>
|
|
<div class="result-title {$titleClass}">{$title}</div>
|
|
<pre>{$resultJson}</pre>
|
|
<div class="btn-row">
|
|
<a href="/admin/companies" class="btn-primary">Volver a empresas</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
|
|
// ─── GET /admin/process-queue ───────────────────────────────────────────
|
|
|
|
public static function processQueue(): void
|
|
{
|
|
SessionAuth::require();
|
|
$user = SessionAuth::user();
|
|
$userName = self::h($user['name'] ?? 'Admin');
|
|
$result = OutboundWorker::processQueue();
|
|
$hasError = !empty($result['errors']);
|
|
$icon = $hasError ? '⚠' : '✅';
|
|
$title = $hasError ? 'Cola Procesada con Advertencias' : 'Cola Procesada Exitosamente';
|
|
$titleClass = $hasError ? 'error' : 'success';
|
|
$resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
|
|
|
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>Procesar Cola — Palmas360</title>
|
|
<style>
|
|
*{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{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-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-bar{background:#fff;padding:10px 28px;display:flex;gap:2px;flex-wrap:wrap;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:8px 18px;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:6px;letter-spacing:.01em}
|
|
.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)}
|
|
.wrap{padding:24px;max-width:800px;margin:0 auto}
|
|
.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);overflow:hidden;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}
|
|
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;margin-top:16px;font-family:'SF Mono',Monaco,monospace;box-shadow:inset 0 2px 8px rgba(0,0,0,.2)}
|
|
.btn-row{text-align:center;margin-top:24px}
|
|
.btn-primary{background:#0b3d91;color:#fff;border:none;border-radius:10px;padding:10px 24px;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block;transition:all .15s}
|
|
.btn-primary:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)}
|
|
</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)"> Procesar Cola de Mensajes</h1>
|
|
</div>
|
|
<div class="topbar-r">
|
|
<span class="ubadge">👤 {$userName}</span>
|
|
<a href="/logout" class="btn-out">Salir</a>
|
|
</div>
|
|
</div>
|
|
<nav class="nav-bar">
|
|
<a href="/admin/dashboard" class="nav-link active">📊 Dashboard</a>
|
|
<a href="/admin/live" class="nav-link">🔴 Live Feed</a>
|
|
<a href="/admin/pending" class="nav-link">⏳ Pendientes</a>
|
|
<a href="/admin/chat" class="nav-link">💬 Chat</a>
|
|
<a href="/admin/companies" class="nav-link">🏢 Empresas</a>
|
|
<a href="/admin/sync-companies" class="nav-link" onclick="return confirm('¿Sincronizar empresas desde el ERP?')">🔄 Sincronizar</a>
|
|
<a href="/admin/process-queue" class="nav-link" onclick="return confirm('¿Procesar cola de mensajes?')">📨 Procesar Cola</a>
|
|
<a href="/admin/bot-config" class="nav-link">🤖 Bot Config</a>
|
|
<a href="/admin/settings" class="nav-link">⚙️ Configuración</a>
|
|
</nav>
|
|
<div class="wrap">
|
|
<div class="result-card">
|
|
<div class="result-icon">{$icon}</div>
|
|
<div class="result-title {$titleClass}">{$title}</div>
|
|
<pre>{$resultJson}</pre>
|
|
<div class="btn-row">
|
|
<a href="/admin/dashboard" class="btn-primary">Volver a dashboard</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
|
|
// ─── GET /admin/settings ────────────────────────────────────────────────
|
|
|
|
public static function settings(): void
|
|
{
|
|
SessionAuth::require();
|
|
$user = SessionAuth::user();
|
|
$userName = self::h($user['name'] ?? 'Admin');
|
|
$settings = Settings::all();
|
|
$msg = $_GET['msg'] ?? '';
|
|
$toastHtml = '';
|
|
if ($msg === 'saved') {
|
|
$toastHtml = '<div class="toast toast-success">Configuración guardada exitosamente.</div>';
|
|
}
|
|
|
|
$fields = [
|
|
'WhatsApp Cloud API' => [
|
|
['key' => 'whatsapp_access_token', 'label' => 'Access Token', 'type' => 'password', 'placeholder' => 'EAAMzB...'],
|
|
['key' => 'whatsapp_app_secret', 'label' => 'App Secret', 'type' => 'password', 'placeholder' => 'a4f82c1d...'],
|
|
['key' => 'whatsapp_verify_token', 'label' => 'Verify Token', 'type' => 'text', 'placeholder' => 'PLM360_WH_...'],
|
|
['key' => 'whatsapp_business_account_id', 'label' => 'Business Account ID', 'type' => 'text', 'placeholder' => '920641783052817'],
|
|
['key' => 'whatsapp_default_phone_number_id', 'label' => 'Phone Number ID (default)', 'type' => 'text', 'placeholder' => '384710295638401'],
|
|
],
|
|
'Inteligencia Artificial' => [
|
|
['key' => 'ai_provider', 'label' => 'Proveedor', 'type' => 'select', 'options' => ['mock' => 'Mock (simulado)', 'openai' => 'OpenAI']],
|
|
['key' => 'openai_api_key', 'label' => 'OpenAI API Key', 'type' => 'password', 'placeholder' => 'sk-...'],
|
|
['key' => 'openai_model', 'label' => 'Modelo', 'type' => 'select', 'options' => ['gpt-4o-mini' => 'GPT-4o Mini', 'gpt-4o' => 'GPT-4o', 'gpt-3.5-turbo' => 'GPT-3.5 Turbo']],
|
|
['key' => 'ai_max_tokens', 'label' => 'Máximo de tokens', 'type' => 'number', 'placeholder' => '500'],
|
|
['key' => 'ai_default_prompt', 'label' => 'System Prompt por defecto', 'type' => 'textarea', 'placeholder' => 'Eres un asistente...'],
|
|
],
|
|
];
|
|
|
|
$versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 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>Configuración — Palmas360</title>
|
|
<style>
|
|
*{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{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-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-bar{background:#fff;padding:10px 28px;display:flex;gap:2px;flex-wrap:wrap;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:8px 18px;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:6px;letter-spacing:.01em}
|
|
.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)}
|
|
.wrap{padding:24px;max-width:900px;margin:0 auto}
|
|
.card{background:#fff;border-radius:12px;box-shadow:0 1px 5px rgba(0,0,0,.08);padding:0;margin-bottom:20px;overflow:hidden}
|
|
.card-h{background:linear-gradient(135deg,#0b3d91,#1565c0);color:#fff;padding:14px 20px;font-size:14px;font-weight:700}
|
|
.card-b{padding:20px}
|
|
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
|
.fw{grid-column:1/-1}
|
|
.lbl{display:block;font-size:12px;font-weight:600;color:#555;margin-bottom:4px}
|
|
.inp{width:100%;padding:9px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;outline:none;transition:border .2s}
|
|
.inp:focus{border-color:#1565c0}
|
|
.inp-sel{padding:9px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;outline:none;background:#fff;cursor:pointer}
|
|
textarea.inp{min-height:90px;resize:vertical;font-family:inherit}
|
|
.btn-primary{background:#0b3d91;color:#fff;border:none;border-radius:8px;padding:10px 24px;font-size:14px;font-weight:600;cursor:pointer}
|
|
.btn-primary:hover{background:#1565c0}
|
|
.btn-row{display:flex;justify-content:flex-end;gap:10px;margin-top:18px}
|
|
.toast{padding:12px 18px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:16px;text-align:center}
|
|
.toast-success{background:#dcfce7;color:#166534;border:1px solid #a3e4bc}
|
|
.version-info{margin-top:16px;padding:12px;background:#f8f9fb;border-radius:8px;font-size:12px;color:#888;text-align:center}
|
|
small{color:#888;font-size:11px;display:block;margin-top:2px}
|
|
@media(max-width:640px){.form-grid{grid-template-columns:1fr}.wrap{padding:12px}}
|
|
</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)"> Configuración</h1>
|
|
</div>
|
|
<div class="topbar-r">
|
|
<span class="ubadge">👤 {$userName}</span>
|
|
<a href="/logout" class="btn-out">Salir</a>
|
|
</div>
|
|
</div>
|
|
<nav class="nav-bar">
|
|
<a href="/admin/dashboard" class="nav-link">📊 Dashboard</a>
|
|
<a href="/admin/live" class="nav-link">🔴 Live Feed</a>
|
|
<a href="/admin/pending" class="nav-link">⏳ Pendientes</a>
|
|
<a href="/admin/chat" class="nav-link">💬 Chat</a>
|
|
<a href="/admin/companies" class="nav-link">🏢 Empresas</a>
|
|
<a href="/admin/bot-config" class="nav-link">🤖 Bot Config</a>
|
|
<a href="/admin/settings" class="nav-link active">⚙️ Configuración</a>
|
|
<a href="/admin/sync-companies" class="nav-link" onclick="return confirm('¿Sincronizar empresas desde el ERP?')">🔄 Sincronizar</a>
|
|
<a href="/admin/process-queue" class="nav-link" onclick="return confirm('¿Procesar cola de mensajes?')">📨 Procesar Cola</a>
|
|
<a href="/admin/settings" class="nav-link">⚙️ Configuración</a>
|
|
</nav>
|
|
<div class="wrap">
|
|
{$toastHtml}
|
|
<form method="POST" action="/admin/settings/save">
|
|
HTML;
|
|
foreach ($fields as $sectionTitle => $sectionFields) {
|
|
echo '<div class="card">';
|
|
echo '<div class="card-h">' . self::h($sectionTitle) . '</div>';
|
|
echo '<div class="card-b"><div class="form-grid">';
|
|
foreach ($sectionFields as $f) {
|
|
$key = $f['key'];
|
|
$val = self::h($settings[$key] ?? '');
|
|
$label = self::h($f['label']);
|
|
$placeholder = self::h($f['placeholder'] ?? '');
|
|
$full = ($f['type'] === 'textarea') ? ' fw' : '';
|
|
echo "<div class=\"{$full}\">";
|
|
echo "<label class=\"lbl\" for=\"{$key}\">{$label}</label>";
|
|
if ($f['type'] === 'select' && !empty($f['options'])) {
|
|
echo "<select class=\"inp-sel\" id=\"{$key}\" name=\"{$key}\" style=\"width:100%\">";
|
|
foreach ($f['options'] as $optVal => $optLabel) {
|
|
$sel = ($settings[$key] ?? '') === $optVal ? ' selected' : '';
|
|
echo "<option value=\"" . self::h($optVal) . "\"{$sel}>" . self::h($optLabel) . "</option>";
|
|
}
|
|
echo "</select>";
|
|
} elseif ($f['type'] === 'textarea') {
|
|
echo "<textarea class=\"inp\" id=\"{$key}\" name=\"{$key}\" placeholder=\"{$placeholder}\">{$val}</textarea>";
|
|
} elseif ($f['type'] === 'number') {
|
|
echo "<input class=\"inp\" type=\"number\" id=\"{$key}\" name=\"{$key}\" value=\"{$val}\" placeholder=\"{$placeholder}\">";
|
|
} else {
|
|
echo "<input class=\"inp\" type=\"{$f['type']}\" id=\"{$key}\" name=\"{$key}\" value=\"{$val}\" placeholder=\"{$placeholder}\">";
|
|
}
|
|
echo "</div>";
|
|
}
|
|
echo '</div></div></div>';
|
|
}
|
|
echo <<<HTML
|
|
<div class="btn-row">
|
|
<a href="/admin/dashboard" class="btn-out" style="background:#ddd;color:#333;padding:10px 24px;border-radius:8px;text-decoration:none;font-size:14px">Cancelar</a>
|
|
<button type="submit" class="btn-primary">Guardar configuración</button>
|
|
</div>
|
|
</form>
|
|
<div class="version-info">Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
|
|
// ─── Chat ────────────────────────────────────────────────────────────────
|
|
|
|
public static function chat(): void
|
|
{
|
|
SessionAuth::require();
|
|
$user = SessionAuth::user();
|
|
$userName = self::h($user['name'] ?? 'Admin');
|
|
$companies = CompanyRepository::findAll();
|
|
|
|
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>Chat — Palmas360</title>
|
|
<style>
|
|
*{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;overflow:hidden;height:100vh}
|
|
.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-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-bar{background:#fff;padding:10px 28px;display:flex;gap:2px;flex-wrap:wrap;border-bottom:1px solid #eef1f5;box-shadow:0 1px 3px rgba(0,0,0,.04);position:sticky;top:52px;z-index:99}
|
|
.nav-link{padding:8px 18px;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:6px;letter-spacing:.01em}
|
|
.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)}
|
|
.chat-layout{display:flex;height:calc(100vh - 104px);overflow:hidden}
|
|
.conv-sidebar{width:340px;min-width:340px;background:#fff;border-right:1px solid #eef1f5;display:flex;flex-direction:column;overflow:hidden}
|
|
.conv-search{padding:14px 16px;border-bottom:1px solid #eef1f5}
|
|
.conv-search input{width:100%;padding:9px 14px 9px 36px;border:1px solid #e2e5ea;border-radius:10px;font-size:13px;outline:none;background:#f5f7fb url('data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22%23999%22><path d=%22M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z/%22></svg>') 12px 50% no-repeat;background-size:16px;transition:all .15s}
|
|
.conv-search input:focus{border-color:#1565c0;background-color:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)}
|
|
.conv-list{flex:1;overflow-y:auto;overflow-x:hidden}
|
|
.conv-item{display:flex;align-items:center;gap:12px;padding:14px 16px;cursor:pointer;border-bottom:1px solid #f5f7fb;transition:all .15s}
|
|
.conv-item:hover{background:#f8faff}
|
|
.conv-item.active{background:#f0f4ff;border-left:3px solid #0b3d91;padding-left:13px}
|
|
.conv-avatar{width:42px;height:42px;border-radius:50%;background:linear-gradient(135deg,#e8eaf6,#d6e4ff);display:flex;align-items:center;justify-content:center;font-size:16px;color:#0b3d91;font-weight:700;flex-shrink:0}
|
|
.conv-info{flex:1;min-width:0}
|
|
.conv-name{font-size:13px;font-weight:600;color:#1a1d23;display:flex;align-items:center;gap:6px}
|
|
.conv-phone{font-size:11px;color:#7a8291;font-family:monospace;margin-top:1px}
|
|
.conv-last{font-size:12px;color:#7a8291;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.conv-meta{text-align:right;flex-shrink:0}
|
|
.conv-time{font-size:11px;color:#a0a8b8}
|
|
.conv-unread{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:9px;background:#0b3d91;color:#fff;font-size:10px;font-weight:700;padding:0 5px;margin-top:4px}
|
|
.conv-company{font-size:10px;color:#0b3d91;background:#f0f4ff;padding:1px 6px;border-radius:4px;font-weight:600}
|
|
.chat-main{flex:1;display:flex;flex-direction:column;background:#f5f7fb}
|
|
.chat-header{display:none;padding:14px 22px;background:#fff;border-bottom:1px solid #eef1f5;align-items:center;gap:12px}
|
|
.chat-header.show{display:flex}
|
|
.chat-info{flex:1}
|
|
.chat-info .name{font-size:14px;font-weight:600}
|
|
.chat-info .phone{font-size:12px;color:#7a8291;font-family:monospace}
|
|
.chat-company-tag{font-size:10px;color:#0b3d91;background:#f0f4ff;padding:2px 8px;border-radius:4px;font-weight:600}
|
|
.chat-messages{flex:1;overflow-y:auto;padding:20px 22px;display:none;flex-direction:column;gap:6px}
|
|
.chat-messages.show{display:flex}
|
|
.msg-row{display:flex;margin-bottom:4px;animation:msgIn .2s ease}
|
|
@keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
|
.msg-row.inbound{justify-content:flex-start}
|
|
.msg-row.outbound{justify-content:flex-end}
|
|
.msg-bubble{max-width:75%;padding:10px 14px;border-radius:16px;font-size:13px;line-height:1.5;position:relative;word-wrap:break-word}
|
|
.msg-row.inbound .msg-bubble{background:#fff;color:#1a1d23;border-bottom-left-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
|
|
.msg-row.outbound .msg-bubble{background:#0b3d91;color:#fff;border-bottom-right-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
|
.msg-meta{display:flex;align-items:center;gap:6px;margin-top:4px;font-size:10px}
|
|
.msg-row.inbound .msg-meta{color:#a0a8b8}
|
|
.msg-row.outbound .msg-meta{color:rgba(255,255,255,.65);justify-content:flex-end}
|
|
.msg-status{font-size:11px}
|
|
.msg-status .sent{color:rgba(255,255,255,.5)}
|
|
.msg-status .delivered{color:#8fcbff}
|
|
.msg-status .read{color:#5cb3ff}
|
|
.msg-reactions{display:flex;gap:2px;margin-top:2px;font-size:16px}
|
|
.msg-media{max-width:240px;border-radius:10px;overflow:hidden;margin-bottom:4px;cursor:pointer}
|
|
.msg-media img{width:100%;display:block;border-radius:10px;border:1px solid rgba(0,0,0,.06)}
|
|
.msg-media.doc{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#f8f9fd;border:1px solid #e2e5ea;border-radius:8px;color:#0b3d91;font-size:12px;font-weight:600}
|
|
.msg-media.doc .ico{font-size:20px}
|
|
.chat-empty{flex:1;display:none;align-items:center;justify-content:center;color:#a0a8b8;flex-direction:column;gap:12px}
|
|
.chat-empty.show{display:flex}
|
|
.chat-empty .big{font-size:48px}
|
|
.chat-input{display:none;padding:14px 22px 18px;background:#fff;border-top:1px solid #eef1f5;gap:10px;align-items:flex-end}
|
|
.chat-input.show{display:flex}
|
|
.chat-input textarea{flex:1;padding:10px 14px;border:1px solid #e2e5ea;border-radius:12px;font-size:13px;outline:none;resize:none;font-family:inherit;max-height:120px;min-height:42px;line-height:1.5;transition:border .15s}
|
|
.chat-input textarea:focus{border-color:#1565c0;box-shadow:0 0 0 3px rgba(21,101,192,.08)}
|
|
.chat-input .send-btn{width:42px;height:42px;border-radius:50%;background:#0b3d91;color:#fff;border:none;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:all .15s;flex-shrink:0}
|
|
.chat-input .send-btn:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.25);transform:scale(1.05)}
|
|
.chat-input .send-btn:disabled{opacity:.4;cursor:not-allowed;transform:none}
|
|
.msg-date-sep{text-align:center;font-size:11px;color:#a0a8b8;padding:12px 0 8px;font-weight:500}
|
|
.typing{display:flex;gap:4px;padding:6px 0}
|
|
.typing span{width:6px;height:6px;border-radius:50%;background:#a0a8b8;animation:typing 1.2s infinite}
|
|
.typing span:nth-child(2){animation-delay:.2s}
|
|
.typing span:nth-child(3){animation-delay:.4s}
|
|
@keyframes typing{0%,60%,100%{opacity:.3}30%{opacity:1}}
|
|
@media(max-width:768px){.conv-sidebar{width:100%;min-width:auto}.chat-main{display:none}.chat-main.mobile{display:flex;position:fixed;inset:0;z-index:200}.conv-sidebar.mobile{display:none}}
|
|
</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)"> Chat</h1>
|
|
</div>
|
|
<div class="topbar-r">
|
|
<span class="ubadge">👤 {$userName}</span>
|
|
<a href="/logout" class="btn-out">Salir</a>
|
|
</div>
|
|
</div>
|
|
<nav class="nav-bar">
|
|
<a href="/admin/dashboard" class="nav-link">📊 Dashboard</a>
|
|
<a href="/admin/live" class="nav-link">🔴 Live Feed</a>
|
|
<a href="/admin/pending" class="nav-link">⏳ Pendientes</a>
|
|
<a href="/admin/chat" class="nav-link active">💬 Chat</a>
|
|
<a href="/admin/companies" class="nav-link">🏢 Empresas</a>
|
|
<a href="/admin/bot-config" class="nav-link">🤖 Bot Config</a>
|
|
<a href="/admin/settings" class="nav-link">⚙️ Configuración</a>
|
|
<a href="/admin/sync-companies" class="nav-link" onclick="return confirm('¿Sincronizar empresas desde el ERP?')">🔄 Sincronizar</a>
|
|
<a href="/admin/process-queue" class="nav-link" onclick="return confirm('¿Procesar cola de mensajes?')">📨 Procesar Cola</a>
|
|
</nav>
|
|
<div class="chat-layout">
|
|
<div class="conv-sidebar" id="convSidebar">
|
|
<div class="conv-search">
|
|
<input type="text" id="convSearch" placeholder="Buscar conversación..." oninput="filterConvs()">
|
|
</div>
|
|
<div class="conv-list" id="convList"></div>
|
|
</div>
|
|
<div class="chat-main">
|
|
<div class="chat-header" id="chatHeader">
|
|
<div class="chat-info">
|
|
<div class="name" id="chatName"></div>
|
|
<div class="phone" id="chatPhone"></div>
|
|
</div>
|
|
<span class="chat-company-tag" id="chatCompanyTag"></span>
|
|
</div>
|
|
<div class="chat-messages" id="chatMessages"></div>
|
|
<div class="chat-empty show" id="chatEmpty">
|
|
<div class="big">💬</div>
|
|
<div>Selecciona una conversación</div>
|
|
</div>
|
|
<div class="chat-input" id="chatInput">
|
|
<textarea id="msgInput" rows="1" placeholder="Escribe un mensaje..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMsg()}"></textarea>
|
|
<button class="send-btn" id="sendBtn" onclick="sendMsg()">➤</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
let activePhone = '';
|
|
|
|
// ─── Load conversations ──────────────────────────────────────────────────────
|
|
async function loadConvs() {
|
|
const r = await fetch('/admin/chat/conversations');
|
|
const data = await r.json();
|
|
const list = document.getElementById('convList');
|
|
list.innerHTML = data.conversations.map(c => {
|
|
const time = formatTime(c.last_time);
|
|
const avatar = (c.contact_name || c.phone).charAt(0).toUpperCase();
|
|
const unread = c.unread_count > 0 ? \`<div class="conv-unread">\${c.unread_count}</div>\` : '';
|
|
const active = c.phone === activePhone ? ' active' : '';
|
|
const company = c.company_name ? \`<span class="conv-company">\${esc(c.company_name)}</span>\` : '';
|
|
return \`<div class="conv-item\${active}" onclick="selectConv('\${c.phone}','\${esc(c.contact_name||c.phone)}','\${esc(c.company_name||'')}')">
|
|
<div class="conv-avatar">\${avatar}</div>
|
|
<div class="conv-info">
|
|
<div class="conv-name">\${esc(c.contact_name || 'Desconocido')} \${company}</div>
|
|
<div class="conv-phone">\${c.phone}</div>
|
|
<div class="conv-last">\${esc(c.last_message || '')}</div>
|
|
</div>
|
|
<div class="conv-meta">
|
|
<div class="conv-time">\${time}</div>
|
|
\${unread}
|
|
</div>
|
|
</div>\`;
|
|
}).join('');
|
|
}
|
|
|
|
// ─── 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');
|
|
});
|
|
document.getElementById('chatHeader').classList.add('show');
|
|
document.getElementById('chatEmpty').classList.remove('show');
|
|
document.getElementById('chatMessages').classList.add('show');
|
|
document.getElementById('chatInput').classList.add('show');
|
|
document.getElementById('chatName').textContent = name;
|
|
document.getElementById('chatPhone').textContent = phone;
|
|
document.getElementById('chatCompanyTag').textContent = company;
|
|
loadMessages(phone);
|
|
document.getElementById('msgInput').focus();
|
|
}
|
|
|
|
// ─── Load messages ───────────────────────────────────────────────────────────
|
|
async function loadMessages(phone) {
|
|
const r = await fetch('/admin/chat/messages?phone=' + encodeURIComponent(phone));
|
|
const data = await r.json();
|
|
const msgsDiv = document.getElementById('chatMessages');
|
|
if (!data.messages || data.messages.length === 0) {
|
|
msgsDiv.innerHTML = '<div style="text-align:center;padding:20px;color:#a0a8b8">No hay mensajes</div>';
|
|
return;
|
|
}
|
|
let html = '';
|
|
let lastDate = '';
|
|
data.messages.forEach(m => {
|
|
const d = m.created_at ? m.created_at.substring(0,10) : '';
|
|
if (d && d !== lastDate) {
|
|
html += \`<div class="msg-date-sep">\${formatDate(d)}</div>\`;
|
|
lastDate = d;
|
|
}
|
|
const isOut = m.direction === 'outbound';
|
|
const time = m.created_at ? m.created_at.substring(11,16) : '';
|
|
const statusIcon = isOut ? statusHtml(m.status) : '';
|
|
const reactions = m.reactions ? \`<div class="msg-reactions">\${esc(m.reactions)}</div>\` : '';
|
|
const media = m.media_id ? mediaHtml(m) : '';
|
|
html += \`<div class="msg-row \${isOut ? 'outbound' : 'inbound'}">
|
|
<div class="msg-bubble">
|
|
\${media}
|
|
<div>\${esc(m.content || '')}</div>
|
|
<div class="msg-meta">
|
|
<span>\${time}</span>
|
|
\${statusIcon}
|
|
</div>
|
|
\${reactions}
|
|
</div>
|
|
</div>\`;
|
|
});
|
|
msgsDiv.innerHTML = html;
|
|
msgsDiv.scrollTop = msgsDiv.scrollHeight;
|
|
}
|
|
|
|
function mediaHtml(m) {
|
|
if (m.message_type === 'image' && m.media_id) {
|
|
return \`<div class="msg-media"><img src="\${esc(m.media_id)}" alt="Imagen" loading="lazy" onclick="window.open(this.src)"></div>\`;
|
|
}
|
|
if (m.message_type === 'document' && m.media_id) {
|
|
return \`<a href="\${esc(m.media_id)}" target="_blank" class="msg-media doc"><span class="ico">📄</span> Ver documento</a>\`;
|
|
}
|
|
if (m.message_type === 'reaction') {
|
|
return \`<div style="font-size:32px;padding:4px 0">\${esc(m.content || '')}</div>\`;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function statusHtml(status) {
|
|
if (!status) return '';
|
|
const icons = {queued:'⏳', sent:'✓', delivered:'✓✓', read:'👁', failed:'✗'};
|
|
const cls = {queued:'', sent:'sent', delivered:'delivered', read:'read', failed:''};
|
|
const ico = icons[status] || '';
|
|
return \`<span class="msg-status \${cls[status]||''}">\${ico}</span>\`;
|
|
}
|
|
|
|
// ─── Send message ────────────────────────────────────────────────────────────
|
|
async function sendMsg() {
|
|
const input = document.getElementById('msgInput');
|
|
const text = input.value.trim();
|
|
if (!text || !activePhone) return;
|
|
document.getElementById('sendBtn').disabled = true;
|
|
try {
|
|
const r = await fetch('/admin/chat/send', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({phone:activePhone, text})
|
|
});
|
|
const j = await r.json();
|
|
if (j.status === 'sent') {
|
|
input.value = '';
|
|
loadMessages(activePhone);
|
|
loadConvs();
|
|
} else {
|
|
alert('Error: ' + (j.error || 'desconocido'));
|
|
}
|
|
} catch(e) {
|
|
alert('Error de conexión');
|
|
}
|
|
document.getElementById('sendBtn').disabled = false;
|
|
}
|
|
|
|
// ─── Filter conversations ────────────────────────────────────────────────────
|
|
function filterConvs() {
|
|
const q = document.getElementById('convSearch').value.toLowerCase();
|
|
document.querySelectorAll('.conv-item').forEach(el => {
|
|
const txt = el.textContent.toLowerCase();
|
|
el.style.display = txt.includes(q) ? 'flex' : 'none';
|
|
});
|
|
}
|
|
|
|
// ─── Utils ───────────────────────────────────────────────────────────────────
|
|
function esc(s) { return (s||'').replace(/[&<>"']/g,function(m){return({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]}); }
|
|
function formatTime(t) {
|
|
if (!t) return '';
|
|
const d = new Date(t.replace(' ','T')+'Z');
|
|
const now = new Date();
|
|
const diff = (now - d) / 1000;
|
|
if (diff < 60) return 'ahora';
|
|
if (diff < 3600) return Math.floor(diff/60) + 'm';
|
|
if (diff < 86400) return d.getHours().toString().padStart(2,'0')+':'+d.getMinutes().toString().padStart(2,'0');
|
|
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate()-1);
|
|
if (d.toDateString() === yesterday.toDateString()) return 'ayer';
|
|
return d.getDate().toString().padStart(2,'0')+'/'+(d.getMonth()+1).toString().padStart(2,'0');
|
|
}
|
|
function formatDate(d) {
|
|
const now = new Date(); const dt = new Date(d+'T12:00:00');
|
|
if (dt.toDateString() === now.toDateString()) return 'Hoy';
|
|
const y = new Date(now); y.setDate(y.getDate()-1);
|
|
if (dt.toDateString() === y.toDateString()) return 'Ayer';
|
|
return dt.getDate().toString().padStart(2,'0')+'/'+(dt.getMonth()+1).toString().padStart(2,'0')+'/'+dt.getFullYear();
|
|
}
|
|
|
|
loadConvs();
|
|
setInterval(loadConvs, 15000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
|
|
// ─── API: lista de conversaciones ────────────────────────────────────────
|
|
|
|
public static function chatConversations(): void
|
|
{
|
|
SessionAuth::require();
|
|
$db = db();
|
|
// Get unique conversations with last message and unread count
|
|
$rows = $db->query("
|
|
SELECT c.phone_number, c.contact_name, c.company_id,
|
|
MAX(c.created_at) as last_time,
|
|
(SELECT content FROM conversations c2 WHERE c2.phone_number = c.phone_number ORDER BY c2.id DESC LIMIT 1) as last_message,
|
|
(SELECT COUNT(*) FROM conversations c3 WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound' AND c3.id > COALESCE((SELECT MAX(c4.id) FROM conversations c4 WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'), 0)) as unread_count
|
|
FROM conversations c
|
|
GROUP BY c.phone_number, c.contact_name, c.company_id
|
|
ORDER BY last_time DESC
|
|
")->fetchAll();
|
|
|
|
$companies = [];
|
|
foreach (CompanyRepository::findAll() as $co) {
|
|
$companies[(int)$co['id']] = $co['name'];
|
|
}
|
|
|
|
$conversations = [];
|
|
foreach ($rows as $r) {
|
|
$cid = (int)$r['company_id'];
|
|
$conversations[] = [
|
|
'phone' => $r['phone_number'],
|
|
'contact_name' => $r['contact_name'] ?? '',
|
|
'company_name' => $companies[$cid] ?? '',
|
|
'last_message' => mb_substr($r['last_message'] ?? '', 0, 80),
|
|
'last_time' => $r['last_time'],
|
|
'unread_count' => (int)$r['unread_count'],
|
|
];
|
|
}
|
|
|
|
jsonResponse(200, ['conversations' => $conversations]);
|
|
}
|
|
|
|
// ─── API: mensajes de una conversación ───────────────────────────────────
|
|
|
|
public static function chatMessages(): void
|
|
{
|
|
SessionAuth::require();
|
|
$phone = trim($_GET['phone'] ?? '');
|
|
if ($phone === '') jsonResponse(400, ['error' => 'phone requerido']);
|
|
|
|
$stmt = db()->prepare("
|
|
SELECT direction, message_type, content, media_id, status, created_at
|
|
FROM conversations
|
|
WHERE phone_number = ?
|
|
ORDER BY id ASC
|
|
");
|
|
$stmt->execute([$phone]);
|
|
$messages = $stmt->fetchAll();
|
|
|
|
// Also include outbound_queue items for this phone
|
|
$stmt2 = db()->prepare("
|
|
SELECT 'outbound' as direction, message_type, payload as content, NULL as media_id, status, created_at
|
|
FROM outbound_queue
|
|
WHERE to_number = ? AND status IN ('sent','delivered','failed')
|
|
AND id > COALESCE((SELECT MAX(c.id) FROM conversations c WHERE c.phone_number = ?), 0)
|
|
ORDER BY id ASC
|
|
");
|
|
$stmt2->execute([$phone, $phone]);
|
|
$queueMsgs = $stmt2->fetchAll();
|
|
|
|
foreach ($queueMsgs as &$qm) {
|
|
$pl = json_decode($qm['content'], true);
|
|
$qm['content'] = $pl['text'] ?? $pl['caption'] ?? $qm['content'];
|
|
}
|
|
|
|
$all = array_merge($messages, $queueMsgs);
|
|
usort($all, fn($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? ''));
|
|
|
|
jsonResponse(200, ['messages' => $all]);
|
|
}
|
|
|
|
// ─── API: enviar mensaje desde el chat ───────────────────────────────────
|
|
|
|
public static function chatSend(): void
|
|
{
|
|
SessionAuth::require();
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$phone = trim($input['phone'] ?? '');
|
|
$text = trim($input['text'] ?? '');
|
|
if ($phone === '' || $text === '') {
|
|
jsonResponse(400, ['error' => 'phone y text requeridos']);
|
|
}
|
|
|
|
// Find company for this phone (lookup by latest conversation)
|
|
$stmt = db()->prepare("SELECT company_id FROM conversations WHERE phone_number = ? ORDER BY id DESC LIMIT 1");
|
|
$stmt->execute([$phone]);
|
|
$conv = $stmt->fetch();
|
|
$companyId = $conv ? (int)$conv['company_id'] : 1;
|
|
|
|
// Enqueue the message
|
|
$db = db();
|
|
$payload = json_encode(['text' => $text]);
|
|
$qStmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload, status, created_at) VALUES (?, ?, 'text', ?, 'queued', NOW())");
|
|
$qStmt->execute([$companyId, $phone, $payload]);
|
|
|
|
// Add to conversations
|
|
$msgId = 'admin_' . time() . '_' . $phone;
|
|
$cStmt = $db->prepare("INSERT INTO conversations (company_id, message_id, phone_number, direction, message_type, content, created_at) VALUES (?, ?, ?, 'outbound', 'text', ?, NOW())");
|
|
$cStmt->execute([$companyId, $msgId, $phone, $text]);
|
|
|
|
jsonResponse(200, ['status' => 'sent']);
|
|
}
|
|
|
|
// ─── GET /admin/bot-config ──────────────────────────────────────────────
|
|
|
|
public static function botConfig(): void
|
|
{
|
|
SessionAuth::require();
|
|
$user = SessionAuth::user();
|
|
$userName = self::h($user['name'] ?? 'Admin');
|
|
$companyId = (int)($_GET['id'] ?? 0);
|
|
$companies = CompanyRepository::findAll();
|
|
$company = null;
|
|
$config = ['commands' => [], 'menus' => [], 'flows' => [], 'ai_prompt' => '', 'fallback' => '', 'greeting' => ''];
|
|
$msg = $_GET['msg'] ?? '';
|
|
$toastHtml = $msg === 'saved' ? '<div class="toast toast-success">Configuración guardada exitosamente.</div>' : '';
|
|
|
|
if ($companyId > 0) {
|
|
$company = CompanyRepository::findById($companyId);
|
|
if ($company) {
|
|
$cj = $company['config_json'] ?? '';
|
|
if ($cj) {
|
|
$parsed = json_decode($cj, true);
|
|
if (is_array($parsed)) $config = $parsed;
|
|
}
|
|
}
|
|
}
|
|
|
|
$configJson = self::h(json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
|
|
|
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>Configurar Bot — Palmas360</title>
|
|
<style>
|
|
*{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{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-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-bar{background:#fff;padding:10px 28px;display:flex;gap:2px;flex-wrap:wrap;border-bottom:1px solid #eef1f5;box-shadow:0 1px 3px rgba(0,0,0,.04);position:sticky;top:52px;z-index:99}
|
|
.nav-link{padding:8px 18px;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:6px;letter-spacing:.01em}
|
|
.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)}
|
|
.wrap{padding:24px;max-width:1100px;margin:0 auto}
|
|
.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}
|
|
.tabs{display:flex;gap:2px;margin-bottom:0;flex-wrap:wrap;position:sticky;top:104px;z-index:90;background:#f5f7fb;padding:10px 0 0}
|
|
.tab-btn{padding:10px 18px;border:none;border-radius:10px 10px 0 0;font-size:13px;font-weight:600;cursor:pointer;background:#e8ecf2;color:#5f6b7a;transition:all .15s}
|
|
.tab-btn:hover{background:#dce1e8}
|
|
.tab-btn.active{background:#fff;color:#0b3d91;box-shadow:0 -2px 4px rgba(0,0,0,.04)}
|
|
.tab-content{display:none;background:#fff;border-radius:0 12px 12px 12px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03);padding:24px;border:1px solid #eef1f5;margin-bottom:24px}
|
|
.tab-content.active{display:block}
|
|
.card-h{font-size:16px;font-weight:700;color:#0b3d91;margin-bottom:16px;padding-bottom:10px;border-bottom:2px solid #eef1f5}
|
|
.form-group{margin-bottom:14px}
|
|
.form-group label{display:block;font-size:12px;font-weight:600;color:#3d4552;margin-bottom:4px}
|
|
.form-group input,.form-group select,.form-group textarea{width:100%;padding:9px 12px;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 textarea{min-height:60px;resize:vertical;font-family:'SF Mono',Monaco,monospace;font-size:12px;line-height:1.6}
|
|
.form-row{display:flex;gap:12px;flex-wrap:wrap}
|
|
.form-row .form-group{flex:1;min-width:150px}
|
|
.btn-primary{background:#0b3d91;color:#fff;border:none;border-radius:8px;padding:10px 24px;font-size:14px;font-weight:600;cursor:pointer;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;transition:all .15s}
|
|
.btn-secondary:hover{background:#dbeafe}
|
|
.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}
|
|
.item-card{background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:16px;margin-bottom:12px}
|
|
.item-card .item-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;font-weight:600;font-size:13px;color:#0b3d91}
|
|
.item-card .item-head .del{cursor:pointer;color:#991b1b;font-size:12px;background:none;border:none;font-weight:600}
|
|
.add-btn{background:none;border:2px dashed #d6e4ff;border-radius:8px;padding:10px;text-align:center;color:#0b3d91;font-size:13px;font-weight:600;cursor:pointer;width:100%;transition:all .15s}
|
|
.add-btn:hover{background:#f0f4ff;border-color:#0b3d91}
|
|
.inline-group{display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap}
|
|
.inline-group input,.inline-group select{flex:1;min-width:100px}
|
|
.inline-group .del{margin-left:auto}
|
|
table.simple{width:100%;border-collapse:collapse;margin-bottom:12px}
|
|
table.simple th{text-align:left;padding:8px 10px;font-size:11px;font-weight:700;color:#0b3d91;text-transform:uppercase;letter-spacing:.3px;border-bottom:2px solid #e8eaf6;background:#f8f9fd}
|
|
table.simple td{padding:8px 10px;border-bottom:1px solid #f0f2f5;font-size:13px;vertical-align:middle}
|
|
.company-select{display:flex;gap:12px;align-items:center;margin-bottom:20px;flex-wrap:wrap}
|
|
.company-select select{padding:9px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;background:#fff;min-width:250px}
|
|
.company-select select:focus{border-color:#1565c0}
|
|
@media(max-width:768px){.wrap{padding:12px}.tabs{top:auto}.tab-content{padding:16px}}
|
|
</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)"> Configurar Bot</h1></div>
|
|
<div class="topbar-r"><span class="ubadge">👤 {$userName}</span><a href="/logout" class="btn-out">Salir</a></div>
|
|
</div>
|
|
<nav class="nav-bar">
|
|
<a href="/admin/dashboard" class="nav-link">📊 Dashboard</a>
|
|
<a href="/admin/live" class="nav-link">🔴 Live Feed</a>
|
|
<a href="/admin/pending" class="nav-link">⏳ Pendientes</a>
|
|
<a href="/admin/chat" class="nav-link">💬 Chat</a>
|
|
<a href="/admin/companies" class="nav-link">🏢 Empresas</a>
|
|
<a href="/admin/bot-config" class="nav-link active">🤖 Bot Config</a>
|
|
<a href="/admin/settings" class="nav-link">⚙️ Configuración</a>
|
|
<a href="/admin/sync-companies" class="nav-link" onclick="return confirm('¿Sincronizar empresas desde el ERP?')">🔄 Sincronizar</a>
|
|
<a href="/admin/process-queue" class="nav-link" onclick="return confirm('¿Procesar cola de mensajes?')">📨 Procesar Cola</a>
|
|
</nav>
|
|
<div class="wrap">
|
|
{$toastHtml}
|
|
<div class="company-select">
|
|
<label style="font-weight:600;font-size:13px">Seleccionar empresa:</label>
|
|
<select onchange="if(this.value) window.location='?id='+this.value">
|
|
<option value="">— Selecciona —</option>
|
|
HTML;
|
|
foreach ($companies as $c) {
|
|
$sel = $companyId === (int)$c['id'] ? 'selected' : '';
|
|
echo "<option value=\"{$c['id']}\" {$sel}>" . self::h($c['name'] . ' — ' . ($c['display_name'] ?? '')) . "</option>";
|
|
}
|
|
echo '</select></div>';
|
|
|
|
if (!$company) {
|
|
echo '<div class="tab-content active" style="border-radius:12px;text-align:center;padding:40px;color:#a0a8b8;font-size:15px">Selecciona una empresa para configurar su bot.</div>';
|
|
echo '</div></body></html>';
|
|
exit;
|
|
}
|
|
|
|
$c = $company;
|
|
$cId = (int)$c['id'];
|
|
$cName = self::h($c['name'] ?? '');
|
|
|
|
// Pre-fill form values from config
|
|
$greetingVal = self::h($config['greeting'] ?? '');
|
|
$fallbackVal = self::h($config['fallback'] ?? '');
|
|
$aiPromptVal = self::h($config['ai_prompt'] ?? '');
|
|
$aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini');
|
|
$aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7));
|
|
$aiProviderVal = self::h($config['ai_provider'] ?? '');
|
|
$approvalWebhookVal = self::h($config['approval_webhook'] ?? '');
|
|
$ignoreVal = self::h(implode("\n", $config['ignore_prefixes'] ?? []));
|
|
$commands = $config['commands'] ?? [];
|
|
$menus = $config['menus'] ?? [];
|
|
$flows = $config['flows'] ?? [];
|
|
$aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : '';
|
|
$aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : '';
|
|
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
|
|
$aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : '';
|
|
$aiModel35 = $aiModelVal === 'gpt-3.5-turbo' ? ' selected' : '';
|
|
|
|
echo <<<HTML
|
|
<form method="POST" action="/admin/bot-config/save">
|
|
<input type="hidden" name="company_id" value="{$cId}">
|
|
|
|
<div class="tabs">
|
|
<button type="button" class="tab-btn active" onclick="switchTab('commands',this)">📋 Comandos</button>
|
|
<button type="button" class="tab-btn" onclick="switchTab('menus',this)">📑 Menús</button>
|
|
<button type="button" class="tab-btn" onclick="switchTab('flows',this)">🔀 Flujos</button>
|
|
<button type="button" class="tab-btn" onclick="switchTab('ai',this)">🤖 IA</button>
|
|
<button type="button" class="tab-btn" onclick="switchTab('general',this)">⚙️ General</button>
|
|
</div>
|
|
|
|
<!-- ─── COMMANDS ─────────────────────────────────────────────────────── -->
|
|
<div class="tab-content active" id="tab-commands">
|
|
<div class="card-h">📋 Comandos</div>
|
|
<p style="font-size:12px;color:#7a8291;margin-bottom:14px">Palabras clave que el usuario escribe para ejecutar acciones. Ej: "menu", "info", "contacto"</p>
|
|
<table class="simple" id="cmdTable">
|
|
<thead><tr><th>Palabra clave</th><th>Acción (menú o flujo)</th><th></th></tr></thead>
|
|
<tbody id="cmdBody">
|
|
HTML;
|
|
$cmdIdx = 0;
|
|
foreach ($commands as $keyword => $action) {
|
|
echo "<tr id=\"cmd-row-{$cmdIdx}\">
|
|
<td><input type=\"text\" name=\"cmd_keyword[]\" value=\"" . self::h($keyword) . "\" style=\"width:100%;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:13px\"></td>
|
|
<td><input type=\"text\" name=\"cmd_action[]\" value=\"" . self::h($action) . "\" style=\"width:100%;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:13px\" placeholder=\"ej: show_main, servicios...\"></td>
|
|
<td><button type=\"button\" class=\"btn-danger-sm\" onclick=\"this.closest('tr').remove()\">✕</button></td>
|
|
</tr>";
|
|
$cmdIdx++;
|
|
}
|
|
echo <<<HTML
|
|
</tbody>
|
|
</table>
|
|
<button type="button" class="add-btn" onclick="addCmd()">+ Agregar comando</button>
|
|
</div>
|
|
|
|
<!-- ─── MENUS ────────────────────────────────────────────────────────── -->
|
|
<div class="tab-content" id="tab-menus">
|
|
<div class="card-h">📑 Menús Interactivos</div>
|
|
<p style="font-size:12px;color:#7a8291;margin-bottom:14px">Menús tipo lista o botones que se envían al usuario. Cada menú tiene un ID (ej: show_main) y puede tener secciones con filas.</p>
|
|
<div id="menuContainer">
|
|
HTML;
|
|
$menuIdx = 0;
|
|
foreach ($menus as $mk => $menu) {
|
|
$mt = $menu['type'] ?? 'list';
|
|
$mh = self::h($menu['header'] ?? '');
|
|
$mb = self::h($menu['body'] ?? '');
|
|
$mf = self::h($menu['footer'] ?? '');
|
|
$mbtn = self::h($menu['button'] ?? '');
|
|
echo "<div class=\"item-card\" id=\"menu-card-{$menuIdx}\">
|
|
<div class=\"item-head\">
|
|
<span>Menú: <input type=\"text\" name=\"menu_key[]\" value=\"" . self::h($mk) . "\" style=\"border:none;background:transparent;font-weight:700;color:#0b3d91;width:200px;font-size:13px\" placeholder=\"ID del menú (ej: show_main)\"></span>
|
|
<button type=\"button\" class=\"del\" onclick=\"this.closest('.item-card').remove()\">✕ Eliminar menú</button>
|
|
</div>
|
|
<div class=\"form-row\">
|
|
<div class=\"form-group\"><label>Tipo</label><select name=\"menu_type[]\"><option value=\"list\"" . ($mt==='list'?' selected':'') . ">Lista</option><option value=\"button\"" . ($mt==='button'?' selected':'') . ">Botones</option></select></div>
|
|
<div class=\"form-group\"><label>Header</label><input type=\"text\" name=\"menu_header[]\" value=\"{$mh}\" placeholder=\"Título del menú\"></div>
|
|
<div class=\"form-group\"><label>Body</label><input type=\"text\" name=\"menu_body[]\" value=\"{$mb}\" placeholder=\"Cuerpo del mensaje\"></div>
|
|
<div class=\"form-group\"><label>Footer</label><input type=\"text\" name=\"menu_footer[]\" value=\"{$mf}\" placeholder=\"Footer (opcional)\"></div>
|
|
<div class=\"form-group\"><label>Botón</label><input type=\"text\" name=\"menu_button[]\" value=\"{$mbtn}\" placeholder=\"Texto del botón\"></div>
|
|
</div>
|
|
<div style=\"margin-top:8px\">
|
|
<div style=\"font-size:12px;font-weight:600;color:#3d4552;margin-bottom:6px\">Secciones:</div>
|
|
<div class=\"sections-container\" id=\"menu-sections-{$menuIdx}\">";
|
|
$si = 0;
|
|
foreach ($menu['sections'] ?? [] as $section) {
|
|
$st = self::h($section['title'] ?? '');
|
|
echo "<div class=\"section-card\" style=\"background:#fff;border:1px solid #eef1f5;border-radius:8px;padding:12px;margin-bottom:8px\">
|
|
<div style=\"display:flex;align-items:center;gap:8px;margin-bottom:8px\">
|
|
<input type=\"text\" name=\"menu_sections[{$menuIdx}][{$si}][title]\" value=\"{$st}\" placeholder=\"Título de sección\" style=\"flex:1;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:13px\">
|
|
<button type=\"button\" class=\"btn-danger-sm\" onclick=\"this.closest('.section-card').remove()\">✕</button>
|
|
</div>
|
|
<div style=\"font-size:11px;font-weight:600;color:#7a8291;margin-bottom:4px\">Filas (cada fila es una opción):</div>";
|
|
$ri = 0;
|
|
foreach ($section['rows'] ?? [] as $row) {
|
|
$rid = self::h($row['id'] ?? '');
|
|
$rt = self::h($row['title'] ?? '');
|
|
$rd = self::h($row['description'] ?? '');
|
|
echo "<div class=\"inline-group\" style=\"margin-bottom:4px\">
|
|
<input type=\"text\" name=\"menu_sections[{$menuIdx}][{$si}][rows][{$ri}][id]\" value=\"{$rid}\" placeholder=\"ID (ej: servicios)\" style=\"flex:1\">
|
|
<input type=\"text\" name=\"menu_sections[{$menuIdx}][{$si}][rows][{$ri}][title]\" value=\"{$rt}\" placeholder=\"Título\" style=\"flex:1\">
|
|
<input type=\"text\" name=\"menu_sections[{$menuIdx}][{$si}][rows][{$ri}][description]\" value=\"{$rd}\" placeholder=\"Descripción\" style=\"flex:1.5\">
|
|
<button type=\"button\" class=\"btn-danger-sm\" onclick=\"this.closest('.inline-group').remove()\">✕</button>
|
|
</div>";
|
|
$ri++;
|
|
}
|
|
echo "<button type=\"button\" class=\"btn-secondary\" style=\"font-size:11px;padding:4px 10px;margin-top:4px\" onclick=\"addRow(this,{$menuIdx},{$si})\">+ Fila</button>
|
|
</div>";
|
|
$si++;
|
|
}
|
|
echo "</div>
|
|
<button type=\"button\" class=\"btn-secondary\" style=\"font-size:11px;padding:4px 10px;margin-top:6px\" onclick=\"addSection({$menuIdx})\">+ Sección</button>
|
|
</div>
|
|
</div>";
|
|
$menuIdx++;
|
|
}
|
|
echo <<<HTML
|
|
</div>
|
|
<button type="button" class="add-btn" onclick="addMenu()">+ Agregar menú</button>
|
|
</div>
|
|
|
|
<!-- ─── FLOWS ────────────────────────────────────────────────────────── -->
|
|
<div class="tab-content" id="tab-flows">
|
|
<div class="card-h">🔀 Flujos</div>
|
|
<p style="font-size:12px;color:#7a8291;margin-bottom:14px">Acciones que se ejecutan cuando un comando o fila de menú es seleccionada.</p>
|
|
<div id="flowContainer">
|
|
HTML;
|
|
$flowIdx = 0;
|
|
foreach ($flows as $fk => $flow) {
|
|
$ft = $flow['type'] ?? 'text';
|
|
$fMsg = self::h($flow['message'] ?? '');
|
|
$fFn = self::h($flow['function'] ?? 'forward_to_ai');
|
|
$fMenu = self::h($flow['menu'] ?? '');
|
|
echo "<div class=\"item-card\" id=\"flow-card-{$flowIdx}\">
|
|
<div class=\"item-head\">
|
|
<span>ID: <input type=\"text\" name=\"flow_key[]\" value=\"" . self::h($fk) . "\" style=\"border:none;background:transparent;font-weight:700;color:#0b3d91;width:200px;font-size:13px\" placeholder=\"ID del flujo (ej: servicios)\"></span>
|
|
<button type=\"button\" class=\"del\" onclick=\"this.closest('.item-card').remove()\">✕</button>
|
|
</div>
|
|
<div class=\"form-row\">
|
|
<div class=\"form-group\" style=\"min-width:120px\">
|
|
<label>Tipo</label>
|
|
<select name=\"flow_type[]\" onchange=\"flowTypeChange(this)\">
|
|
<option value=\"text\"" . ($ft==='text'?' selected':'') . ">Texto (respuesta fija)</option>
|
|
<option value=\"function\"" . ($ft==='function'?' selected':'') . ">Función (acción especial)</option>
|
|
<option value=\"menu\"" . ($ft==='menu'?' selected':'') . ">Menú (submenú)</option>
|
|
</select>
|
|
</div>
|
|
<div class=\"form-group flow-text\" style=\"" . ($ft==='text'?'':'display:none') . "\">
|
|
<label>Mensaje de respuesta</label>
|
|
<textarea name=\"flow_message[]\" rows=\"2\" placeholder=\"Texto que se enviará al usuario\">{$fMsg}</textarea>
|
|
</div>
|
|
<div class=\"form-group flow-function\" style=\"" . ($ft==='function'?'':'display:none') . "\">
|
|
<label>Función</label>
|
|
<select name=\"flow_function[]\">
|
|
<option value=\"forward_to_ai\"" . ($fFn==='forward_to_ai'?' selected':'') . ">Escalar a IA</option>
|
|
<option value=\"forward_to_agent\"" . ($fFn==='forward_to_agent'?' selected':'') . ">Escalar a agente humano</option>
|
|
<option value=\"webhook\"" . ($fFn==='webhook'?' selected':'') . ">Llamar webhook externo</option>
|
|
</select>
|
|
</div>
|
|
<div class=\"form-group flow-menu\" style=\"" . ($ft==='menu'?'':'display:none') . "\">
|
|
<label>Menú a mostrar</label>
|
|
<input type=\"text\" name=\"flow_menu[]\" value=\"{$fMenu}\" placeholder=\"ID del menú (ej: show_main)\">
|
|
</div>
|
|
</div>
|
|
</div>";
|
|
$flowIdx++;
|
|
}
|
|
echo <<<HTML
|
|
</div>
|
|
<button type="button" class="add-btn" onclick="addFlow()">+ Agregar flujo</button>
|
|
</div>
|
|
|
|
<!-- ─── AI ───────────────────────────────────────────────────────────── -->
|
|
<div class="tab-content" id="tab-ai">
|
|
<div class="card-h">🤖 Inteligencia Artificial</div>
|
|
<div class="form-group">
|
|
<label>Proveedor (sobrescribe el global)</label>
|
|
<select name="ai_provider">
|
|
<option value="">Usar configuración global</option>
|
|
<option value="openai"{$aiProviderOpenai}>OpenAI</option>
|
|
<option value="mock"{$aiProviderMock}>Mock (simulado)</option>
|
|
</select>
|
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">Si no se selecciona, usa el proveedor de Configuración General</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Modelo</label>
|
|
<select name="ai_model">
|
|
<option value="gpt-4o-mini"{$aiModelMini}>GPT-4o Mini</option>
|
|
<option value="gpt-4o"{$aiModel4o}>GPT-4o</option>
|
|
<option value="gpt-3.5-turbo"{$aiModel35}>GPT-3.5 Turbo</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Temperatura ({$aiTempVal})</label>
|
|
<input type="range" name="ai_temperature" min="0" max="1" step="0.1" value="{$aiTempVal}" oninput="this.previousElementSibling.textContent='Temperatura ('+this.value+')'">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>System Prompt</label>
|
|
<textarea name="ai_prompt" rows="4" placeholder="Eres un asistente virtual de {$cName}...">{$aiPromptVal}</textarea>
|
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">Instrucciones que define el comportamiento de la IA</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── GENERAL ──────────────────────────────────────────────────────── -->
|
|
<div class="tab-content" id="tab-general">
|
|
<div class="card-h">⚙️ General</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Mensaje de bienvenida (greeting)</label>
|
|
<textarea name="greeting" rows="2" placeholder="¡Bienvenido! Escribe 'menu' para ver opciones...">{$greetingVal}</textarea>
|
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">Primer mensaje que recibe un usuario nuevo</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Mensaje por defecto (fallback)</label>
|
|
<textarea name="fallback" rows="2" placeholder="No entendí. Escribe menu para ver opciones.">{$fallbackVal}</textarea>
|
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">Cuando el bot no entiende el mensaje</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Webhook de aprobación (approval_webhook)</label>
|
|
<input type="text" name="approval_webhook" value="{$approvalWebhookVal}" placeholder="https://api.ejemplo.com/webhook-aprobacion">
|
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">URL a la que se notificará cuando se requiera aprobación</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Prefijos a ignorar (uno por línea)</label>
|
|
<textarea name="ignore_prefixes" rows="3" placeholder="Ej: 555 111">{$ignoreVal}</textarea>
|
|
<div style="font-size:11px;color:#7a8291;margin-top:3px">Números que comiencen con estos prefijos no recibirán respuestas del bot</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:flex;gap:12px;margin-top:16px">
|
|
<button type="submit" class="btn-primary">💾 Guardar configuración</button>
|
|
<a href="/admin/companies" class="btn-secondary" style="text-decoration:none;display:inline-flex;align-items:center">↩ Volver a empresas</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<script>
|
|
function switchTab(tab, btn) {
|
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-btn').forEach(t => t.classList.remove('active'));
|
|
document.getElementById('tab-' + tab).classList.add('active');
|
|
btn.classList.add('active');
|
|
}
|
|
|
|
// Commands
|
|
function addCmd() {
|
|
const tbody = document.getElementById('cmdBody');
|
|
const idx = tbody.children.length;
|
|
const tr = document.createElement('tr');
|
|
tr.id = 'cmd-row-' + idx;
|
|
tr.innerHTML = '<td><input type="text" name="cmd_keyword[]" style="width:100%;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:13px" placeholder="ej: menu"></td>' +
|
|
'<td><input type="text" name="cmd_action[]" style="width:100%;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:13px" placeholder="ej: show_main"></td>' +
|
|
'<td><button type="button" class="btn-danger-sm" onclick="this.closest(\'tr\').remove()">✕</button></td>';
|
|
tbody.appendChild(tr);
|
|
}
|
|
|
|
// Menus
|
|
function addMenu() {
|
|
const container = document.getElementById('menuContainer');
|
|
const idx = container.children.length;
|
|
const div = document.createElement('div');
|
|
div.className = 'item-card';
|
|
div.id = 'menu-card-' + idx;
|
|
div.innerHTML = '<div class="item-head"><span>Menú: <input type="text" name="menu_key[]" style="border:none;background:transparent;font-weight:700;color:#0b3d91;width:200px;font-size:13px" placeholder="ID del menú (ej: show_main)"></span><button type="button" class="del" onclick="this.closest(\'.item-card\').remove()">✕ Eliminar menú</button></div>' +
|
|
'<div class="form-row">' +
|
|
'<div class="form-group"><label>Tipo</label><select name="menu_type[]"><option value="list">Lista</option><option value="button">Botones</option></select></div>' +
|
|
'<div class="form-group"><label>Header</label><input type="text" name="menu_header[]" placeholder="Título del menú"></div>' +
|
|
'<div class="form-group"><label>Body</label><input type="text" name="menu_body[]" placeholder="Cuerpo del mensaje"></div>' +
|
|
'<div class="form-group"><label>Footer</label><input type="text" name="menu_footer[]" placeholder="Footer (opcional)"></div>' +
|
|
'<div class="form-group"><label>Botón</label><input type="text" name="menu_button[]" placeholder="Texto del botón"></div>' +
|
|
'</div>' +
|
|
'<div style="margin-top:8px"><div style="font-size:12px;font-weight:600;color:#3d4552;margin-bottom:6px">Secciones:</div><div class="sections-container" id="menu-sections-' + idx + '"></div><button type="button" class="btn-secondary" style="font-size:11px;padding:4px 10px;margin-top:6px" onclick="addSection(' + idx + ')">+ Sección</button></div>';
|
|
container.appendChild(div);
|
|
}
|
|
|
|
function addSection(mi) {
|
|
const container = document.getElementById('menu-sections-' + mi);
|
|
const si = container.children.length;
|
|
const div = document.createElement('div');
|
|
div.className = 'section-card';
|
|
div.style.cssText = 'background:#fff;border:1px solid #eef1f5;border-radius:8px;padding:12px;margin-bottom:8px';
|
|
div.innerHTML = '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px"><input type="text" name="menu_sections[' + mi + '][' + si + '][title]" placeholder="Título de sección" style="flex:1;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:13px"><button type="button" class="btn-danger-sm" onclick="this.closest(\'.section-card\').remove()">✕</button></div>' +
|
|
'<div style="font-size:11px;font-weight:600;color:#7a8291;margin-bottom:4px">Filas:</div>' +
|
|
'<div class="rows-container" id="menu-rows-' + mi + '-' + si + '"></div>' +
|
|
'<button type="button" class="btn-secondary" style="font-size:11px;padding:4px 10px;margin-top:4px" onclick="addRow(this,' + mi + ',' + si + ')">+ Fila</button>';
|
|
container.appendChild(div);
|
|
}
|
|
|
|
function addRow(btn, mi, si) {
|
|
const container = btn.closest('.section-card').querySelector('.rows-container');
|
|
const ri = container.children.length;
|
|
const div = document.createElement('div');
|
|
div.className = 'inline-group';
|
|
div.style.cssText = 'display:flex;gap:8px;align-items:center;margin-bottom:4px';
|
|
div.innerHTML = '<input type="text" name="menu_sections[' + mi + '][' + si + '][rows][' + ri + '][id]" placeholder="ID" style="flex:1;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px">' +
|
|
'<input type="text" name="menu_sections[' + mi + '][' + si + '][rows][' + ri + '][title]" placeholder="Título" style="flex:1;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px">' +
|
|
'<input type="text" name="menu_sections[' + mi + '][' + si + '][rows][' + ri + '][description]" placeholder="Descripción" style="flex:1.5;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px">' +
|
|
'<button type="button" class="btn-danger-sm" onclick="this.closest(\'.inline-group\').remove()">✕</button>';
|
|
container.appendChild(div);
|
|
}
|
|
|
|
// Flows
|
|
function addFlow() {
|
|
const container = document.getElementById('flowContainer');
|
|
const idx = container.children.length;
|
|
const div = document.createElement('div');
|
|
div.className = 'item-card';
|
|
div.id = 'flow-card-' + idx;
|
|
div.innerHTML = '<div class="item-head"><span>ID: <input type="text" name="flow_key[]" style="border:none;background:transparent;font-weight:700;color:#0b3d91;width:200px;font-size:13px" placeholder="ID del flujo (ej: servicios)"></span><button type="button" class="del" onclick="this.closest(\'.item-card\').remove()">✕</button></div>' +
|
|
'<div class="form-row">' +
|
|
'<div class="form-group" style="min-width:120px"><label>Tipo</label><select name="flow_type[]" onchange="flowTypeChange(this)"><option value="text">Texto (respuesta fija)</option><option value="function">Función (acción especial)</option><option value="menu">Menú (submenú)</option></select></div>' +
|
|
'<div class="form-group flow-text"><label>Mensaje de respuesta</label><textarea name="flow_message[]" rows="2" placeholder="Texto que se enviará al usuario"></textarea></div>' +
|
|
'<div class="form-group flow-function" style="display:none"><label>Función</label><select name="flow_function[]"><option value="forward_to_ai">Escalar a IA</option><option value="forward_to_agent">Escalar a agente humano</option><option value="webhook">Llamar webhook externo</option></select></div>' +
|
|
'<div class="form-group flow-menu" style="display:none"><label>Menú a mostrar</label><input type="text" name="flow_menu[]" placeholder="ID del menú (ej: show_main)"></div>' +
|
|
'</div>';
|
|
container.appendChild(div);
|
|
}
|
|
|
|
function flowTypeChange(sel) {
|
|
const card = sel.closest('.item-card');
|
|
card.querySelector('.flow-text').style.display = sel.value === 'text' ? '' : 'none';
|
|
card.querySelector('.flow-function').style.display = sel.value === 'function' ? '' : 'none';
|
|
card.querySelector('.flow-menu').style.display = sel.value === 'menu' ? '' : 'none';
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
exit;
|
|
}
|
|
}
|