Files
bot_palmas/admin/DashboardController.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 ea7560f9bf refactor: elimina phone_number_id y display_phone del formulario de empresa
- El número WA Business es global (configuración general), no por empresa
- OutboundWorker usa fallback a WHATSAPP_DEFAULT_PHONE_NUMBER_ID si la empresa no tiene
- CompanyRepository::save() ya no requiere phone_number_id
- phone_number_id permite DEFAULT '' en schema

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 22:19:05 -05:00

3593 lines
184 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 Layout::open('Live Feed', 'live', $user['name'] ?? 'Admin');
echo <<<HTML
<style>
.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}
.feed-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}
.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}
</style>
<div class="toolbar">
<span class="dot" id="dot"></span>
<span id="status">Conectando...</span>
<button class="btn-pause" id="btnPause" onclick="togglePause()">&#9646;&#9646; Pausar</button>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#666;cursor:pointer;margin-left:8px">
<input type="checkbox" id="chkSound" style="accent-color:#0b3d91"> Sonido
</label>
<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>&#128230; Payload JSON</span><button class="mx" onclick="closeModal()">&#x2715;</button></div>
<div class="mb"><pre id="mpre">Cargando...</pre></div>
</div>
</div>
<script>
const ICONS = {text:'&#128172;',image:'&#128247;',audio:'&#127925;',video:'&#127916;',document:'&#128196;',sticker:'&#127774;',reaction:'&#128077;',location:'&#128205;',interactive:'&#128280;',button:'&#128306;',status:'&#128202;'};
const BADGE = t => 'b-'+(ICONS[t]?t:'other');
let paused = false;
let count = 0;
let es = null;
function togglePause() {
paused = !paused;
document.getElementById('btnPause').innerHTML = paused ? '&#9654; Reanudar' : '&#9646;&#9646; Pausar';
document.getElementById('dot').className = 'dot' + (paused ? ' paused' : '');
if (paused) { document.getElementById('status').textContent = 'En pausa'; es && es.close(); }
else { connect(); }
}
function addCards(rows) {
if (!rows.length) return;
const feed = document.getElementById('feed');
const empty = feed.querySelector('.empty');
if (empty) empty.remove();
rows.forEach(row => {
count++;
const icon = ICONS[row.message_type] || '&#128225;';
const card = document.createElement('div');
card.className = 'feed-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(row.message_type)}">\${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' : '');
if (document.getElementById('chkSound').checked) {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator(); const g = ctx.createGain();
osc.connect(g); g.connect(ctx.destination);
osc.frequency.value = 880; g.gain.setValueAtTime(0.08, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.12);
osc.start(); osc.stop(ctx.currentTime + 0.12);
}
}
function connect() {
es = new EventSource('/admin/webhook/stream/sse');
es.onopen = () => {
document.getElementById('dot').className = 'dot';
document.getElementById('status').textContent = 'Conectado — tiempo real (SSE)';
};
es.onmessage = e => { if (!paused) try { addCards(JSON.parse(e.data)); } catch(_) {} };
es.onerror = () => {
document.getElementById('dot').className = 'dot paused';
document.getElementById('status').textContent = 'Reconectando...';
};
}
function esc(s) {
return String(s||'').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]);
}
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);
document.getElementById('mpre').textContent = JSON.stringify(await r.json(), null, 2);
} catch(e) { document.getElementById('mpre').textContent = 'Error.'; }
}
function closeModal() { document.getElementById('modal').style.display = 'none'; }
document.addEventListener('keydown', e => { if(e.key==='Escape') closeModal(); });
window.addEventListener('pagehide', () => { es && es.close(); });
connect();
</script>
</body>
</html>
HTML;
exit;
}
// ─── GET /admin/webhook/stream/sse — Server-Sent Events ─────────────────
public static function streamSse(): void
{
SessionAuth::require();
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no'); // disable nginx buffering
header('Connection: keep-alive');
set_time_limit(35);
ignore_user_abort(false);
$lastId = max(0, (int)($_GET['after'] ?? 0));
if ($lastId === 0) {
try {
$row = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1')->fetch();
$lastId = $row ? (int)$row['id'] : 0;
} catch (\PDOException $e) {}
}
// Tell the client to reconnect after 3s if connection drops
echo "retry: 3000\n\n";
ob_flush(); flush();
$deadline = time() + 28; // release worker after 28s, client auto-reconnects
while (!connection_aborted() && time() < $deadline) {
try {
$stmt = db()->prepare(
'SELECT id, event_field, from_number, contact_name,
message_type, message_preview, received_at
FROM webhook_logs WHERE id > ? ORDER BY id ASC LIMIT 20'
);
$stmt->execute([$lastId]);
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
if ($rows) {
$lastId = (int)end($rows)['id'];
echo 'data: ' . json_encode($rows, JSON_UNESCAPED_UNICODE) . "\n\n";
} else {
echo ": heartbeat\n\n";
}
} catch (\PDOException $e) {
echo ": db-error\n\n";
}
ob_flush();
flush();
// Sleep in 200ms chunks so connection_aborted() is checked frequently
for ($i = 0; $i < 10 && !connection_aborted(); $i++) {
usleep(200000);
}
}
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 '&#128172; Texto';
case 'image': return '&#128247; Imagen';
case 'audio': return '&#127925; Audio';
case 'video': return '&#127916; Video';
case 'document': return '&#128196; Documento';
case 'location': return '&#128205; Ubicaci&oacute;n';
case 'interactive': return '&#128280; Interactivo';
case 'button': return '&#128306; Bot&oacute;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>' : '';
// 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 Layout::open('Admin', 'dashboard', $v['user']['name'] ?? 'Admin', $sPending);
echo <<<HTML
<style>
/* Stats */
.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;text-decoration:none;display:block;transition:transform .15s,box-shadow .15s;cursor:pointer}
.sc:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.05)}
.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}
.sc.r{border-color:#e74c3c}.sc.r::after{background:linear-gradient(135deg,#e74c3c,transparent 60%)}.sc.r .v{color:#e74c3c}
/* 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}
.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}
.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}
.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}
/* Drawer cards */
.dp-card{background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:14px;transition:opacity .3s}
.dp-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.dp-phone{font-family:monospace;font-size:12px;font-weight:600;color:#3d4552}
.dp-name{font-size:12px;color:#5f6b7a;flex:1}
.dp-time{font-size:11px;color:#a0a8b8}
.dp-in{font-size:12px;color:#5f6b7a;background:#fff;border-radius:6px;padding:6px 10px;margin-bottom:4px;border:1px solid #eef1f5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dp-resp{font-size:12px;color:#1a7a3a;background:#e8f8f0;border-radius:6px;padding:6px 10px;margin-bottom:10px;border:1px solid #a3e4bc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dp-actions{display:flex;gap:8px}
.dp-approve{flex:1;background:#166534;color:#fff;border:none;border-radius:8px;padding:8px;font-size:12px;font-weight:700;cursor:pointer;transition:background .15s}
.dp-approve:hover{background:#1a7a3a}
.dp-reject{flex:1;background:#991b1b;color:#fff;border:none;border-radius:8px;padding:8px;font-size:12px;font-weight:700;cursor:pointer;transition:background .15s}
.dp-reject:hover{background:#b91c1c}
.dp-approve:disabled,.dp-reject:disabled{opacity:.5;cursor:not-allowed}
@media(max-width:768px){
.stats{grid-template-columns:repeat(2,1fr);padding:14px 16px 0}
.fbar,.stats,.tw{padding-left:12px;padding-right:12px;margin-left:0;margin-right:0}
.tw{margin:8px 12px 16px}
th:nth-child(4),td:nth-child(4){display:none}
#drawer{width:100vw}
}
</style>
<div class="stats">
<a class="sc" href="/admin/dashboard"> <div class="v">{$sTotal}</div> <div class="l">📡 Total hoy</div></a>
<a class="sc g" href="?type=messages&date={$dateVal}"><div class="v">{$sMsgs}</div> <div class="l">💬 Mensajes texto</div></a>
<a class="sc o" href="?type=messages&date={$dateVal}"><div class="v">{$sMedia}</div> <div class="l">📎 Multimedia</div></a>
<a class="sc s" href="?type=statuses&date={$dateVal}"><div class="v">{$sStatuses}</div> <div class="l">📊 Estados</div></a>
<a class="sc r" href="#" onclick="openDrawer();return false"> <div class="v">{$sPending}</div> <div class="l">⏳ Pendientes <span style="font-size:10px;opacity:.7">(clic para ver)</span></div></a>
<a class="sc" href="/admin/companies" style="border-left-color:#0b3d91"><div class="v" style="color:#0b3d91">{$companyCount}</div> <div class="l">🏢 Empresas</div></a>
</div>
<!-- ── Drawer de aprobación rápida ─────────────────────────────────────── -->
<div id="drawerOverlay" onclick="closeDrawer()" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:300;backdrop-filter:blur(2px)"></div>
<div id="drawer" style="display:none;position:fixed;top:0;right:0;width:440px;max-width:100vw;height:100vh;background:#fff;z-index:301;box-shadow:-8px 0 32px rgba(0,0,0,.12);flex-direction:column;overflow:hidden">
<div style="background:linear-gradient(135deg,#0a2e6e,#0b3d91);color:#fff;padding:16px 20px;display:flex;align-items:center;justify-content:space-between">
<span style="font-weight:700;font-size:15px">⏳ Pendientes de aprobación</span>
<button onclick="closeDrawer()" style="background:none;border:none;color:rgba(255,255,255,.8);font-size:22px;cursor:pointer;line-height:1;padding:0 4px">&#x2715;</button>
</div>
<div id="drawerBody" style="flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px">
<div style="text-align:center;color:#a0a8b8;padding:40px 0">Cargando...</div>
</div>
<div style="padding:12px 20px;border-top:1px solid #eef1f5;display:flex;gap:10px;align-items:center">
<a href="/admin/pending" style="font-size:13px;color:#0b3d91;text-decoration:none;font-weight:600">Ver todos →</a>
<button onclick="refreshDrawer()" style="margin-left:auto;background:#f0f4ff;color:#0b3d91;border:1px solid #d6e4ff;border-radius:8px;padding:7px 14px;font-size:12px;font-weight:600;cursor:pointer">🔄 Actualizar</button>
</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()">&#x2715;</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(); closeDrawer(); } });
// ── Drawer de pendientes ─────────────────────────────────────────────────────
function openDrawer() {
document.getElementById('drawer').style.display = 'flex';
document.getElementById('drawerOverlay').style.display = 'block';
refreshDrawer();
}
function closeDrawer() {
document.getElementById('drawer').style.display = 'none';
document.getElementById('drawerOverlay').style.display = 'none';
}
async function refreshDrawer() {
const body = document.getElementById('drawerBody');
body.innerHTML = '<div style="text-align:center;color:#a0a8b8;padding:40px 0">Cargando...</div>';
try {
const r = await fetch('/admin/pending-list');
const { items } = await r.json();
if (!items || !items.length) {
body.innerHTML = '<div style="text-align:center;color:#a0a8b8;padding:40px 0;font-size:15px">✅ Sin pendientes</div>';
return;
}
body.innerHTML = items.map(function(it) {
return '<div class="dp-card" id="dp-' + it.id + '">'
+ '<div class="dp-top">'
+ '<span class="dp-phone">' + esc(it.phone) + '</span>'
+ '<span class="dp-name">' + esc(it.contact_name || '') + '</span>'
+ '<span class="dp-time">' + (it.created_at||'').substr(11,5) + '</span>'
+ '</div>'
+ '<div class="dp-in">↩ ' + esc(it.incoming_message || '') + '</div>'
+ '<div class="dp-resp">🤖 ' + esc(getPreview(it.bot_response)) + '</div>'
+ '<div class="dp-actions">'
+ '<button class="dp-approve" onclick="pendingAction(' + it.id + ',\'approve\',this)">✓ Aprobar</button>'
+ '<button class="dp-reject" onclick="pendingAction(' + it.id + ',\'reject\',this)">✕ Rechazar</button>'
+ '</div>'
+ '</div>';
}).join('');
} catch(e) {
body.innerHTML = '<div style="text-align:center;color:#991b1b;padding:20px">Error al cargar</div>';
}
}
function getPreview(resp) {
if (!resp) return '';
try { const p = JSON.parse(resp); return p.payload || p.text || JSON.stringify(p).substring(0,80); } catch(_) { return String(resp).substring(0,80); }
}
async function pendingAction(id, action, btn) {
btn.disabled = true;
const card = document.getElementById('dp-' + id);
try {
const r = await fetch('/admin/pending-' + action, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id}) });
const j = await r.json();
if (j.status === action + 'd' || j.status) {
card.style.opacity = '0';
card.style.transition = 'opacity .3s';
setTimeout(() => card.remove(), 300);
} else { btn.disabled = false; }
} catch(e) { btn.disabled = false; alert('Error de conexión'); }
}
(function () {
const titleStyle = 'color: #1e88e5; font-size: 14px; font-weight: 700;';
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 pendingList(): void
{
SessionAuth::require();
header('Content-Type: application/json');
$items = PendingApproval::findAll('pending');
echo json_encode(['items' => $items], JSON_UNESCAPED_UNICODE);
exit;
}
public static function pending(): void
{
SessionAuth::require();
$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();
$sPending = PendingApproval::countPending(null);
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo Layout::open('Pendientes de Aprobación', 'pending', $user['name'] ?? 'Admin', $sPending);
echo <<<HTML
<style>
.wrap{padding:24px;max-width:1000px;margin:0 auto}
.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.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>
<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 Layout::open('Empresas', 'companies', $user['name'] ?? 'Admin');
echo <<<HTML
<style>
.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}
.count-row{margin-top:14px;font-size:12px;color:#7a8291;text-align:center}
@media(max-width:768px){
.wrap{padding:12px;overflow-x:auto}
th:nth-child(7),td:nth-child(7){display:none}
}
</style>
<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">&#10133; 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\">&#9998; Editar</a>
<a href=\"/admin/company/delete?id={$id}\" class=\"btn-danger\" onclick=\"return confirm('&iquest;Eliminar empresa {$eName}?')\">&#128465; 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 ─────────────────────────────────────────────
// ── Catálogo de endpoints (fijo para todas las empresas) ─────────────────
private static function endpointCatalog(): array
{
return [
'upload' => [
'cosecha_up' => 'Ingreso ciclos cosecha, sanidad, polinización',
'bascula_up' => 'Ingreso tiquetes de báscula',
'ausentismo_up' => 'Reporte de ausentismos',
'pluviometria_up' => 'Reporte de Pluviometría',
'subproductos_up' => 'Reporte de Salida subproductos',
],
'download' => [
'cosecha_dn' => 'Informe ciclos cosecha, sanidad, polinización',
'produccion_dn' => 'Informe de producción',
'ausentismo_dn' => 'Informe de ausentismos',
'pluviometria_dn' => 'Informe de Pluviometría',
'subproductos_dn' => 'Informe de Salida subproductos de extractora',
'numeros_dn' => 'Informe de números activos',
],
];
}
public static function companyEdit(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$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');
$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']);
$erp_active_users = (int)($company['erp_active_users'] ?? 0);
$reqAprChecked = $requires_approval ? 'checked' : '';
$isActChecked = $is_active ? 'checked' : '';
$botOptions = '';
foreach (['normal' => 'Normal', 'ai' => 'AI', 'hybrid' => 'Híbrido'] as $val => $lbl) {
$sel = $bot_type === $val ? 'selected' : '';
$botOptions .= "<option value=\"{$val}\" {$sel}>{$lbl}</option>\n";
}
$pageTitle = $isEdit ? 'Editar Empresa' : 'Nueva Empresa';
echo Layout::open($pageTitle, 'companies', $user['name'] ?? 'Admin');
// ── Tab: Números WhatsApp ─────────────────────────────────────────────
$phonesHtml = '';
$epHtml = '';
if ($isEdit) {
// Phones
$phones = db()->prepare("SELECT * FROM company_phones WHERE company_id=? ORDER BY permission_type, wa_number");
$phones->execute([$id]);
$phones = $phones->fetchAll(\PDO::FETCH_ASSOC);
$limitType1 = max(1, $erp_active_users * 10);
$limitType23 = max(1, $erp_active_users);
$countT1 = count(array_filter($phones, fn($p) => (int)$p['permission_type'] === 1));
$countT23 = count(array_filter($phones, fn($p) => (int)$p['permission_type'] !== 1));
$pTypeName = ['1' => 'Solo reporta', '2' => 'Solo recibe', '3' => 'Reporta y recibe'];
$pTypeCls = ['1' => 'badge-blue', '2' => 'badge-green', '3' => 'badge-teal'];
$rows = '';
foreach ($phones as $ph) {
$t = (string)$ph['permission_type'];
$tc = $pTypeCls[$t] ?? 'badge-gray';
$tn = $pTypeName[$t] ?? '?';
$lbl = self::h($ph['label'] ?? '');
$num = self::h($ph['wa_number']);
$act = $ph['is_active'] ? '<span class="badge badge-green">Activo</span>' : '<span class="badge badge-gray">Inactivo</span>';
$rows .= "<tr>
<td style='font-family:monospace;font-size:13px'>+{$num}</td>
<td>{$lbl}</td>
<td><span class='badge {$tc}'>{$tn}</span></td>
<td>{$act}</td>
<td><button class='btn-danger-sm' onclick='deletePhone({$ph['id']},this)'>Eliminar</button></td>
</tr>";
}
if (!$rows) $rows = '<tr><td colspan="5" class="empty">Sin números registrados</td></tr>';
$warnERP = $erp_active_users === 0
? '<div class="toast toast-error" style="margin-bottom:12px">⚠ Usuarios activos ERP = 0. Configura el campo en la pestaña General o ejecuta sincronización.</div>'
: '';
$pTypeOpts = '';
foreach ($pTypeName as $v => $n) $pTypeOpts .= "<option value='{$v}'>{$n}</option>";
$phonesHtml = <<<HTML
{$warnERP}
<div style="display:flex;align-items:center;gap:16px;margin-bottom:14px;flex-wrap:wrap">
<span class="badge badge-blue">Tipo 1 (Solo reporta): {$countT1} / {$limitType1}</span>
<span class="badge badge-teal">Tipo 2+3 (Reciben): {$countT23} / {$limitType23}</span>
</div>
<div class="card" style="margin-bottom:16px">
<table>
<thead><tr><th>Número WA</th><th>Etiqueta</th><th>Permiso</th><th>Estado</th><th></th></tr></thead>
<tbody id="phoneRows">{$rows}</tbody>
</table>
</div>
<div class="card">
<div class="card-h">Agregar número</div>
<div class="card-b">
<div class="form-row">
<div class="form-group">
<label>Número (sin +, ej: 573001234567)</label>
<input type="text" id="newPhone" placeholder="573001234567" pattern="[0-9]+" style="max-width:220px">
</div>
<div class="form-group">
<label>Etiqueta</label>
<input type="text" id="newPhoneLabel" placeholder="Ej: Supervisor finca A">
</div>
<div class="form-group">
<label>Tipo de permiso</label>
<select id="newPhoneType">{$pTypeOpts}</select>
</div>
</div>
<div id="phoneMsg"></div>
<button class="btn-primary" onclick="addPhone({$id})">Agregar número</button>
</div>
</div>
HTML;
// Endpoints
$epRows = db()->prepare("SELECT endpoint_key, url, method, last_response, last_called_at FROM company_endpoints WHERE company_id=?");
$epRows->execute([$id]);
$epMap = [];
foreach ($epRows->fetchAll(\PDO::FETCH_ASSOC) as $r) $epMap[$r['endpoint_key']] = $r;
$catalog = self::endpointCatalog();
$renderEpSection = function(string $dir, string $dirLabel, string $badge) use ($catalog, $epMap, $id): string {
$html = "<h3 style='font-size:13px;font-weight:600;color:#111827;margin:0 0 12px'>{$badge} {$dirLabel}</h3>";
foreach ($catalog[$dir] as $key => $label) {
$saved = $epMap[$key] ?? [];
$url = self::h($saved['url'] ?? '');
$meth = $saved['method'] ?? 'GET';
$lastAt = $saved['last_called_at'] ? '<span style="font-size:11px;color:#9ca3af">' . self::h($saved['last_called_at']) . '</span>' : '';
$mSel = fn($v) => $meth === $v ? 'selected' : '';
$html .= <<<EP
<div style="border:1px solid #e5e7eb;border-radius:8px;padding:14px;margin-bottom:10px">
<div style="font-size:13px;font-weight:500;color:#111827;margin-bottom:8px">{$label} {$lastAt}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
<select id="m_{$key}" style="width:90px;padding:7px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px">
<option {$mSel('GET')}>GET</option><option {$mSel('POST')}>POST</option>
</select>
<input type="text" id="u_{$key}" value="{$url}" placeholder="https://..." style="flex:1;min-width:200px;padding:7px 10px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px">
<button class="btn-secondary" onclick="saveEp({$id},'{$key}','{$dir}')">Guardar</button>
<button class="btn-sm" onclick="testEp({$id},'{$key}')">Probar</button>
</div>
<div id="ep_resp_{$key}" style="margin-top:8px;display:none"></div>
</div>
EP;
}
return $html;
};
$epHtml = $renderEpSection('upload', 'Subir información (WhatsApp → ERP)', '<span class="badge badge-blue">↑ Upload</span>')
. '<div style="margin:20px 0;border-top:1px solid #e5e7eb"></div>'
. $renderEpSection('download', 'Bajar información (ERP → WhatsApp)', '<span class="badge badge-green">↓ Download</span>');
}
$tabsHtml = $isEdit ? <<<HTML
<div style="display:flex;gap:0;border-bottom:1px solid #e5e7eb;margin-bottom:20px">
<button class="tab-btn" id="tb-general" onclick="showTab('general')">General</button>
<button class="tab-btn" id="tb-phones" onclick="showTab('phones')">Números WhatsApp</button>
<button class="tab-btn" id="tb-endpoints" onclick="showTab('endpoints')">Endpoints API</button>
</div>
HTML : '';
echo <<<HTML
<style>
.tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:10px 18px;font-size:13px;font-weight:500;color:#6b7280;cursor:pointer;transition:color .12s,border-color .12s;margin-bottom:-1px}
.tab-btn.active{color:#111827;border-bottom-color:#111827}
.tab-panel{display:none}.tab-panel.active{display:block}
</style>
<div class="wrap" style="max-width:860px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
<h1 style="font-size:18px;font-weight:700;color:#111827">{$pageTitle}</h1>
<a href="/admin/companies" class="btn-secondary" style="font-size:12px">← Empresas</a>
</div>
{$tabsHtml}
<!-- Tab General -->
<div id="panel-general" class="tab-panel">
<div class="card">
<div class="card-b">
<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>Nombre *</label>
<input type="text" name="name" value="{$name}" required placeholder="Comercializadora ABC">
</div>
<div class="form-group">
<label>Nombre mostrado</label>
<input type="text" name="display_name" value="{$display_name}" placeholder="ABC S.A.">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>API Base URL *</label>
<input type="text" name="api_base_url" value="{$api_base_url}" required placeholder="https://erp.empresa.com/api/">
<div class="hint">Base URL del ERP de esta empresa</div>
</div>
<div class="form-group">
<label>API Key</label>
<input type="text" name="api_key" value="{$api_key}" placeholder="Token Bearer">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Usuarios activos en ERP</label>
<input type="number" name="erp_active_users" value="{$erp_active_users}" min="0" placeholder="0">
<div class="hint">Define los l&iacute;mites de n&uacute;meros WA. Se actualiza con sincronizaci&oacute;n ERP.</div>
</div>
<div class="form-group">
<label>Tipo de Bot</label>
<select name="bot_type">{$botOptions}</select>
</div>
</div>
<div style="display:flex;gap:20px;margin-bottom:14px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="checkbox" name="requires_approval" value="1" {$reqAprChecked}> Requiere aprobaci&oacute;n manual
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="checkbox" name="is_active" value="1" {$isActChecked}> Activa
</label>
</div>
<div class="form-group">
<label>Configuraci&oacute;n JSON adicional</label>
<textarea name="config_json" rows="5" style="font-family:monospace;font-size:12px">{$config_json}</textarea>
</div>
<div style="display:flex;gap:10px;padding-top:16px;border-top:1px solid #e5e7eb">
<button type="submit" class="btn-primary">Guardar</button>
<a href="/admin/companies" class="btn-secondary">Cancelar</a>
</div>
</form>
</div>
</div>
</div>
HTML;
if ($isEdit) {
echo <<<HTML
<!-- Tab Números WhatsApp -->
<div id="panel-phones" class="tab-panel">
{$phonesHtml}
</div>
<!-- Tab Endpoints API -->
<div id="panel-endpoints" class="tab-panel">
{$epHtml}
</div>
<script>
function showTab(name) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById('panel-' + name).classList.add('active');
document.getElementById('tb-' + name).classList.add('active');
history.replaceState(null,'',location.pathname+'?id={$id}&tab='+name);
}
const initTab = new URLSearchParams(location.search).get('tab') || 'general';
showTab(initTab);
async function addPhone(cid) {
const num = document.getElementById('newPhone').value.trim().replace(/\D/g,'');
const lbl = document.getElementById('newPhoneLabel').value.trim();
const typ = document.getElementById('newPhoneType').value;
const msg = document.getElementById('phoneMsg');
if (!num) { msg.innerHTML='<div class="toast toast-error">Ingresa un número válido</div>'; return; }
const fd = new FormData();
fd.append('company_id', cid); fd.append('wa_number', num);
fd.append('label', lbl); fd.append('permission_type', typ);
const r = await fetch('/admin/company/phone/save', {method:'POST',body:fd});
const j = await r.json();
if (j.ok) {
msg.innerHTML='<div class="toast toast-success">'+j.message+'</div>';
setTimeout(()=>location.reload(),800);
} else {
msg.innerHTML='<div class="toast toast-error">'+j.error+'</div>';
}
}
async function deletePhone(pid, btn) {
if (!confirm('¿Eliminar este número?')) return;
btn.disabled=true;
const fd=new FormData(); fd.append('id',pid);
const r=await fetch('/admin/company/phone/delete',{method:'POST',body:fd});
const j=await r.json();
if (j.ok) btn.closest('tr').remove();
else { alert(j.error); btn.disabled=false; }
}
async function saveEp(cid, key, dir) {
const url = document.getElementById('u_'+key).value.trim();
const meth = document.getElementById('m_'+key).value;
const fd=new FormData();
fd.append('company_id',cid); fd.append('endpoint_key',key);
fd.append('direction',dir); fd.append('url',url); fd.append('method',meth);
const r=await fetch('/admin/company/endpoint/save',{method:'POST',body:fd});
const j=await r.json();
const box=document.getElementById('ep_resp_'+key);
box.style.display='';
box.innerHTML=j.ok ? '<span style="color:#166534;font-size:12px">✓ Guardado</span>' : '<span style="color:#991b1b;font-size:12px">'+j.error+'</span>';
setTimeout(()=>box.style.display='none', 2000);
}
async function testEp(cid, key) {
const url=document.getElementById('u_'+key).value.trim();
const meth=document.getElementById('m_'+key).value;
const box=document.getElementById('ep_resp_'+key);
box.style.display=''; box.innerHTML='<span style="color:#6b7280;font-size:12px">Probando...</span>';
const fd=new FormData();
fd.append('company_id',cid); fd.append('endpoint_key',key);
fd.append('url',url); fd.append('method',meth);
const r=await fetch('/admin/company/endpoint/test',{method:'POST',body:fd});
const j=await r.json();
if (j.ok) {
const preview=JSON.stringify(j.response,null,2).substring(0,800);
box.innerHTML='<pre style="font-size:11px;max-height:200px;overflow-y:auto;margin:0">'+preview+'</pre>';
} else {
box.innerHTML='<div class="toast toast-error" style="margin:0;font-size:12px">'+j.error+'</div>';
}
}
</script>
HTML;
}
echo '</div>';
echo Layout::close();
}
// ─── POST /admin/company/phone/save ──────────────────────────────────────
public static function companyPhoneSave(): void
{
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$companyId = (int)($_POST['company_id'] ?? 0);
$waNumber = preg_replace('/\D/', '', $_POST['wa_number'] ?? '');
$label = trim($_POST['label'] ?? '');
$permissionType = (int)($_POST['permission_type'] ?? 1);
if ($companyId <= 0 || strlen($waNumber) < 7) {
echo json_encode(['ok' => false, 'error' => 'Datos inválidos']);
exit;
}
if (!in_array($permissionType, [1, 2, 3])) {
echo json_encode(['ok' => false, 'error' => 'Tipo de permiso inválido']);
exit;
}
$company = CompanyRepository::findById($companyId);
if (!$company) {
echo json_encode(['ok' => false, 'error' => 'Empresa no encontrada']);
exit;
}
$erpUsers = (int)($company['erp_active_users'] ?? 0);
$limitT1 = max(1, $erpUsers * 10);
$limitT23 = max(1, $erpUsers);
// Contar números actuales
$counts = db()->prepare("SELECT permission_type, COUNT(*) as cnt FROM company_phones WHERE company_id=? AND is_active=1 GROUP BY permission_type");
$counts->execute([$companyId]);
$cntMap = [];
foreach ($counts->fetchAll(\PDO::FETCH_ASSOC) as $r) $cntMap[(int)$r['permission_type']] = (int)$r['cnt'];
$cntT1 = $cntMap[1] ?? 0;
$cntT23 = ($cntMap[2] ?? 0) + ($cntMap[3] ?? 0);
if ($permissionType === 1 && $cntT1 >= $limitT1) {
echo json_encode(['ok' => false, 'error' => "Límite alcanzado: máximo {$limitT1} números de tipo 1 (usuarios ERP × 10)"]);
exit;
}
if ($permissionType !== 1 && $cntT23 >= $limitT23) {
echo json_encode(['ok' => false, 'error' => "Límite alcanzado: máximo {$limitT23} números de tipo 2/3 (igual a usuarios ERP)"]);
exit;
}
try {
db()->prepare("INSERT INTO company_phones (company_id, wa_number, label, permission_type) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE label=VALUES(label), permission_type=VALUES(permission_type), is_active=1")
->execute([$companyId, $waNumber, $label, $permissionType]);
echo json_encode(['ok' => true, 'message' => "Número +{$waNumber} agregado"]);
} catch (\PDOException $e) {
echo json_encode(['ok' => false, 'error' => 'Error DB: ' . $e->getMessage()]);
}
exit;
}
// ─── POST /admin/company/phone/delete ────────────────────────────────────
public static function companyPhoneDelete(): void
{
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) { echo json_encode(['ok' => false, 'error' => 'ID inválido']); exit; }
db()->prepare("DELETE FROM company_phones WHERE id=?")->execute([$id]);
echo json_encode(['ok' => true]);
exit;
}
// ─── POST /admin/company/endpoint/save ───────────────────────────────────
public static function companyEndpointSave(): void
{
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$companyId = (int)($_POST['company_id'] ?? 0);
$key = preg_replace('/[^a-z0-9_]/', '', $_POST['endpoint_key'] ?? '');
$direction = in_array($_POST['direction'] ?? '', ['upload','download']) ? $_POST['direction'] : null;
$url = trim($_POST['url'] ?? '');
$method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET';
if ($companyId <= 0 || $key === '' || $direction === null) {
echo json_encode(['ok' => false, 'error' => 'Datos inválidos']);
exit;
}
try {
db()->prepare("INSERT INTO company_endpoints (company_id, endpoint_key, direction, url, method) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE url=VALUES(url), method=VALUES(method)")
->execute([$companyId, $key, $direction, $url, $method]);
echo json_encode(['ok' => true]);
} catch (\PDOException $e) {
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
exit;
}
// ─── POST /admin/company/endpoint/test ───────────────────────────────────
public static function companyEndpointTest(): void
{
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$companyId = (int)($_POST['company_id'] ?? 0);
$key = preg_replace('/[^a-z0-9_]/', '', $_POST['endpoint_key'] ?? '');
$url = trim($_POST['url'] ?? '');
$method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET';
if ($url === '') { echo json_encode(['ok' => false, 'error' => 'URL vacía']); exit; }
$company = CompanyRepository::findById($companyId);
$apiKey = $company['api_key'] ?? '';
$ch = curl_init($url);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json'],
];
if ($apiKey !== '') $opts[CURLOPT_HTTPHEADER][] = 'Authorization: Bearer ' . $apiKey;
if ($method === 'POST') { $opts[CURLOPT_POST] = true; $opts[CURLOPT_POSTFIELDS] = '{}'; }
curl_setopt_array($ch, $opts);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err) { echo json_encode(['ok' => false, 'error' => 'cURL: ' . $err]); exit; }
$decoded = json_decode($resp, true);
$preview = is_array($decoded) ? $decoded : (string)$resp;
// Guardar última respuesta
if ($key !== '') {
db()->prepare("UPDATE company_endpoints SET last_response=?, last_called_at=NOW() WHERE company_id=? AND endpoint_key=?")
->execute([is_string($resp) ? substr($resp, 0, 2000) : '', $companyId, $key]);
}
if ($httpCode >= 200 && $httpCode < 300) {
echo json_encode(['ok' => true, 'http_code' => $httpCode, 'response' => $preview]);
} else {
echo json_encode(['ok' => false, 'error' => "HTTP {$httpCode}", 'response' => $preview]);
}
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 ? '&#10060;' : '&#9989;';
$title = $hasError ? 'Error en Sincronizaci&oacute;n' : 'Sincronizaci&oacute;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 Layout::open('Sincronizar Empresas', 'companies', $user['name'] ?? 'Admin');
echo <<<HTML
<style>
.wrap{padding:24px;max-width:800px;margin:0 auto}
.btn-row{text-align:center;margin-top:24px}
</style>
<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 ? '&#9888;' : '&#9989;';
$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 Layout::open('Procesar Cola de Mensajes', 'dashboard', $user['name'] ?? 'Admin');
echo <<<HTML
<style>
.wrap{padding:24px;max-width:800px;margin:0 auto}
.btn-row{text-align:center;margin-top:24px}
</style>
<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'],
['key' => 'verify_hmac_signature', 'label' => 'Validar firma HMAC de Meta', 'type' => 'checkbox', 'placeholder' => ''],
],
'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 Layout::open('Configuración', 'settings', $user['name'] ?? 'Admin');
echo <<<HTML
<style>
.wrap{padding:24px;max-width:900px;margin:0 auto}
.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-row{display:flex;justify-content:flex-end;gap:10px;margin-top:18px}
.chk-lbl{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500;color:#333;cursor:pointer;padding:8px 0}
.chk-lbl input[type=checkbox]{width:18px;height:18px;accent-color:#0b3d91}
.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>
<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}\">";
} elseif ($f['type'] === 'checkbox') {
$checked = !empty($settings[$key]) ? ' checked' : '';
echo "<label class=\"chk-lbl\"><input type=\"checkbox\" name=\"{$key}\" value=\"1\"{$checked}> {$label}</label>";
} 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 Layout::open('Chat', 'chat', $user['name'] ?? 'Admin');
echo <<<HTML
<style>
body{overflow:hidden;height:100vh}
.chat-layout{display:flex;height:calc(100vh - 56px);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-open{display:flex;position:fixed;inset:0;z-index:200;top:0}
.conv-sidebar.mobile-hidden{display:none}
.back-btn{display:flex}
}
.back-btn{display:none;background:none;border:none;color:#0b3d91;font-size:20px;cursor:pointer;padding:4px 8px;border-radius:6px;line-height:1}
.back-btn:hover{background:#f0f4ff}
</style>
<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" id="chatMain">
<div class="chat-header" id="chatHeader">
<button class="back-btn" onclick="closeMobileChat()" title="Volver">&#8592;</button>
<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()">&#10148;</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.toggle('active', el.querySelector('.conv-phone')?.textContent === phone);
});
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;
// mobile: hide sidebar, show chat fullscreen
if (window.innerWidth <= 768) {
document.getElementById('convSidebar').classList.add('mobile-hidden');
document.getElementById('chatMain').classList.add('mobile-open');
}
loadMessages(phone);
document.getElementById('msgInput').focus();
}
function closeMobileChat() {
document.getElementById('convSidebar').classList.remove('mobile-hidden');
document.getElementById('chatMain').classList.remove('mobile-open');
}
// ─── Load messages ───────────────────────────────────────────────────────────
async function loadMessages(phone) {
const r = await fetch('/admin/chat/messages?phone=' + encodeURIComponent(phone));
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({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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();
}
// ─── Auto-resize textarea ────────────────────────────────────────────────────
const msgInput = document.getElementById('msgInput');
msgInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
loadConvs();
setInterval(loadConvs, 15000);
</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,
MAX(c.contact_name) as contact_name,
MAX(c.company_id) as 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
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 Layout::open('Configurar Bot', 'bot-config', $user['name'] ?? 'Admin');
echo <<<HTML
<style>
.wrap{padding:24px;max-width:1100px;margin:0 auto}
.tabs{display:flex;gap:2px;margin-bottom:0;flex-wrap:wrap;position:sticky;top:56px;z-index:90;background:#f9fafb;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}
.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>
<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 style="display:grid;grid-template-columns:1fr 300px;gap:24px;align-items:start">
<div>
<div class="form-group">
<label>Mensaje de bienvenida (greeting)</label>
<textarea name="greeting" id="prevGreeting" rows="2" placeholder="¡Bienvenido! Escribe 'menu' para ver opciones..." oninput="updatePreview()" style="width:100%;padding:10px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;font-family:inherit;background:#fafbfc;resize:none">{$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" id="prevFallback" rows="2" placeholder="No entendí. Escribe menu para ver opciones." oninput="updatePreview()" style="width:100%;padding:10px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;font-family:inherit;background:#fafbfc;resize:none">{$fallbackVal}</textarea>
<div style="font-size:11px;color:#7a8291;margin-top:3px">Cuando el bot no entiende el mensaje</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:&#10;555&#10;111" style="width:100%;padding:10px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;font-family:inherit;background:#fafbfc">{$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>
<!-- WhatsApp preview -->
<div style="position:sticky;top:120px">
<div style="font-size:11px;font-weight:700;color:#7a8291;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Vista previa</div>
<div style="background:#111;border-radius:36px;padding:8px;box-shadow:0 8px 32px rgba(0,0,0,.18);width:260px">
<div style="background:#0b3d91;border-radius:28px 28px 0 0;padding:10px 14px 8px;display:flex;align-items:center;gap:8px">
<div style="width:32px;height:32px;border-radius:50%;background:rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;font-size:14px;color:#fff;font-weight:700">{$cName[0]}</div>
<div><div style="font-size:12px;font-weight:600;color:#fff">{$cName}</div><div style="font-size:10px;color:rgba(255,255,255,.7)">en línea</div></div>
</div>
<div style="background:#e5ddd5 url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAB3RJTUUH5QMTDxIJCVn2bAAAAA1JREFUCNdjYGBg+A8AAQQAAiEBMUQAAAAASUVORK5CYII=");border-radius:0 0 28px 28px;padding:12px 10px;min-height:200px;display:flex;flex-direction:column;gap:8px">
<div style="background:#fff;border-radius:0 8px 8px 8px;padding:8px 10px;max-width:85%;box-shadow:0 1px 2px rgba(0,0,0,.1)">
<div id="prevGreetingDisplay" style="font-size:12px;color:#1a1d23;line-height:1.4;white-space:pre-wrap">{$greetingVal}</div>
<div style="font-size:10px;color:#a0a8b8;text-align:right;margin-top:3px">12:00</div>
</div>
<div style="background:#fff;border-radius:0 8px 8px 8px;padding:8px 10px;max-width:85%;box-shadow:0 1px 2px rgba(0,0,0,.1)">
<div id="prevFallbackDisplay" style="font-size:12px;color:#1a1d23;line-height:1.4;white-space:pre-wrap">{$fallbackVal}</div>
<div style="font-size:10px;color:#a0a8b8;text-align:right;margin-top:3px">fallback</div>
</div>
</div>
</div>
<div style="font-size:10px;color:#a0a8b8;text-align:center;margin-top:6px">Vista previa aproximada</div>
</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';
}
function updatePreview() {
const g = document.getElementById('prevGreeting')?.value || '';
const f = document.getElementById('prevFallback')?.value || '';
const gd = document.getElementById('prevGreetingDisplay');
const fd = document.getElementById('prevFallbackDisplay');
if (gd) gd.textContent = g || '(vacío)';
if (fd) fd.textContent = f || '(vacío)';
}
</script>
</body>
</html>
HTML;
exit;
}
public static function conversationFlow(): 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;
}
}
}
http_response_code(200);
header('Content-Type: text/html; charset=utf-8');
echo Layout::open('Hilo Conductor', 'conv-flow', $user['name'] ?? 'Admin');
echo <<<HTML
<style>
body{background:#f0f2f6}
.wrap{padding:16px 24px 40px;max-width:1300px;margin:0 auto}
.company-select{display:flex;gap:12px;align-items:center;margin-bottom:16px;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}
.company-select label{font-weight:600;font-size:13px}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;align-items:center}
.toolbar .btn-pri{background:#0b3d91;color:#fff;border:none;border-radius:8px;padding:9px 20px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:6px}
.toolbar .btn-pri:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)}
.toolbar .btn-sec{background:#fff;color:#0b3d91;border:1px solid #d6e4ff;border-radius:8px;padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:6px}
.toolbar .btn-sec:hover{background:#f0f4ff}
.legend{display:flex;gap:14px;flex-wrap:wrap;padding:10px 14px;background:#fff;border-radius:10px;border:1px solid #eef1f5;margin-bottom:16px;font-size:12px;align-items:center}
.legend-item{display:flex;align-items:center;gap:6px}
.legend-dot{width:12px;height:12px;border-radius:50%;display:inline-block}
.legend-dot.cmd{background:#6366f1}
.legend-dot.menu{background:#0ea5e9}
.legend-dot.row-dot{background:#f59e0b}
.legend-dot.flow{background:#8b5cf6}
.legend-dot.sub{background:#10b981}
.legend-dot.action{background:#ef4444}
/* ─── TREE ─── */
.tree-canvas{background:#fff;border-radius:12px;border:1px solid #eef1f5;padding:24px;overflow-x:auto;min-height:300px;box-shadow:0 1px 4px rgba(0,0,0,.04)}
.no-data{text-align:center;padding:60px 20px;color:#a0a8b8;font-size:15px}
.root-node{display:flex;flex-direction:column;align-items:center}
.entry-node{display:flex;flex-direction:column;align-items:center;position:relative;width:100%}
.node-group{display:flex;flex-direction:column;align-items:center;width:100%;position:relative}
.node-card{background:#fff;border-radius:10px;padding:12px 16px;min-width:220px;max-width:340px;position:relative;box-shadow:0 1px 3px rgba(0,0,0,.06),0 4px 12px rgba(0,0,0,.04);transition:all .2s;cursor:pointer}
.node-card:hover{box-shadow:0 4px 12px rgba(0,0,0,.1),0 8px 24px rgba(0,0,0,.06);transform:translateY(-2px)}
.node-card .tag{font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;margin-bottom:4px;display:flex;align-items:center;gap:4px}
.node-card .n-title{font-size:14px;font-weight:700;margin-bottom:2px;word-break:break-word}
.node-card .n-sub{font-size:11px;color:#6b7280;margin-bottom:4px;word-break:break-word}
.node-card .n-info{font-size:11px;color:#6b7280;display:flex;gap:4px;flex-wrap:wrap;margin-bottom:6px}
.node-card .n-info span{background:#f3f4f6;padding:1px 6px;border-radius:4px}
.node-card .actions{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap}
.node-card .actions button{font-size:10px;padding:2px 8px;border-radius:4px;border:none;cursor:pointer;font-weight:600;transition:all .12s}
.node-card .actions .edit-btn{background:#e8f4fd;color:#0369a1}
.node-card .actions .edit-btn:hover{background:#bae6fd}
.node-card .actions .del-btn{background:#fef2f2;color:#991b1b}
.node-card .actions .del-btn:hover{background:#fee2e2}
.node-card .actions .add-btn-sm{background:#f0fdf4;color:#166534}
.node-card .actions .add-btn-sm:hover{background:#dcfce7;}
.cmd-card{border-left:4px solid #6366f1}
.menu-card{border-left:4px solid #0ea5e9}
.row-card{border-left:4px solid #f59e0b;min-width:200px}
.flow-card{border-left:4px solid #8b5cf6}
.sub-card{border-left:4px solid #10b981}
.root-card{border-left:4px solid #374151}
.row-list{display:flex;flex-direction:column;gap:3px;margin-top:6px;width:100%}
.row-item{display:flex;align-items:center;gap:6px;background:#f9fafb;padding:5px 8px;border-radius:6px;font-size:12px;border:1px solid #f0f1f3;transition:all .12s;cursor:pointer}
.row-item:hover{background:#f0f4ff;border-color:#d6e4ff}
.row-item .r-icon{font-size:14px;flex-shrink:0}
.row-item .r-title{font-weight:600;flex:1;color:#1f2937}
.row-item .r-id{font-size:10px;color:#6b7280;font-family:monospace;background:#eef1f5;padding:1px 5px;border-radius:3px}
.row-item .r-arrow{color:#9ca3af;font-size:12px}
.row-item .r-target{font-size:11px;color:#6366f1;font-weight:500;font-family:monospace}
.connector-v{width:2px;height:20px;background:#d1d5db;flex-shrink:0}
.connector-v.thick{height:28px}
.connector-branch{display:flex;gap:20px;position:relative;padding-top:4px;flex-wrap:wrap;justify-content:center}
.connector-branch::before{content:'';position:absolute;top:0;left:50%;right:50%;height:2px;background:#d1d5db}
.connector-branch::after{content:none}
.branch-item{display:flex;flex-direction:column;align-items:center;position:relative;min-width:0}
.branch-item::before{content:'';position:absolute;top:0;left:50%;width:2px;height:10px;background:#d1d5db}
.branch-item:first-child::before{left:50%}
.branch-item:last-child::before{left:50%}
.empty-hint{padding:20px;text-align:center;color:#9ca3af;font-size:12px}
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:1000;backdrop-filter:blur(2px)}
.overlay.show{display:flex;align-items:center;justify-content:center}
.modal{background:#fff;border-radius:14px;max-width:520px;width:90%;max-height:85vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.15);padding:24px}
.modal h2{font-size:16px;font-weight:700;color:#0b3d91;margin-bottom:16px;padding-bottom:10px;border-bottom:2px solid #eef1f5}
.modal .form-group{margin-bottom:12px}
.modal .form-group label{display:block;font-size:12px;font-weight:600;color:#3d4552;margin-bottom:4px}
.modal .form-group input,.modal .form-group select,.modal .form-group textarea{width:100%;padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;font-family:inherit;background:#fafbfc;transition:all .15s;color:#1a1d23}
.modal .form-group input:focus,.modal .form-group select:focus,.modal .form-group textarea:focus{border-color:#1565c0;background:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)}
.modal .form-group textarea{min-height:50px;resize:vertical}
.modal .btn-row{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
.modal .btn-row button{padding:8px 20px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:none;transition:all .12s}
.modal .btn-row .btn-save{background:#0b3d91;color:#fff}
.modal .btn-row .btn-save:hover{background:#1565c0}
.modal .btn-row .btn-cancel{background:#f3f4f6;color:#4b5563}
.modal .btn-row .btn-cancel:hover{background:#e5e7eb}
.modal .btn-row .btn-del-modal{background:#fef2f2;color:#991b1b}
.modal .btn-row .btn-del-modal:hover{background:#fee2e2}
.badge-list{background:#e0f2fe;color:#0369a1}
.badge-button{background:#fef3c7;color:#92400e}
.badge-text{background:#f3e8ff;color:#6b21a8}
.badge-func{background:#d1fae5;color:#065f46}
.badge-menu{background:#dbeafe;color:#1e40af}
.badge-ai{background:#fce7f3;color:#9d174d}
.badge-agent{background:#fff7ed;color:#9a3412}
.badge-webhook{background:#e0e7ff;color:#3730a3}
.highlight-path .node-card{opacity:.4}
.highlight-path .highlighted .node-card{opacity:1;box-shadow:0 0 0 2px #6366f1,0 4px 12px rgba(99,102,241,.15)}
.row-item.highlighted{background:#eef2ff;border-color:#a5b4fc}
@media(max-width:768px){.wrap{padding:12px}.tree-canvas{padding:12px}.connector-branch{gap:10px}.node-card{min-width:160px;max-width:260px}}
</style>
<div class="wrap">
{$toastHtml}
<div class="company-select">
<label>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="tree-canvas"><div class="no-data">Selecciona una empresa para ver su hilo conductor.</div></div>';
echo '</div></body></html>';
exit;
}
$c = $company;
$cName = self::h($c['name'] ?? '');
// ─── Build flows lookup ─────────────────────────────────────────
$flows = $config['flows'] ?? [];
$menus = $config['menus'] ?? [];
$commands = $config['commands'] ?? [];
$greeting = $config['greeting'] ?? '';
$fallback = $config['fallback'] ?? '';
echo <<<HTML
<div class="toolbar">
<span style="font-weight:600;font-size:14px">{$cName}</span>
<span style="font-size:12px;color:#6b7280">—</span>
<a href="/admin/bot-config?id={$companyId}" class="btn-sec">⚙️ Ir a Configuración Completa</a>
<form method="POST" action="/admin/bot-config/save" id="quickSaveForm" style="display:inline">
<input type="hidden" name="company_id" value="{$companyId}">
<input type="hidden" name="config_json_override" id="configOverride" value="">
<button type="submit" class="btn-pri" style="display:none" id="saveBtn">💾 Guardar Cambios</button>
</form>
</div>
<div class="legend">
<span style="font-weight:600;color:#374151;font-size:12px">Leyenda:</span>
<span class="legend-item"><span class="legend-dot cmd"></span> Comando</span>
<span class="legend-item"><span class="legend-dot menu"></span> Menú</span>
<span class="legend-item"><span class="legend-dot row-dot"></span> Botón / Fila</span>
<span class="legend-item"><span class="legend-dot flow"></span> Flujo (acción)</span>
<span class="legend-item"><span class="legend-dot sub"></span> Sub-menú</span>
<span class="legend-item"><span class="legend-dot action"></span> Acción final</span>
</div>
<div class="tree-canvas" id="treeCanvas">
HTML;
// ─── Render the conversation tree ────────────────────────────────
self::renderConversationTree($config, $companyId);
echo '</div></div>';
// ─── Inline JS for interactivity ─────────────────────────────────
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
echo <<<HTML
<!-- Modal para editar -->
<div class="overlay" id="modalOverlay">
<div class="modal" id="modalContent"></div>
</div>
<script>
const CONFIG = {$configJson};
const COMPANY_ID = {$companyId};
function showModal(html) {
document.getElementById('modalContent').innerHTML = html;
document.getElementById('modalOverlay').classList.add('show');
}
function hideModal() {
document.getElementById('modalOverlay').classList.remove('show');
}
document.getElementById('modalOverlay').addEventListener('click', function(e) {
if (e.target === this) hideModal();
});
// Highlight a row's flow path (on hover)
function highlightRow(el) {
const target = el.dataset.flowTarget;
if (!target) return;
document.querySelectorAll('.row-item, .node-card').forEach(n => n.classList.remove('highlighted'));
el.classList.add('highlighted');
const flowNode = document.getElementById('flow-node-' + target);
if (flowNode) {
flowNode.querySelector('.node-card')?.classList.add('highlighted');
flowNode.closest('.branch-item')?.querySelectorAll('.node-card').forEach(n => n.classList.add('highlighted'));
}
}
function unhighlightRow() {
document.querySelectorAll('.row-item, .node-card').forEach(n => n.classList.remove('highlighted'));
}
// ─── Edit Command ───
function editCommand(key) {
const cmd = CONFIG.commands[key] || '';
showModal(\`
<h2>✏️ Editar Comando</h2>
<div class="form-group"><label>Palabra clave</label><input type="text" id="editCmdKey" value="\${key}"></div>
<div class="form-group"><label>Acción (menú o flujo)</label>
<select id="editCmdAction">
<option value="">Selecciona destino...</option>
\${Object.keys(CONFIG.menus).map(m => '<option value="' + m + '"' + (cmd === m ? ' selected' : '') + '>📑 Menú: ' + m + '</option>').join('')}
\${Object.keys(CONFIG.flows).map(f => '<option value="' + f + '"' + (cmd === f ? ' selected' : '') + '>🔀 Flujo: ' + f + '</option>').join('')}
</select>
</div>
<div class="btn-row">
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
<button class="btn-del-modal" onclick="deleteCommand('\${key}')">Eliminar</button>
<button class="btn-save" onclick="saveCommand('\${key}')">Guardar</button>
</div>\`);
}
function saveCommand(oldKey) {
const newKey = document.getElementById('editCmdKey').value.trim();
const action = document.getElementById('editCmdAction').value;
if (!newKey || !action) { alert('Completa todos los campos'); return; }
delete CONFIG.commands[oldKey];
if (oldKey !== newKey) {
delete CONFIG.commands[oldKey];
}
CONFIG.commands[newKey] = action;
saveConfig();
}
function deleteCommand(key) {
if (!confirm('¿Eliminar comando "' + key + '"?')) return;
delete CONFIG.commands[key];
saveConfig();
}
// ─── Edit Menu ───
function editMenu(key) {
const menu = CONFIG.menus[key];
if (!menu) return;
const sectionsHtml = (menu.sections || []).map((s, si) => \`
<div style="background:#f9fafb;border-radius:8px;padding:10px;margin-bottom:8px">
<div style="display:flex;gap:6px;margin-bottom:6px">
<input type="text" class="sec-title-\${si}" value="\${s.title}" placeholder="Título sección" style="flex:1;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px">
<button class="btn-cancel" style="padding:4px 10px" onclick="this.parentElement.parentElement.remove()">✕</button>
</div>
<div style="font-size:11px;color:#6b7280;margin-bottom:4px">Filas (botones):</div>
\${(s.rows || []).map((r, ri) => \`
<div style="display:flex;gap:4px;margin-bottom:3px">
<input type="text" class="row-id-\${si}-\${ri}" value="\${r.id}" placeholder="ID" style="flex:1;padding:4px 8px;border:1px solid #e2e5ea;border-radius:4px;font-size:11px;font-family:monospace">
<input type="text" class="row-title-\${si}-\${ri}" value="\${r.title}" placeholder="Título" style="flex:2;padding:4px 8px;border:1px solid #e2e5ea;border-radius:4px;font-size:11px">
<input type="text" class="row-desc-\${si}-\${ri}" value="\${r.description||''}" placeholder="Descripción" style="flex:2;padding:4px 8px;border:1px solid #e2e5ea;border-radius:4px;font-size:11px">
<button class="btn-cancel" style="padding:2px 6px;font-size:10px" onclick="this.parentElement.remove()">✕</button>
</div>\`).join('')}
<button class="btn-sec" style="font-size:10px;padding:3px 8px;margin-top:4px" onclick="addRowInline(this, \${si})">+ Fila</button>
</div>\`).join('')}
showModal(\`
<h2>✏️ Editar Menú: \${key}</h2>
<div class="form-group"><label>ID del menú</label><input type="text" id="editMenuKey" value="\${key}"></div>
<div class="form-group"><label>Header</label><input type="text" id="editMenuHeader" value="\${menu.header||''}"></div>
<div class="form-group"><label>Body</label><input type="text" id="editMenuBody" value="\${menu.body||''}"></div>
<div class="form-group"><label>Footer</label><input type="text" id="editMenuFooter" value="\${menu.footer||''}"></div>
<div class="form-group"><label>Texto del botón</label><input type="text" id="editMenuButton" value="\${menu.button||'Menú'}"></div>
<div id="sectionsEditContainer">\${sectionsHtml}</div>
<button class="btn-sec" style="font-size:11px;padding:4px 10px;margin-top:6px" onclick="addSectionInline()">+ Sección</button>
<div class="btn-row">
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
<button class="btn-del-modal" onclick="deleteMenu('\${key}')">Eliminar menú</button>
<button class="btn-save" onclick="saveMenu('\${key}')">Guardar</button>
</div>\`);
}
function addSectionInline() {
const cont = document.getElementById('sectionsEditContainer');
const si = cont.children.length;
const div = document.createElement('div');
div.style.cssText = 'background:#f9fafb;border-radius:8px;padding:10px;margin-bottom:8px';
div.innerHTML = \`
<div style="display:flex;gap:6px;margin-bottom:6px">
<input type="text" placeholder="Título sección" style="flex:1;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px" class="sec-title-\${si}">
<button class="btn-cancel" style="padding:4px 10px" onclick="this.parentElement.parentElement.remove()">✕</button>
</div>
<div style="font-size:11px;color:#6b7280;margin-bottom:4px">Filas (botones):</div>
<div class="rows-cont"></div>
<button class="btn-sec" style="font-size:10px;padding:3px 8px;margin-top:4px" onclick="addRowInline(this, \${si})">+ Fila</button>\`;
cont.appendChild(div);
}
function addRowInline(btn, si) {
const cont = btn.closest('[style*="background:#f9fafb"]') || btn.parentElement;
const rowsCont = cont.querySelector('.rows-cont') || cont.querySelector('div:not(:first-child):not(:has(button))');
const rc = btn.closest('[style*="background:#f9fafb"]')?.querySelector('.rows-cont');
if (!rc) {
// create rows container if missing
const c = btn.closest('[style*="background:#f9fafb"]');
const r = document.createElement('div');
r.className = 'rows-cont';
c.insertBefore(r, btn);
r.appendChild(createRowInput(si, 0));
return;
}
const ri = rc.children.length;
rc.appendChild(createRowInput(si, ri));
}
function createRowInput(si, ri) {
const div = document.createElement('div');
div.style.cssText = 'display:flex;gap:4px;margin-bottom:3px';
div.innerHTML = \`
<input type="text" placeholder="ID" style="flex:1;padding:4px 8px;border:1px solid #e2e5ea;border-radius:4px;font-size:11px;font-family:monospace" class="row-id-\${si}-\${ri}">
<input type="text" placeholder="Título" style="flex:2;padding:4px 8px;border:1px solid #e2e5ea;border-radius:4px;font-size:11px" class="row-title-\${si}-\${ri}">
<input type="text" placeholder="Descripción" style="flex:2;padding:4px 8px;border:1px solid #e2e5ea;border-radius:4px;font-size:11px" class="row-desc-\${si}-\${ri}">
<button class="btn-cancel" style="padding:2px 6px;font-size:10px" onclick="this.parentElement.remove()">✕</button>\`;
return div;
}
function saveMenu(oldKey) {
const newKey = document.getElementById('editMenuKey').value.trim();
if (!newKey) { alert('El ID del menú es requerido'); return; }
const menu = {
type: 'list',
header: document.getElementById('editMenuHeader').value,
body: document.getElementById('editMenuBody').value,
footer: document.getElementById('editMenuFooter').value,
button: document.getElementById('editMenuButton').value || 'Menú',
sections: []
};
const cont = document.getElementById('sectionsEditContainer');
const secDivs = cont.children;
for (let si = 0; si < secDivs.length; si++) {
const secDiv = secDivs[si];
const titleEl = secDiv.querySelector('.sec-title-' + si) || secDiv.querySelector('input[placeholder="Título sección"]');
const title = titleEl?.value?.trim();
if (!title) continue;
const rows = [];
const rowEls = secDiv.querySelectorAll('[class^="row-id-"]');
const rowSet = new Set();
rowEls.forEach(el => {
const match = el.className.match(/row-id-(\\d+)-(\\d+)/);
if (!match) return;
const rsi = parseInt(match[1]);
const rri = parseInt(match[2]);
if (rsi !== si || rowSet.has(rri)) return;
rowSet.add(rri);
const titleInput = secDiv.querySelector('.row-title-' + rsi + '-' + rri);
const descInput = secDiv.querySelector('.row-desc-' + rsi + '-' + rri);
const id = el.value.trim();
const t = titleInput?.value?.trim();
if (id && t) rows.push({ id, title: t, description: descInput?.value?.trim() || '' });
});
if (rows.length > 0) menu.sections.push({ title, rows });
}
delete CONFIG.menus[oldKey];
CONFIG.menus[newKey] = menu;
// Update commands that reference this menu
for (const k in CONFIG.commands) {
if (CONFIG.commands[k] === oldKey) CONFIG.commands[k] = newKey;
}
// Update flows that reference this menu
for (const k in CONFIG.flows) {
if (CONFIG.flows[k].type === 'menu' && CONFIG.flows[k].menu === oldKey) CONFIG.flows[k].menu = newKey;
}
saveConfig();
}
function deleteMenu(key) {
if (!confirm('¿Eliminar menú "' + key + '" y todas sus conexiones?')) return;
delete CONFIG.menus[key];
// Remove associated flows
for (const k in CONFIG.flows) {
if (CONFIG.flows[k].menu === key) delete CONFIG.flows[k];
}
saveConfig();
}
// ─── Edit Flow ───
function editFlow(key) {
const flow = CONFIG.flows[key];
if (!flow) return;
const menuOpts = Object.keys(CONFIG.menus).map(m => '<option value="' + m + '"' + (flow.menu === m ? ' selected' : '') + '>' + m + '</option>').join('');
showModal(\`
<h2>🔀 Editar Flujo: \${key}</h2>
<div class="form-group"><label>ID del flujo</label><input type="text" id="editFlowKey" value="\${key}"></div>
<div class="form-group"><label>Tipo</label>
<select id="editFlowType" onchange="flowTypeToggle()">
<option value="text"\${flow.type==='text'?' selected':''}>Texto (respuesta fija)</option>
<option value="function"\${flow.type==='function'?' selected':''}>Función (acción especial)</option>
<option value="menu"\${flow.type==='menu'?' selected':''}>Menú (submenú)</option>
</select>
</div>
<div class="form-group" id="flowTextGroup" style="\${flow.type==='text'?'':'display:none'}">
<label>Mensaje de respuesta</label>
<textarea id="editFlowMessage" rows="3">\${flow.message||''}</textarea>
</div>
<div class="form-group" id="flowFuncGroup" style="\${flow.type==='function'?'':'display:none'}">
<label>Función</label>
<select id="editFlowFunction">
<option value="forward_to_ai"\${flow.function==='forward_to_ai'?' selected':''}>Escalar a IA</option>
<option value="forward_to_agent"\${flow.function==='forward_to_agent'?' selected':''}>Escalar a agente humano</option>
<option value="webhook"\${flow.function==='webhook'?' selected':''}>Llamar webhook externo</option>
</select>
</div>
<div class="form-group" id="flowMenuGroup" style="\${flow.type==='menu'?'':'display:none'}">
<label>Sub-menú a mostrar</label>
<select id="editFlowMenu"><option value="">Selecciona...</option>\${menuOpts}</select>
</div>
<div class="btn-row">
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
<button class="btn-del-modal" onclick="deleteFlow('\${key}')">Eliminar</button>
<button class="btn-save" onclick="saveFlow('\${key}')">Guardar</button>
</div>\`);
}
function flowTypeToggle() {
const t = document.getElementById('editFlowType').value;
document.getElementById('flowTextGroup').style.display = t === 'text' ? '' : 'none';
document.getElementById('flowFuncGroup').style.display = t === 'function' ? '' : 'none';
document.getElementById('flowMenuGroup').style.display = t === 'menu' ? '' : 'none';
}
function saveFlow(oldKey) {
const newKey = document.getElementById('editFlowKey').value.trim();
const type = document.getElementById('editFlowType').value;
if (!newKey) { alert('El ID del flujo es requerido'); return; }
const flow = { type };
if (type === 'text') flow.message = document.getElementById('editFlowMessage').value.trim();
else if (type === 'function') flow.function = document.getElementById('editFlowFunction').value;
else if (type === 'menu') flow.menu = document.getElementById('editFlowMenu').value;
delete CONFIG.flows[oldKey];
CONFIG.flows[newKey] = flow;
// Update menu rows that reference this flow
for (const mk in CONFIG.menus) {
const menu = CONFIG.menus[mk];
for (const s of (menu.sections || [])) {
for (const r of (s.rows || [])) {
if (r.id === oldKey && oldKey !== newKey) r.id = newKey;
}
}
}
saveConfig();
}
function deleteFlow(key) {
if (!confirm('¿Eliminar flujo "' + key + '"?')) return;
delete CONFIG.flows[key];
saveConfig();
}
// ─── Add new node ───
function addNew(type) {
if (type === 'command') {
showModal(\`
<h2> Nuevo Comando</h2>
<div class="form-group"><label>Palabra clave</label><input type="text" id="newCmdKey" placeholder="ej: menu, info, contacto"></div>
<div class="form-group"><label>Acción</label>
<select id="newCmdAction">
<option value="">Selecciona destino...</option>
<optgroup label="Menús">\${Object.keys(CONFIG.menus).map(m => '<option value="' + m + '">📑 ' + m + '</option>').join('')}</optgroup>
<optgroup label="Flujos">\${Object.keys(CONFIG.flows).map(f => '<option value="' + f + '">🔀 ' + f + '</option>').join('')}</optgroup>
</select>
</div>
<div class="btn-row">
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
<button class="btn-save" onclick="saveNewCommand()">Crear</button>
</div>\`);
} else if (type === 'menu') {
showModal(\`
<h2> Nuevo Menú</h2>
<div class="form-group"><label>ID del menú</label><input type="text" id="newMenuKey" placeholder="ej: show_main, sub_servicios"></div>
<div class="form-group"><label>Header</label><input type="text" id="newMenuHeader" placeholder="Título del menú"></div>
<div class="form-group"><label>Body</label><input type="text" id="newMenuBody" placeholder="Cuerpo del mensaje"></div>
<div class="form-group"><label>Footer</label><input type="text" id="newMenuFooter" placeholder="Footer opcional"></div>
<div class="form-group"><label>Texto del botón</label><input type="text" id="newMenuButton" value="Menú" placeholder="Texto del botón"></div>
<div class="btn-row">
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
<button class="btn-save" onclick="saveNewMenu()">Crear</button>
</div>\`);
} else if (type === 'flow') {
const menuOpts = Object.keys(CONFIG.menus).map(m => '<option value="' + m + '">' + m + '</option>').join('');
showModal(\`
<h2> Nuevo Flujo</h2>
<div class="form-group"><label>ID del flujo</label><input type="text" id="newFlowKey" placeholder="ej: servicios, reportes, fluviometria"></div>
<div class="form-group"><label>Tipo</label>
<select id="newFlowType" onchange="newFlowTypeToggle()">
<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" id="newFlowTextGroup"><label>Mensaje de respuesta</label><textarea id="newFlowMessage" rows="3"></textarea></div>
<div class="form-group" id="newFlowFuncGroup" style="display:none"><label>Función</label>
<select id="newFlowFunction"><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" id="newFlowMenuGroup" style="display:none"><label>Sub-menú a mostrar</label><select id="newFlowMenu"><option value="">Selecciona...</option>\${menuOpts}</select></div>
<div class="btn-row">
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
<button class="btn-save" onclick="saveNewFlow()">Crear</button>
</div>\`);
}
}
function newFlowTypeToggle() {
const t = document.getElementById('newFlowType').value;
document.getElementById('newFlowTextGroup').style.display = t === 'text' ? '' : 'none';
document.getElementById('newFlowFuncGroup').style.display = t === 'function' ? '' : 'none';
document.getElementById('newFlowMenuGroup').style.display = t === 'menu' ? '' : 'none';
}
function saveNewCommand() {
const key = document.getElementById('newCmdKey').value.trim();
const action = document.getElementById('newCmdAction').value;
if (!key || !action) { alert('Completa todos los campos'); return; }
if (CONFIG.commands[key]) { alert('El comando "' + key + '" ya existe'); return; }
CONFIG.commands[key] = action;
hideModal();
saveConfig();
}
function saveNewMenu() {
const key = document.getElementById('newMenuKey').value.trim();
if (!key) { alert('El ID del menú es requerido'); return; }
if (CONFIG.menus[key]) { alert('El menú "' + key + '" ya existe'); return; }
CONFIG.menus[key] = {
type: 'list',
header: document.getElementById('newMenuHeader').value.trim(),
body: document.getElementById('newMenuBody').value.trim(),
footer: document.getElementById('newMenuFooter').value.trim(),
button: document.getElementById('newMenuButton').value.trim() || 'Menú',
sections: []
};
hideModal();
saveConfig();
}
function saveNewFlow() {
const key = document.getElementById('newFlowKey').value.trim();
const type = document.getElementById('newFlowType').value;
if (!key) { alert('El ID del flujo es requerido'); return; }
if (CONFIG.flows[key]) { alert('El flujo "' + key + '" ya existe'); return; }
const flow = { type };
if (type === 'text') flow.message = document.getElementById('newFlowMessage').value.trim();
else if (type === 'function') flow.function = document.getElementById('newFlowFunction').value;
else if (type === 'menu') flow.menu = document.getElementById('newFlowMenu').value;
CONFIG.flows[key] = flow;
hideModal();
saveConfig();
}
// ─── Save to server ───
function saveConfig() {
// Show saving indicator
const btn = document.getElementById('saveBtn');
btn.textContent = '💾 Guardando...';
btn.style.display = 'inline-flex';
const form = document.getElementById('quickSaveForm');
// Build full config preserving all keys
const fullConfig = Object.assign({}, CONFIG);
// We need to submit via form POST to /admin/bot-config/save
// Reconstruct POST fields
const fd = new FormData();
fd.set('company_id', COMPANY_ID);
// Commands
Object.entries(fullConfig.commands || {}).forEach(([kw, act]) => {
fd.append('cmd_keyword[]', kw);
fd.append('cmd_action[]', act);
});
// Menus
let mi = 0;
Object.entries(fullConfig.menus || {}).forEach(([mk, menu]) => {
fd.append('menu_key[]', mk);
fd.append('menu_type[]', menu.type || 'list');
fd.append('menu_header[]', menu.header || '');
fd.append('menu_body[]', menu.body || '');
fd.append('menu_footer[]', menu.footer || '');
fd.append('menu_button[]', menu.button || 'Menú');
(menu.sections || []).forEach((section, si) => {
fd.append('menu_sections[' + mi + '][' + si + '][title]', section.title);
(section.rows || []).forEach((row, ri) => {
fd.append('menu_sections[' + mi + '][' + si + '][rows][' + ri + '][id]', row.id);
fd.append('menu_sections[' + mi + '][' + si + '][rows][' + ri + '][title]', row.title);
fd.append('menu_sections[' + mi + '][' + si + '][rows][' + ri + '][description]', row.description || '');
});
});
mi++;
});
// Flows
Object.entries(fullConfig.flows || {}).forEach(([fk, flow]) => {
fd.append('flow_key[]', fk);
fd.append('flow_type[]', flow.type);
fd.append('flow_message[]', flow.message || '');
fd.append('flow_function[]', flow.function || 'forward_to_ai');
fd.append('flow_menu[]', flow.menu || '');
});
// General
fd.append('greeting', fullConfig.greeting || '');
fd.append('fallback', fullConfig.fallback || '');
fd.append('ai_prompt', fullConfig.ai_prompt || '');
fd.append('ai_model', fullConfig.ai_model || 'gpt-4o-mini');
fd.append('ai_temperature', String(fullConfig.ai_temperature || 0.7));
fd.append('ai_provider', fullConfig.ai_provider || '');
fd.append('approval_webhook', fullConfig.approval_webhook || '');
fd.append('ignore_prefixes', (fullConfig.ignore_prefixes || []).join('\\n'));
fetch('/admin/bot-config/save', {
method: 'POST',
body: fd
}).then(r => {
if (r.redirected) {
window.location.href = '/admin/conversation-flow?id=' + COMPANY_ID + '&msg=saved';
} else {
window.location.reload();
}
}).catch(e => {
alert('Error al guardar: ' + e.message);
btn.textContent = '💾 Guardar Cambios';
});
}
// Initial: show save button after any changes
let configChanged = false;
</script>
</body>
</html>
HTML;
exit;
}
private static function renderConversationTree(array $config, int $companyId): void
{
$flows = $config['flows'] ?? [];
$menus = $config['menus'] ?? [];
$commands = $config['commands'] ?? [];
$greeting = $config['greeting'] ?? '';
$fallback = $config['fallback'] ?? '';
$hasAny = !empty($commands) || !empty($menus) || !empty($flows) || $greeting !== '';
if (!$hasAny) {
echo '<div class="no-data">🚀 No hay configuración aún. <a href="/admin/bot-config?id=' . $companyId . '" style="color:#0b3d91">Crea comandos, menús y flujos</a> para ver el hilo conductor.</div>';
return;
}
// ── Build a reverse map: menuKey -> list of commands that point to it ──
$cmdToMenus = [];
foreach ($commands as $kw => $action) {
if (isset($menus[$action])) {
$cmdToMenus[$action][] = $kw;
}
}
// ── Render entry points ──
echo '<div class="root-node">';
// Greeting
if ($greeting !== '') {
echo '<div class="entry-node">';
echo '<div class="node-card root-card" style="min-width:260px;text-align:center">';
echo '<div class="tag" style="justify-content:center">👋 Entrada</div>';
echo '<div class="n-title" style="color:#374151">Mensaje de Bienvenida</div>';
echo '<div class="n-sub" style="font-style:italic;color:#6b7280">"' . self::h($greeting) . '"</div>';
echo '</div>';
echo '<div class="connector-v"></div>';
echo '</div>';
}
// ── Render commands (entry points) ──
$displayedMenus = [];
// Render commands that point to menus
echo '<div class="node-group" style="width:100%">';
echo '<div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center">';
foreach ($commands as $kw => $action) {
$targetType = isset($menus[$action]) ? 'menu' : (isset($flows[$action]) ? 'flow' : 'unknown');
$targetIcon = $targetType === 'menu' ? '📑' : ($targetType === 'flow' ? '🔀' : '❓');
echo '<div class="branch-item">';
echo '<div class="node-card cmd-card">';
echo '<div class="tag" style="color:#6366f1">⌨️ Comando</div>';
echo '<div class="n-title">' . self::h($kw) . '</div>';
echo '<div class="n-info"><span>→ ' . $targetIcon . ' ' . self::h($action) . '</span></div>';
echo '<div class="actions">';
echo '<button class="edit-btn" onclick="editCommand(\'' . self::h($kw) . '\')">✎ Editar</button>';
echo '</div>';
echo '</div>';
// Connect to target
echo '<div class="connector-v thick"></div>';
// Show target menu or flow
if ($targetType === 'menu' && isset($menus[$action])) {
self::renderMenuNode($action, $menus[$action], $flows, $menus, $companyId);
$displayedMenus[$action] = true;
} elseif ($targetType === 'flow' && isset($flows[$action])) {
self::renderFlowNode($action, $flows[$action], $menus, $companyId);
}
echo '</div>';
}
echo '</div>';
echo '</div>';
// ── Render menus that are not linked by any command ──
$orphanMenus = array_diff_key($menus, $displayedMenus);
if (!empty($orphanMenus)) {
echo '<div class="connector-v thick"></div>';
echo '<div style="font-size:11px;color:#9ca3af;font-weight:600;margin:8px 0 4px">📌 Menús sin comando de entrada</div>';
echo '<div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center">';
foreach ($orphanMenus as $mk => $menu) {
echo '<div class="branch-item">';
echo '<div class="node-card menu-card" style="border-left-color:#9ca3af">';
echo '<div class="tag" style="color:#9ca3af">📑 Menú</div>';
echo '<div class="n-title">' . self::h($mk) . '</div>';
echo '<div class="n-info"><span>' . ($menu['body'] ? self::h($menu['body']) : 'Sin cuerpo') . '</span></div>';
echo '<div class="actions">';
echo '<button class="edit-btn" onclick="editMenu(\'' . self::h($mk) . '\')">✎ Editar</button>';
echo '</div>';
echo '</div>';
echo '<div class="connector-v thick"></div>';
self::renderMenuNode($mk, $menu, $flows, $menus, $companyId);
echo '</div>';
}
echo '</div>';
}
// ── Render orphan flows (not linked by any menu row) ──
$linkedFlows = [];
foreach ($menus as $mk => $menu) {
foreach ($menu['sections'] ?? [] as $section) {
foreach ($section['rows'] ?? [] as $row) {
if (isset($flows[$row['id']])) {
$linkedFlows[$row['id']] = true;
}
}
}
}
foreach ($commands as $kw => $action) {
if (isset($flows[$action])) $linkedFlows[$action] = true;
}
$orphanFlows = array_diff_key($flows, $linkedFlows);
if (!empty($orphanFlows)) {
echo '<div class="connector-v thick"></div>';
echo '<div style="font-size:11px;color:#9ca3af;font-weight:600;margin:8px 0 4px">📌 Flujos sin conectar</div>';
echo '<div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center">';
foreach ($orphanFlows as $fk => $flow) {
echo '<div class="branch-item">';
echo '<div class="node-card flow-card" style="border-left-color:#9ca3af">';
echo '<div class="tag" style="color:#9ca3af">🔀 Flujo</div>';
echo '<div class="n-title">' . self::h($fk) . '</div>';
echo '<div class="n-sub">';
if ($flow['type'] === 'text') echo '📝 ' . self::h(mb_substr($flow['message'] ?? '', 0, 60));
elseif ($flow['type'] === 'function') echo '⚡ ' . self::h($flow['function'] ?? '');
elseif ($flow['type'] === 'menu') echo '📑 → ' . self::h($flow['menu'] ?? '');
echo '</div>';
echo '<div class="actions">';
echo '<button class="edit-btn" onclick="editFlow(\'' . self::h($fk) . '\')">✎ Editar</button>';
echo '</div>';
echo '</div>';
echo '</div>';
}
echo '</div>';
}
// ── Add buttons at bottom ──
echo '<div class="connector-v thick"></div>';
echo '<div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-top:8px">';
echo '<button class="btn-sec" style="font-size:12px;padding:6px 14px;border:1px solid #d6e4ff;border-radius:8px;cursor:pointer;background:#fff;color:#0b3d91;font-weight:600" onclick="addNew(\'command\')">+ Comando</button>';
echo '<button class="btn-sec" style="font-size:12px;padding:6px 14px;border:1px solid #d6e4ff;border-radius:8px;cursor:pointer;background:#fff;color:#0b3d91;font-weight:600" onclick="addNew(\'menu\')">+ Menú</button>';
echo '<button class="btn-sec" style="font-size:12px;padding:6px 14px;border:1px solid #d6e4ff;border-radius:8px;cursor:pointer;background:#fff;color:#0b3d91;font-weight:600" onclick="addNew(\'flow\')">+ Flujo</button>';
echo '</div>';
echo '<div style="margin-top:12px;text-align:center">';
echo '<button class="btn-pri" style="padding:10px 28px;font-size:14px;cursor:pointer" onclick="saveConfig()">💾 Guardar Todo</button>';
echo '</div>';
echo '</div>'; // root-node
}
private static function renderMenuNode(string $menuKey, array $menu, array $flows, array $allMenus, int $companyId): void
{
$typeLabel = ($menu['type'] ?? 'list') === 'list' ? 'Lista' : 'Botones';
$typeBadge = ($menu['type'] ?? 'list') === 'list' ? 'badge-list' : 'badge-button';
$hasRows = false;
foreach ($menu['sections'] ?? [] as $section) {
if (!empty($section['rows'])) { $hasRows = true; break; }
}
echo '<div style="display:flex;flex-direction:column;align-items:center;width:100%">';
echo '<div class="node-card menu-card" id="menu-node-' . self::h($menuKey) . '">';
echo '<div class="tag" style="color:#0ea5e9">📑 Menú <span class="badge ' . $typeBadge . '" style="margin-left:4px">' . $typeLabel . '</span></div>';
echo '<div class="n-title" style="color:#0ea5e9">' . self::h($menuKey) . '</div>';
if ($menu['header']) echo '<div class="n-info"><span>📌 ' . self::h($menu['header']) . '</span></div>';
if ($menu['body']) echo '<div class="n-sub">' . self::h($menu['body']) . '</div>';
echo '<div class="actions">';
echo '<button class="edit-btn" onclick="editMenu(\'' . self::h($menuKey) . '\')">✎ Editar</button>';
echo '</div>';
// Show rows/buttons
if ($hasRows) {
echo '<div class="row-list">';
foreach ($menu['sections'] ?? [] as $section) {
foreach ($section['rows'] ?? [] as $row) {
$flow = $flows[$row['id']] ?? null;
$flowLabel = '';
if ($flow) {
if ($flow['type'] === 'text') $flowLabel = '📝 Respuesta';
elseif ($flow['type'] === 'function') $flowLabel = '⚡ ' . ($flow['function'] ?? '');
elseif ($flow['type'] === 'menu') $flowLabel = '📑 → ' . ($flow['menu'] ?? '');
} else {
$flowLabel = '⚠️ Sin flujo';
}
echo '<div class="row-item" data-flow-target="' . self::h($row['id']) . '" onmouseenter="highlightRow(this)" onmouseleave="unhighlightRow()" onclick="editFlow(\'' . self::h($row['id']) . '\')">';
echo '<span class="r-icon">🔘</span>';
echo '<span class="r-title">' . self::h($row['title']) . '</span>';
if ($row['description']) echo '<span style="font-size:10px;color:#9ca3af;flex-shrink:0">' . self::h($row['description']) . '</span>';
echo '<span class="r-id">' . self::h($row['id']) . '</span>';
echo '<span class="r-arrow">→</span>';
echo '<span class="r-target">' . $flowLabel . '</span>';
echo '</div>';
}
}
echo '</div>';
} else {
echo '<div style="font-size:11px;color:#9ca3af;margin-top:6px;font-style:italic">Sin botones aún</div>';
}
echo '</div>'; // node-card
// ── Show first sub-menu connection inline ──
foreach ($menu['sections'] ?? [] as $section) {
foreach ($section['rows'] ?? [] as $row) {
$flow = $flows[$row['id']] ?? null;
if ($flow && $flow['type'] === 'menu' && isset($flow['menu'])) {
echo '<div class="connector-v"></div>';
echo '<div style="display:flex;flex-direction:column;align-items:center">';
echo '<div style="font-size:10px;color:#6366f1;font-weight:600;margin:2px 0">🔘 ' . self::h($row['title']) . ' →</div>';
self::renderFlowNode($row['id'], $flow, $allMenus, $companyId, true);
echo '</div>';
break 2;
}
}
}
echo '</div>'; // flex container
}
private static function renderFlowNode(string $flowKey, array $flow, array $allMenus, int $companyId, bool $isSub = false): void
{
$icon = '🔀';
$typeLabel = 'Flujo';
$typeColor = '#8b5cf6';
$cardClass = 'flow-card';
if ($flow['type'] === 'text') {
$icon = '📝';
$typeLabel = 'Respuesta';
$typeColor = '#8b5cf6';
} elseif ($flow['type'] === 'function') {
$fn = $flow['function'] ?? 'forward_to_ai';
$fnLabels = ['forward_to_ai' => '🤖 IA', 'forward_to_agent' => '👤 Agente', 'webhook' => '🔗 Webhook'];
$icon = $fn === 'forward_to_ai' ? '🤖' : ($fn === 'forward_to_agent' ? '👤' : '🔗');
$typeLabel = $fnLabels[$fn] ?? '⚡ Función';
$typeColor = $fn === 'forward_to_ai' ? '#ec4899' : ($fn === 'forward_to_agent' ? '#f97316' : '#6366f1');
$cardClass = 'flow-card action';
} elseif ($flow['type'] === 'menu') {
$icon = '📑';
$typeLabel = 'Sub-menú';
$typeColor = '#10b981';
$cardClass = 'sub-card';
}
$nodeId = 'flow-node-' . $flowKey;
echo '<div class="node-card ' . $cardClass . '" id="' . $nodeId . '" style="' . ($isSub ? 'min-width:200px' : '') . '">';
echo '<div class="tag" style="color:' . $typeColor . '">' . $icon . ' ' . $typeLabel . '</div>';
echo '<div class="n-title" style="color:' . $typeColor . ';font-size:13px">' . self::h($flowKey) . '</div>';
if ($flow['type'] === 'text') {
echo '<div class="n-sub">' . self::h(mb_substr($flow['message'] ?? '', 0, 80)) . '</div>';
} elseif ($flow['type'] === 'menu' && isset($flow['menu']) && isset($allMenus[$flow['menu']])) {
echo '<div class="n-sub">→ ' . self::h($flow['menu']) . '</div>';
} elseif ($flow['type'] === 'function') {
echo '<div class="n-sub">' . ($flow['function'] ?? 'forward_to_ai') . '</div>';
}
echo '<div class="actions">';
echo '<button class="edit-btn" onclick="editFlow(\'' . self::h($flowKey) . '\')">✎ Editar</button>';
echo '</div>';
echo '</div>';
// If flow is sub-menu, render the sub-menu below
if ($flow['type'] === 'menu' && isset($flow['menu']) && isset($allMenus[$flow['menu']])) {
$subKey = $flow['menu'];
$subMenu = $allMenus[$subKey];
echo '<div class="connector-v"></div>';
echo '<div class="node-card sub-card" style="background:#f0fdf4">';
echo '<div class="tag" style="color:#10b981">📑 Sub-menú: ' . self::h($subKey) . '</div>';
if ($subMenu['body']) echo '<div class="n-sub">' . self::h($subMenu['body']) . '</div>';
// Show sub-menu's rows
$hasRows = false;
foreach ($subMenu['sections'] ?? [] as $section) {
if (!empty($section['rows'])) { $hasRows = true; break; }
}
if ($hasRows) {
echo '<div class="row-list">';
foreach ($subMenu['sections'] ?? [] as $section) {
foreach ($section['rows'] ?? [] as $row) {
echo '<div class="row-item" style="cursor:default">';
echo '<span class="r-icon">🔘</span>';
echo '<span class="r-title">' . self::h($row['title']) . '</span>';
echo '<span class="r-id">' . self::h($row['id']) . '</span>';
echo '</div>';
}
}
echo '</div>';
} else {
echo '<div style="font-size:11px;color:#9ca3af;margin-top:4px;font-style:italic">Sin botones</div>';
}
echo '<div class="actions">';
echo '<button class="edit-btn" onclick="editMenu(\'' . self::h($subKey) . '\')">✎ Editar sub-menú</button>';
echo '</div>';
echo '</div>';
}
}
// ─── GET /admin/test-message ──────────────────────────────────────────────
public static function testMessage(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$companies = CompanyRepository::findAll();
$opts = '';
foreach ($companies as $c) {
$opts .= '<option value="' . self::h($c['phone_number_id']) . '" data-phone="' . self::h($c['display_phone'] ?? '') . '">'
. self::h($c['name']) . '</option>';
}
echo Layout::open('Mensaje de prueba', 'test', $user['username'] ?? 'Admin');
echo <<<HTML
<div class="wrap" style="max-width:640px">
<div style="margin-bottom:20px">
<h1 style="font-size:18px;font-weight:700;color:#111827">Mensaje de prueba</h1>
<p style="color:#6b7280;font-size:13px;margin-top:4px">Simula un mensaje entrante de WhatsApp al webhook. El bot responde igual que en producción.</p>
</div>
<div id="result" style="display:none;margin-bottom:16px"></div>
<div class="card">
<div class="card-b">
<form id="testForm">
<div class="form-group">
<label>Empresa</label>
<select name="phone_number_id" id="cmpSel" required>{$opts}</select>
</div>
<div class="form-row">
<div class="form-group">
<label>Número origen (sin +)</label>
<input type="text" name="from_number" placeholder="573001234567" required pattern="[0-9]+" value="573001234567">
</div>
<div class="form-group">
<label>Nombre</label>
<input type="text" name="from_name" placeholder="Cliente prueba" value="Cliente prueba">
</div>
</div>
<div class="form-group">
<label>Mensaje</label>
<textarea name="message" rows="3" placeholder="Hola, quiero información" required style="resize:vertical">Hola</textarea>
</div>
<button type="submit" class="btn-primary" id="sendBtn" style="width:100%">Enviar mensaje de prueba</button>
</form>
</div>
</div>
</div>
<script>
document.getElementById('testForm').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = document.getElementById('sendBtn');
const res = document.getElementById('result');
btn.disabled = true;
btn.textContent = 'Enviando…';
res.style.display = 'none';
const fd = new FormData(this);
try {
const r = await fetch('/admin/test-message/send', { method:'POST', body: fd });
const json = await r.json();
res.style.display = '';
if (json.ok) {
res.innerHTML = '<div class="toast toast-success">✓ ' + json.message + '</div>';
} else {
res.innerHTML = '<div class="toast toast-error">✗ ' + json.error + '</div>';
}
} catch(err) {
res.style.display = '';
res.innerHTML = '<div class="toast toast-error">Error de red: ' + err.message + '</div>';
}
btn.disabled = false;
btn.textContent = 'Enviar mensaje de prueba';
});
</script>
HTML;
echo Layout::close();
}
// ─── POST /admin/test-message/send ───────────────────────────────────────
public static function testMessageSend(): void
{
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$phoneNumberId = trim($_POST['phone_number_id'] ?? '');
$fromNumber = preg_replace('/\D/', '', $_POST['from_number'] ?? '');
$fromName = trim($_POST['from_name'] ?? 'Test');
$message = trim($_POST['message'] ?? '');
if ($phoneNumberId === '' || $fromNumber === '' || $message === '') {
echo json_encode(['ok' => false, 'error' => 'Faltan campos requeridos']);
exit;
}
$payload = [
'object' => 'whatsapp_business_account',
'entry' => [[
'id' => 'TEST_WABA',
'changes' => [[
'field' => 'messages',
'value' => [
'messaging_product' => 'whatsapp',
'metadata' => [
'display_phone_number' => '0000000000',
'phone_number_id' => $phoneNumberId,
],
'contacts' => [[
'profile' => ['name' => $fromName],
'wa_id' => $fromNumber,
]],
'messages' => [[
'id' => 'wamid.TEST_' . time(),
'from' => $fromNumber,
'timestamp' => (string)time(),
'type' => 'text',
'text' => ['body' => $message],
]],
],
]],
]],
];
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
$appSecret = env('WHATSAPP_APP_SECRET', '');
$sig = $appSecret !== '' ? 'sha256=' . hash_hmac('sha256', $json, $appSecret) : 'sha256=test';
// POST al webhook propio vía cURL
$webhookUrl = 'https://admin.palmas360.com/admin/v1/wp-webhook';
$ch = curl_init($webhookUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Hub-Signature-256: ' . $sig,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($curlErr) {
echo json_encode(['ok' => false, 'error' => 'cURL: ' . $curlErr]);
exit;
}
if ($httpCode === 200) {
echo json_encode(['ok' => true, 'message' => 'Mensaje enviado al webhook. Revisa Live Feed y logs.']);
} else {
$body = is_string($resp) ? substr($resp, 0, 200) : '';
echo json_encode(['ok' => false, 'error' => "Webhook respondió HTTP $httpCode. $body"]);
}
exit;
}
}