Files
bot_palmas/admin/DashboardController.php
Lizandro GuarnizoandClaude Sonnet 4.6 fe048f53a3 refactor: simplificar Por Categoría — sin toggle, greeting_flow directo
- Eliminar checkbox 'Pedir finca al inicio': innecesario porque el
  dynamic_list ya auto-selecciona cuando solo hay 1 opción (finca, empresa, etc.)
- greeting_flow controla si hay flujo de bienvenida; si está vacío → greeting_menu
- Si greeting_flow tiene valor → greeting='' (dispara el flow) y se oculta greeting_menu
- Hint text explica que con 1 sola opción se auto-selecciona sin preguntar
- save-per-type deriva greeting/greeting_menu del greeting_flow, no de un toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 23:19:50 -05:00

5898 lines
309 KiB
PHP
Raw Permalink 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-raw{background:#fef9c3;color:#854d0e}
.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;',raw:'&#128268;'};
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'; clearInterval(pollTimer); pollTimer = null; }
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);
}
}
let lastId = 0;
let pollTimer = null;
async function poll() {
if (paused) return;
try {
const r = await fetch('/admin/webhook/stream?after=' + lastId + (lastId === 0 ? '&init=1' : ''));
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
if (d.last_id !== undefined) { lastId = d.last_id; }
if (d.rows && d.rows.length) { lastId = d.rows[d.rows.length-1].id; addCards(d.rows); }
document.getElementById('dot').className = 'dot';
document.getElementById('status').textContent = 'Conectado — polling cada 3s';
} catch(e) {
document.getElementById('dot').className = 'dot paused';
document.getElementById('status').textContent = 'Error de conexion, reintentando...';
}
}
function connect() {
poll();
pollTimer = setInterval(poll, 3000);
}
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', () => { clearInterval(pollTimer); });
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) {
$row = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1')->fetch();
$lastId = $row ? (int)$row['id'] : 0;
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['rows' => [], 'last_id' => $lastId]);
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 json_encode(['rows' => [], 'last_id' => $afterId]);
exit;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['rows' => $rows, 'last_id' => $rows ? (int)end($rows)['id'] : $afterId], 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_up' => 'Ingreso ciclos sanidad',
'polinizacion_up' => 'Ingreso ciclos 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_dn' => 'Informe ciclos sanidad',
'polinizacion_dn' => 'Informe ciclos 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;
// For new companies, default phone_number_id to the shared value already in use
$defaultPhoneNumberId = '';
if (!$isEdit) {
$r = db()->query("SELECT phone_number_id FROM companies WHERE phone_number_id != '' LIMIT 1")->fetch();
$defaultPhoneNumberId = $r ? (string)$r['phone_number_id'] : env('WHATSAPP_DEFAULT_PHONE_NUMBER_ID', '');
}
$cv = fn(string $k, string $d = '') => self::h($company[$k] ?? $d);
$name = $cv('name');
$display_name = $cv('display_name');
$phone_number_id = $isEdit ? $cv('phone_number_id') : self::h($defaultPhoneNumberId);
$display_phone = $cv('display_phone');
$api_base_url = $cv('api_base_url');
$api_key = $cv('api_key');
$config_json = $cv('config_json');
$bot_type = $company['bot_type'] ?? 'normal';
$requires_approval = !empty($company['requires_approval']);
$is_active = $isEdit ? !empty($company['is_active']) : true; // default active for new companies
$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';
$errorBanner = '';
if (!empty($_GET['error'])) {
$errText = self::h(urldecode($_GET['error']));
$errorBanner = "<div style='background:#fef2f2;border:1px solid #fca5a5;color:#991b1b;padding:12px 16px;border-radius:8px;margin-bottom:16px;font-size:14px'>⚠ {$errText}</div>";
}
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;justify-content:space-between;margin-bottom:14px;flex-wrap:wrap;gap:12px">
<div style="display:flex;gap:12px;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>
<button class="btn-secondary" onclick="syncPhones({$id})" id="syncBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
Sincronizar desde ERP
</button>
</div>
<div id="syncMsg" style="margin-bottom:10px"></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 manualmente</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 — CRUD dinámico
$epStmt = db()->prepare("SELECT id,endpoint_key,name,params,direction,url,method,is_active,last_called_at,body_fields FROM company_endpoints WHERE company_id=? ORDER BY direction,endpoint_key");
$epStmt->execute([$id]);
$allEps = $epStmt->fetchAll(\PDO::FETCH_ASSOC);
$baseRaw = rtrim($company['api_base_url'] ?? '', '/');
$baseHtml = self::h($baseRaw);
$epRows = '';
foreach ($allEps as $ep) {
$epKey = self::h($ep['endpoint_key']);
$epName = self::h($ep['name'] ?? '');
$epUrl = self::h($ep['url'] ?? '');
$epMeth = $ep['method'] ?? 'GET';
// Preview full URL
$rawUrl = $ep['url'] ?? '';
if ($rawUrl === '' || str_starts_with($rawUrl, 'http')) {
$previewUrl = self::h($rawUrl);
} else {
$sep = str_starts_with($rawUrl, '?') ? '' : '/';
$previewUrl = self::h($baseRaw . $sep . ltrim($rawUrl, '/'));
}
$epDir = $ep['direction'] ?? 'download';
$epParams = self::h($ep['params'] ?? '');
$epAct = $ep['is_active'] ? 'checked' : '';
$lastAt = $ep['last_called_at'] ? '<span style="font-size:11px;color:#9ca3af">' . self::h($ep['last_called_at']) . '</span>' : '';
$mSel = fn($v) => $epMeth === $v ? 'selected' : '';
$dSel = fn($v) => $epDir === $v ? 'selected' : '';
$epId = (int)$ep['id'];
// body_fields for collect_and_post
$bfData = json_decode($ep['body_fields'] ?? '', true) ?: [];
$bfRowsHtml = '';
foreach ($bfData as $bf) {
$bk = self::h($bf['key'] ?? '');
$bl = self::h($bf['label'] ?? '');
$bp = self::h($bf['prompt'] ?? '');
$bfRowsHtml .= "<div class=\"bf-row\" style=\"display:grid;grid-template-columns:1fr 1fr 2fr auto;gap:4px;margin-bottom:3px\">
<input type=\"text\" class=\"bf-key\" value=\"{$bk}\" placeholder=\"clave\" style=\"font-family:monospace;font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px\">
<input type=\"text\" class=\"bf-lbl\" value=\"{$bl}\" placeholder=\"etiqueta\" style=\"font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px\">
<input type=\"text\" class=\"bf-prm\" value=\"{$bp}\" placeholder=\"Pregunta al usuario\" style=\"font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px\">
<button type=\"button\" onclick=\"this.closest('.bf-row').remove()\" style=\"background:#fee2e2;border:none;border-radius:3px;padding:2px 6px;cursor:pointer;font-size:11px\">✕</button>
</div>";
}
$bfSection = $epDir === 'upload' ? '' : 'display:none';
// ── URL vars config ──────────────────────────────────────────
$varConfigs = json_decode($ep['params'] ?? '[]', true) ?: [];
// Detect {var} placeholders in URL
preg_match_all('/\{([^}]+)\}/', $ep['url'] ?? '', $urlVarMatches);
$urlVars = array_unique($urlVarMatches[1] ?? []);
// Merge: URL vars not yet in config get added with default mode=ask
$configuredKeys = array_column($varConfigs, 'key');
foreach ($urlVars as $uv) {
if (!in_array($uv, $configuredKeys, true)) {
$varConfigs[] = ['key' => $uv, 'mode' => 'ask', 'prompt' => "¿Ingresa {$uv}?", 'value' => ''];
}
}
$varRowsHtml = '';
$fixedPresets = ['current_date'=>'Fecha actual','month_start'=>'Inicio mes actual','month_end'=>'Fin mes actual','last_7_start'=>'Hace 7 días','last_30_start'=>'Hace 30 días','year_start'=>'Inicio año'];
foreach ($varConfigs as $vc) {
$vk = self::h($vc['key'] ?? '');
$vm = $vc['mode'] ?? 'ask';
$vp = self::h($vc['prompt'] ?? '');
$vv = self::h($vc['value'] ?? '');
$mAsk = $vm === 'ask' ? 'selected' : '';
$mFix = $vm === 'fixed' ? 'selected' : '';
$askStyle = $vm === 'ask' ? '' : 'display:none';
$fixStyle = $vm === 'fixed' ? '' : 'display:none';
$fixOptions = '';
foreach ($fixedPresets as $pk => $pl) {
$sel = $vv === $pk ? 'selected' : '';
$fixOptions .= "<option value=\"{$pk}\" {$sel}>{$pl}</option>";
}
// Custom literal option
$isCustom = $vv !== '' && !array_key_exists($vv, $fixedPresets);
$fixOptions .= '<option value="__custom"' . ($isCustom ? ' selected' : '') . '>Valor literal...</option>';
$customVal = $isCustom ? $vv : '';
$varRowsHtml .= <<<VR
<div class="var-row" data-key="{$vk}" style="display:grid;grid-template-columns:80px 100px 1fr auto;gap:6px;align-items:center;margin-bottom:5px;font-size:12px">
<span style="font-family:monospace;background:#f1f5f9;padding:2px 5px;border-radius:3px;font-size:11px">{{{$vk}}}</span>
<select class="vr-mode" onchange="toggleVarMode(this)" style="font-size:11px;padding:3px;border:1px solid #e5e7eb;border-radius:3px">
<option value="ask" {$mAsk}>❓ Preguntar</option>
<option value="fixed" {$mFix}>📌 Fijo</option>
</select>
<span class="vr-ask-wrap" style="{$askStyle}">
<input type="text" class="vr-prompt" value="{$vp}" placeholder="Pregunta al usuario (ej: ¿Desde qué fecha? AAAA-MM-DD)" style="width:100%;font-size:11px;padding:3px 5px;border:1px solid #e5e7eb;border-radius:3px">
</span>
<span class="vr-fix-wrap" style="{$fixStyle};display:flex;gap:4px">
<select class="vr-preset" onchange="toggleCustom(this)" style="font-size:11px;padding:3px;border:1px solid #e5e7eb;border-radius:3px">{$fixOptions}</select>
<input type="text" class="vr-custom" value="{$customVal}" placeholder="AAAA-MM-DD" style="width:100px;font-size:11px;padding:3px 5px;border:1px solid #e5e7eb;border-radius:3px;display:' . ($isCustom ? 'block' : 'none') . '">
</span>
<button type="button" onclick="this.closest('.var-row').remove()" style="background:#fee2e2;border:none;border-radius:3px;padding:2px 6px;cursor:pointer;font-size:11px">✕</button>
</div>
VR;
}
$varsSection = !empty($urlVars) ? '' : 'display:none';
$epRows .= <<<ROW
<tr id="ep-row-{$epId}">
<td style="min-width:120px">
<input type="text" value="{$epKey}" id="epk_{$epId}" style="width:100%;font-family:monospace;font-size:12px;padding:4px 6px;border:1px solid #e5e7eb;border-radius:4px">
</td>
<td>
<input type="text" value="{$epName}" id="epn_{$epId}" placeholder="Nombre legible" style="width:100%;font-size:12px;padding:4px 6px;border:1px solid #e5e7eb;border-radius:4px">
</td>
<td>
<select id="epd_{$epId}" onchange="toggleBfSection({$epId},this.value)" style="font-size:12px;padding:4px;border:1px solid #e5e7eb;border-radius:4px">
<option value="download" {$dSel('download')}>↓ Download</option>
<option value="upload" {$dSel('upload')}>↑ Upload</option>
<option value="list" {$dSel('list')}>📋 List</option>
</select>
</td>
<td>
<select id="epm_{$epId}" style="font-size:12px;padding:4px;border:1px solid #e5e7eb;border-radius:4px">
<option {$mSel('GET')}>GET</option><option {$mSel('POST')}>POST</option>
</select>
</td>
<td style="min-width:220px">
<input type="text" value="{$epUrl}" id="epu_{$epId}" placeholder="?peticion=xxx o /ruta" oninput="onUrlChange({$epId})" style="width:100%;font-size:12px;padding:4px 6px;border:1px solid #e5e7eb;border-radius:4px">
<div id="ep-preview-{$epId}" style="font-size:10px;color:#6b7280;font-family:monospace;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{$previewUrl}">{$previewUrl}</div>
<div style="font-size:10px;color:#9ca3af;margin-top:1px">Usa {'{var}'} para variables. {$lastAt}</div>
</td>
<td><input type="checkbox" {$epAct} id="epa_{$epId}" title="Activo"></td>
<td style="white-space:nowrap">
<button class="btn-sm" onclick="saveEpRow({$id},{$epId})">💾</button>
<button class="btn-sm" onclick="testEpRow({$id},{$epId})">▶</button>
<button class="btn-danger-sm" onclick="deleteEpRow({$id},{$epId})">✕</button>
</td>
</tr>
<tr id="ep-vars-{$epId}" style="{$varsSection}">
<td colspan="7" style="padding:8px 12px;background:#fefce8;border-bottom:1px solid #fde68a">
<div style="font-size:11px;font-weight:600;color:#92400e;margin-bottom:6px">⚙️ Variables de URL — configura cómo se obtiene cada <code>{'{var}'}</code></div>
<div id="vr-rows-{$epId}">{$varRowsHtml}</div>
</td>
</tr>
<tr id="ep-bf-{$epId}" style="{$bfSection}">
<td colspan="7" style="padding:8px 12px;background:#f0f7ff;border-bottom:1px solid #e5e7eb">
<div style="font-size:11px;font-weight:600;color:#3b5bdb;margin-bottom:6px">📋 Campos del body (para "Pedir campos y enviar")</div>
<div id="bf-rows-{$epId}">{$bfRowsHtml}</div>
<button type="button" onclick="addBfRow({$epId})" style="font-size:11px;background:#e7efff;border:1px solid #c7d7ff;border-radius:3px;padding:3px 8px;cursor:pointer;margin-top:4px">+ Campo</button>
</td>
</tr>
<tr id="ep-resp-{$epId}" style="display:none"><td colspan="7" style="padding:8px;font-size:11px;color:#374151;background:#f9fafb;border-radius:6px"></td></tr>
ROW;
}
if (!$epRows) $epRows = '<tr><td colspan="7" class="empty">Sin endpoints. Agrega uno abajo.</td></tr>';
$epHtml = <<<EP
<div class="card-h" style="margin-bottom:12px">🔌 Endpoints API</div>
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 14px;margin-bottom:14px;font-size:12px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span>🔗 <strong>URL base:</strong></span>
<code style="background:#fff8e1;padding:2px 6px;border-radius:4px;font-size:12px">{$baseHtml}</code>
<a href="/admin/company/edit?id={$id}&tab=general" style="color:#1e40af;font-size:11px">cambiar en General →</a>
<span style="color:#92400e;font-size:11px">· Los endpoints usan rutas relativas a esta URL</span>
</div>
<p style="font-size:12px;color:#7a8291;margin-bottom:14px">
Escribe solo la ruta o sufijo: <code>?peticion=produccion</code> o <code>/mi/ruta</code>. Se combina con la URL base.<br>
Usa <code>{'{variable}'}</code> para variables que el bot pedirá al usuario. Ejemplo: <code>?desde={'{desde}'}&amp;hasta={'{hasta}'}</code>
</p>
<div class="card" style="overflow-x:auto">
<table style="width:100%;min-width:700px">
<thead>
<tr style="font-size:12px;text-align:left">
<th>Key</th><th>Nombre</th><th>Dirección</th><th>Método</th><th>Ruta / sufijo</th><th>Activo</th><th></th>
</tr>
</thead>
<tbody id="epTableBody">{$epRows}</tbody>
</table>
</div>
<div id="epMsg" style="margin:8px 0"></div>
<div class="card" style="margin-top:16px">
<div class="card-h">+ Agregar endpoint</div>
<div class="card-b">
<div class="form-row">
<div class="form-group">
<label>Key <span style="font-size:10px;color:#9ca3af">(sin espacios, ej: fincas_list)</span></label>
<input type="text" id="newEpKey" placeholder="mi_endpoint" style="font-family:monospace">
</div>
<div class="form-group">
<label>Nombre</label>
<input type="text" id="newEpName" placeholder="Lista de fincas">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Dirección</label>
<select id="newEpDir">
<option value="download">↓ Download (ERP → WA)</option>
<option value="upload">↑ Upload (WA → ERP)</option>
<option value="list">📋 Lista dinámica</option>
</select>
</div>
<div class="form-group">
<label>Método</label>
<select id="newEpMethod"><option>GET</option><option>POST</option></select>
</div>
</div>
<div class="form-group">
<label>Ruta / sufijo</label>
<input type="text" id="newEpUrl" placeholder="?peticion=xxx o /mi/ruta" oninput="updateNewEpPreview()">
<div id="newEpPreview" style="font-size:11px;color:#6b7280;font-family:monospace;margin-top:4px"></div>
</div>
<div id="newEpMsg" style="margin-bottom:8px"></div>
<button class="btn-primary" onclick="addNewEp({$id})">Agregar</button>
</div>
</div>
EP;
// Prepend clone panel
$otherCompanies = array_filter(CompanyRepository::findAll(), fn($c) => (int)$c['id'] !== $id);
$cloneOpts = '<option value="">— Selecciona empresa origen —</option>';
foreach ($otherCompanies as $oc) {
$cloneOpts .= '<option value="' . (int)$oc['id'] . '">' . self::h($oc['name'] ?? $oc['display_name'] ?? '') . '</option>';
}
$clonePanel = <<<CLONE
<div style="background:#f0f4ff;border:1px solid #c7d7ff;border-radius:10px;padding:14px 16px;margin-bottom:20px">
<div style="font-size:13px;font-weight:600;color:#1e40af;margin-bottom:10px">📋 Clonar endpoints desde otra empresa</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<select id="cloneSrc" style="flex:1;min-width:200px;padding:7px 10px;border:1px solid #c7d7ff;border-radius:6px;font-size:13px">
{$cloneOpts}
</select>
<button class="btn-secondary" onclick="showCloneOptions()">Clonar →</button>
</div>
<div id="cloneOptions" style="display:none;margin-top:10px">
<div style="font-size:12px;color:#374151;margin-bottom:8px;font-weight:500">¿Cómo quieres clonar los endpoints existentes en esta empresa?</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-primary" onclick="doClone('overwrite')" style="font-size:12px">Sobreescribir todo</button>
<button class="btn-secondary" onclick="doClone('add_only')" style="font-size:12px">Solo añadir faltantes</button>
<button onclick="cancelClone()" style="background:none;border:none;font-size:12px;color:#6b7280;cursor:pointer;padding:4px 8px">Cancelar</button>
</div>
</div>
<div id="cloneMsg" style="margin-top:8px"></div>
</div>
CLONE;
$epHtml = $clonePanel . $epHtml;
}
// For "copy from" dropdown on new company form
$copyOpts = '';
if (!$isEdit) {
foreach (CompanyRepository::findAll(true) as $co) {
$cid = (int)$co['id'];
$cn = self::h($co['name']);
$copyOpts .= "<option value=\"{$cid}\">{$cn}</option>";
}
}
$allCompaniesJson = !$isEdit
? json_encode(
array_column(CompanyRepository::findAll(true), null, 'id'),
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG
)
: '{}';
$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>
{$errorBanner}
{$tabsHtml}
<!-- Tab General -->
<div id="panel-general" class="tab-panel active">
<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 . '">';
if (!$isEdit && $copyOpts !== '') echo <<<COPY
<div class="form-group" style="background:#f0f4ff;border:1px solid #c7d7ff;border-radius:8px;padding:14px;margin-bottom:18px">
<label style="font-weight:700;color:#0b3d91">📋 Copiar configuración de empresa existente</label>
<div style="display:flex;gap:10px;align-items:center;margin-top:8px;flex-wrap:wrap">
<select id="copyFromSel" style="flex:1;min-width:200px">
<option value="">— Selecciona empresa a copiar —</option>
{$copyOpts}
</select>
<button type="button" class="btn-secondary" onclick="copyFromCompany()">Copiar campos</button>
</div>
<div style="font-size:11px;color:#6b7280;margin-top:6px">Copia API URL, API Key, tipo de bot, configuración JSON y endpoints. Podrás editar cada campo antes de guardar.</div>
</div>
COPY;
echo <<<HTML
<div class="form-row">
<div class="form-group">
<label>Nombre interno *</label>
<input type="text" name="name" id="f_name" value="{$name}" required placeholder="palmas_norte">
<div class="hint">Identificador único, sin espacios</div>
</div>
<div class="form-group">
<label>Nombre mostrado</label>
<input type="text" name="display_name" id="f_display_name" value="{$display_name}" placeholder="Palmas Norte S.A.">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>WhatsApp Phone Number ID *</label>
<input type="text" name="phone_number_id" id="f_phone_number_id" value="{$phone_number_id}" required placeholder="384710295638401" style="font-family:monospace">
<div class="hint">ID del número de WhatsApp Business desde el que se envían mensajes. Si todas las empresas comparten el mismo número, usa el mismo valor para todas.</div>
</div>
<div class="form-group">
<label>Teléfono mostrado</label>
<input type="text" name="display_phone" id="f_display_phone" value="{$display_phone}" placeholder="+57 300 123 4567">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>API Base URL *</label>
<input type="text" name="api_base_url" id="f_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" id="f_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" id="f_erp_active_users" value="{$erp_active_users}" min="0" placeholder="0">
<div class="hint">Define los l&iacute;mites de n&uacute;meros WA.</div>
</div>
<div class="form-group">
<label>Tipo de Bot</label>
<select name="bot_type" id="f_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" id="f_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" id="f_is_active" value="1" {$isActChecked}> Activa
</label>
</div>
<div class="form-group">
<label>Configuraci&oacute;n JSON (bot: men&uacute;s, flujos, etc.)</label>
<textarea name="config_json" id="f_config_json" rows="6" 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 <<<SCRIPT
<script>
const ALL_COMPANIES = {$allCompaniesJson};
function copyFromCompany() {
const sel = document.getElementById('copyFromSel');
const cid = parseInt(sel.value);
if (!cid) return;
const c = ALL_COMPANIES[cid];
if (!c) return;
const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val || ''; };
set('f_display_name', c.display_name || '');
set('f_api_base_url', c.api_base_url || '');
set('f_api_key', c.api_key || '');
set('f_erp_active_users', c.erp_active_users || 0);
set('f_config_json', c.config_json || '');
const bt = document.getElementById('f_bot_type');
if (bt) bt.value = c.bot_type || 'normal';
const ra = document.getElementById('f_requires_approval');
if (ra) ra.checked = !!c.requires_approval;
const ia = document.getElementById('f_is_active');
if (ia) ia.checked = !!c.is_active;
document.getElementById('f_name').focus();
sel.value = '';
}
</script>
SCRIPT;
}
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 syncPhones(cid) {
const btn = document.getElementById('syncBtn');
const msg = document.getElementById('syncMsg');
btn.disabled = true;
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="animation:spin 1s linear infinite"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg> Sincronizando...';
msg.innerHTML = '';
const fd = new FormData(); fd.append('company_id', cid);
try {
const r = await fetch('/admin/company/phones/sync', {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(), 1200);
} else {
msg.innerHTML = '<div class="toast toast-error">' + j.error + (j.detail ? '<br><code style="font-size:11px">' + j.detail + '</code>' : '') + '</div>';
}
} catch(e) {
msg.innerHTML = '<div class="toast toast-error">Error de red: ' + e.message + '</div>';
}
btn.disabled = false;
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg> Sincronizar desde ERP';
}
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; }
}
function toggleBfSection(epId, dir) {
const row = document.getElementById('ep-bf-'+epId);
if (row) row.style.display = (dir === 'upload') ? '' : 'none';
}
function addBfRow(epId) {
const c = document.getElementById('bf-rows-'+epId);
if (!c) return;
const d = document.createElement('div');
d.className = 'bf-row';
d.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 2fr auto;gap:4px;margin-bottom:3px';
d.innerHTML =
'<input type="text" class="bf-key" placeholder="clave" style="font-family:monospace;font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px">' +
'<input type="text" class="bf-lbl" placeholder="etiqueta" style="font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px">' +
'<input type="text" class="bf-prm" placeholder="Pregunta al usuario" style="font-size:11px;padding:2px 5px;border:1px solid #e5e7eb;border-radius:3px">' +
'<button type="button" onclick="this.closest(\'.bf-row\').remove()" style="background:#fee2e2;border:none;border-radius:3px;padding:2px 6px;cursor:pointer;font-size:11px">✕</button>';
c.appendChild(d);
}
function collectBfFields(epId) {
const rows = document.querySelectorAll('#bf-rows-'+epId+' .bf-row');
const fields = [];
rows.forEach(r => {
const key = r.querySelector('.bf-key')?.value.trim();
if (!key) return;
fields.push({
key,
label: r.querySelector('.bf-lbl')?.value.trim() || key,
prompt: r.querySelector('.bf-prm')?.value.trim() || '',
});
});
return JSON.stringify(fields);
}
function toggleVarMode(sel) {
const row = sel.closest('.var-row');
const isAsk = sel.value === 'ask';
row.querySelector('.vr-ask-wrap').style.display = isAsk ? '' : 'none';
row.querySelector('.vr-fix-wrap').style.display = isAsk ? 'none' : '';
}
function toggleCustom(sel) {
const wrap = sel.closest('.vr-fix-wrap');
wrap.querySelector('.vr-custom').style.display = sel.value === '__custom' ? 'block' : 'none';
}
function collectVarConfigs(epId) {
const rows = document.querySelectorAll('#vr-rows-'+epId+' .var-row');
const configs = [];
rows.forEach(r => {
const key = r.dataset.key;
const mode = r.querySelector('.vr-mode').value;
if (mode === 'ask') {
configs.push({ key, mode, prompt: r.querySelector('.vr-prompt')?.value.trim() || '' });
} else {
const preset = r.querySelector('.vr-preset').value;
const value = preset === '__custom' ? (r.querySelector('.vr-custom')?.value.trim() || '') : preset;
configs.push({ key, mode, value });
}
});
return JSON.stringify(configs);
}
const BASE_URL = '{$baseHtml}';
function buildFullUrl(path) {
if (!path || path.startsWith('http')) return path;
const base = BASE_URL.replace(/\/$/, '');
const sep = path.startsWith('?') ? '' : '/';
return base + sep + path.replace(/^\//, '');
}
function updateEpPreview(epId) {
const path = document.getElementById('epu_'+epId)?.value || '';
const el = document.getElementById('ep-preview-'+epId);
if (!el) return;
const full = buildFullUrl(path);
el.textContent = full || '';
el.title = full || '';
}
function updateNewEpPreview() {
const path = document.getElementById('newEpUrl')?.value || '';
const el = document.getElementById('newEpPreview');
if (el) el.textContent = buildFullUrl(path) || '';
}
// When URL changes, refresh the vars section
function onUrlChange(epId) {
updateEpPreview(epId);
const url = document.getElementById('epu_'+epId).value;
const matches = [...url.matchAll(/\{([^}]+)\}/g)].map(m => m[1]);
const vrRows = document.getElementById('vr-rows-'+epId);
const vrTr = document.getElementById('ep-vars-'+epId);
if (!vrRows) return;
// Add rows for new vars not yet present
const existing = [...vrRows.querySelectorAll('.var-row')].map(r => r.dataset.key);
matches.forEach(key => {
if (existing.includes(key)) return;
const div = document.createElement('div');
div.className = 'var-row';
div.dataset.key = key;
div.style.cssText = 'display:grid;grid-template-columns:80px 100px 1fr auto;gap:6px;align-items:center;margin-bottom:5px;font-size:12px';
div.innerHTML = '<span style="font-family:monospace;background:#f1f5f9;padding:2px 5px;border-radius:3px;font-size:11px">{'+key+'}</span>' + `
<select class="vr-mode" onchange="toggleVarMode(this)" style="font-size:11px;padding:3px;border:1px solid #e5e7eb;border-radius:3px">
<option value="ask" selected>❓ Preguntar</option><option value="fixed">📌 Fijo</option>
</select>
<span class="vr-ask-wrap">
<input type="text" class="vr-prompt" value="" placeholder="Pregunta al usuario" style="width:100%;font-size:11px;padding:3px 5px;border:1px solid #e5e7eb;border-radius:3px">
</span>
<span class="vr-fix-wrap" style="display:none;gap:4px">
<select class="vr-preset" onchange="toggleCustom(this)" style="font-size:11px;padding:3px;border:1px solid #e5e7eb;border-radius:3px">
<option value="current_date">Fecha actual</option><option value="month_start">Inicio mes</option>
<option value="month_end">Fin mes</option><option value="last_7_start">Hace 7 días</option>
<option value="last_30_start">Hace 30 días</option><option value="year_start">Inicio año</option>
<option value="__custom">Valor literal...</option>
</select>
<input type="text" class="vr-custom" placeholder="AAAA-MM-DD" style="display:none;width:100px;font-size:11px;padding:3px 5px;border:1px solid #e5e7eb;border-radius:3px">
</span>
<button type="button" onclick="this.closest('.var-row').remove()" style="background:#fee2e2;border:none;border-radius:3px;padding:2px 6px;cursor:pointer;font-size:11px">✕</button>`;
vrRows.appendChild(div);
});
if (vrTr) vrTr.style.display = matches.length ? '' : 'none';
}
async function saveEpRow(cid, epId) {
const fd=new FormData();
fd.append('company_id', cid);
fd.append('ep_id', epId);
fd.append('endpoint_key', document.getElementById('epk_'+epId).value.trim());
fd.append('name', document.getElementById('epn_'+epId).value.trim());
fd.append('direction', document.getElementById('epd_'+epId).value);
fd.append('method', document.getElementById('epm_'+epId).value);
fd.append('url', document.getElementById('epu_'+epId).value.trim());
fd.append('is_active', document.getElementById('epa_'+epId).checked ? '1' : '0');
fd.append('body_fields', collectBfFields(epId));
fd.append('params', collectVarConfigs(epId));
const msg=document.getElementById('epMsg');
const r=await fetch('/admin/company/endpoint/save',{method:'POST',body:fd});
const j=await r.json();
msg.innerHTML=j.ok
? '<div class="toast toast-success">✓ Guardado</div>'
: '<div class="toast toast-error">'+j.error+'</div>';
setTimeout(()=>msg.innerHTML='', 2000);
}
async function testEpRow(cid, epId) {
const url=document.getElementById('epu_'+epId).value.trim();
const meth=document.getElementById('epm_'+epId).value;
const respRow=document.getElementById('ep-resp-'+epId);
const respCell=respRow.querySelector('td');
respRow.style.display=''; respCell.textContent='Probando...';
const fd=new FormData();
fd.append('company_id',cid); fd.append('ep_id',epId);
fd.append('endpoint_key', document.getElementById('epk_'+epId).value.trim());
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) {
respCell.innerHTML='<pre style="font-size:11px;max-height:180px;overflow-y:auto;margin:0">'+JSON.stringify(j.response,null,2).substring(0,1000)+'</pre>';
} else {
respCell.innerHTML='<span style="color:#991b1b">'+j.error+'</span>';
}
}
async function deleteEpRow(cid, epId) {
if (!confirm('¿Eliminar este endpoint?')) return;
const fd=new FormData(); fd.append('company_id',cid); fd.append('ep_id',epId);
const r=await fetch('/admin/company/endpoint/delete',{method:'POST',body:fd});
const j=await r.json();
if (j.ok) {
document.getElementById('ep-row-'+epId)?.remove();
document.getElementById('ep-resp-'+epId)?.remove();
} else alert(j.error);
}
async function addNewEp(cid) {
const key = document.getElementById('newEpKey').value.trim();
const name = document.getElementById('newEpName').value.trim();
const dir = document.getElementById('newEpDir').value;
const meth = document.getElementById('newEpMethod').value;
const url = document.getElementById('newEpUrl').value.trim();
const msg = document.getElementById('newEpMsg');
if (!key || !url) { msg.innerHTML='<div class="toast toast-error">Key y URL son requeridos</div>'; return; }
const fd=new FormData();
fd.append('company_id',cid); fd.append('endpoint_key',key); fd.append('name',name);
fd.append('direction',dir); fd.append('method',meth); fd.append('url',url); fd.append('is_active','1');
const r=await fetch('/admin/company/endpoint/save',{method:'POST',body:fd});
const j=await r.json();
if (j.ok) {
msg.innerHTML='<div class="toast toast-success">✓ Agregado</div>';
setTimeout(()=>location.reload(), 800);
} else {
msg.innerHTML='<div class="toast toast-error">'+j.error+'</div>';
}
}
function showCloneOptions() {
const src = document.getElementById('cloneSrc').value;
if (!src) { alert('Selecciona una empresa origen'); return; }
document.getElementById('cloneOptions').style.display = 'block';
}
function cancelClone() {
document.getElementById('cloneOptions').style.display = 'none';
}
async function doClone(mode) {
const src = document.getElementById('cloneSrc').value;
if (!src) return;
cancelClone();
const msg = document.getElementById('cloneMsg');
msg.innerHTML = '<span style="font-size:12px;color:#6b7280">Clonando...</span>';
const fd = new FormData();
fd.append('source_company_id', src);
fd.append('target_company_id', {$id});
fd.append('mode', mode);
try {
const r = await fetch('/admin/company/endpoints/clone', {method:'POST', body:fd});
const j = await r.json();
if (j.ok) {
msg.innerHTML = '<div class="toast toast-success" style="margin:0">' + j.message + '</div>';
setTimeout(() => location.reload(), 1200);
} else {
msg.innerHTML = '<div class="toast toast-error" style="margin:0">' + j.error + '</div>';
}
} catch(e) {
msg.innerHTML = '<div class="toast toast-error" style="margin:0">Error de red</div>';
}
}
</script>
HTML;
}
echo '</div>';
echo Layout::close();
}
// ─── POST /admin/company/phones/sync ─────────────────────────────────────
public static function companyPhonesSync(): void
{
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$companyId = (int)($_POST['company_id'] ?? 0);
if ($companyId <= 0) {
echo json_encode(['ok' => false, 'error' => 'ID de empresa inválido']);
exit;
}
$company = CompanyRepository::findById($companyId);
if (!$company) {
echo json_encode(['ok' => false, 'error' => 'Empresa no encontrada']);
exit;
}
// Buscar endpoint numeros_dn configurado para esta empresa
$epRow = db()->prepare("SELECT url, method FROM company_endpoints WHERE company_id=? AND endpoint_key='numeros_dn' LIMIT 1");
$epRow->execute([$companyId]);
$ep = $epRow->fetch(\PDO::FETCH_ASSOC);
if (!$ep || empty($ep['url'])) {
echo json_encode(['ok' => false, 'error' => 'El endpoint "Informe de números activos" no está configurado en la pestaña Endpoints API.']);
exit;
}
// Llamar al endpoint — prepend base URL si es ruta relativa
$epUrl = $ep['url'];
if (!str_starts_with($epUrl, 'http')) {
$base = rtrim($company['api_base_url'] ?? '', '/');
$sep = str_starts_with($epUrl, '?') ? '' : '/';
$epUrl = $base . $sep . ltrim($epUrl, '/');
}
$apiKey = $company['api_key'] ?? '';
$ch = curl_init($epUrl);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
];
if ($apiKey !== '') $opts[CURLOPT_HTTPHEADER][] = 'Authorization: Bearer ' . $apiKey;
if (strtoupper($ep['method'] ?? 'GET') === 'POST') {
$opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = '{}';
$opts[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json';
}
curl_setopt_array($ch, $opts);
$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' => 'No se pudo conectar al ERP.', 'detail' => $curlErr]);
exit;
}
if ($httpCode !== 200) {
echo json_encode(['ok' => false, 'error' => "El ERP respondió HTTP {$httpCode}.", 'detail' => substr((string)$resp, 0, 300)]);
exit;
}
$data = json_decode($resp, true);
if (!is_array($data)) {
echo json_encode(['ok' => false, 'error' => 'La respuesta del ERP no es JSON válido.', 'detail' => substr((string)$resp, 0, 300)]);
exit;
}
// Aceptar root array o {numeros:[...]}
$numeros = isset($data['numeros']) ? $data['numeros'] : (array_values($data) && is_array($data[0] ?? null) ? $data : []);
if (empty($numeros)) {
echo json_encode(['ok' => false, 'error' => 'El JSON no contiene números. Espera un array en "numeros" o un array raíz.', 'detail' => substr($resp, 0, 300)]);
exit;
}
$inserted = 0; $updated = 0; $skipped = 0;
$pTypeMap = ['1' => 1, '2' => 2, '3' => 3, 1 => 1, 2 => 2, 3 => 3];
$stmt = 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
");
foreach ($numeros as $n) {
$waNumber = preg_replace('/\D/', '', (string)($n['wa_number'] ?? $n['numero'] ?? $n['phone'] ?? ''));
if (strlen($waNumber) < 7) { $skipped++; continue; }
$label = trim((string)($n['nombre'] ?? $n['name'] ?? $n['label'] ?? ''));
$permRaw = $n['permiso'] ?? $n['permission_type'] ?? $n['tipo'] ?? 3;
$permission = $pTypeMap[(int)$permRaw] ?? 3;
try {
$before = db()->prepare("SELECT id FROM company_phones WHERE company_id=? AND wa_number=?");
$before->execute([$companyId, $waNumber]);
$exists = $before->fetch();
$stmt->execute([$companyId, $waNumber, $label, $permission]);
$exists ? $updated++ : $inserted++;
} catch (\PDOException $e) { $skipped++; }
}
echo json_encode([
'ok' => true,
'message' => "Sincronización completa: {$inserted} nuevos, {$updated} actualizados, {$skipped} omitidos.",
]);
exit;
}
// ─── 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);
$epId = (int)($_POST['ep_id'] ?? 0);
$key = preg_replace('/[^a-z0-9_]/', '', strtolower($_POST['endpoint_key'] ?? ''));
$name = trim($_POST['name'] ?? '');
$direction = in_array($_POST['direction'] ?? '', ['upload','download','list']) ? $_POST['direction'] : 'download';
$url = trim($_POST['url'] ?? '');
$method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET';
$isActive = ($_POST['is_active'] ?? '0') === '1' ? 1 : 0;
$params = trim($_POST['params'] ?? '');
$bodyFieldsRaw = trim($_POST['body_fields'] ?? '');
// Validate JSON or null
$bodyFields = null;
if ($bodyFieldsRaw !== '' && $bodyFieldsRaw !== '[]') {
$decoded = json_decode($bodyFieldsRaw, true);
if (is_array($decoded) && !empty($decoded)) {
$bodyFields = json_encode($decoded, JSON_UNESCAPED_UNICODE);
}
}
if ($companyId <= 0 || $key === '') {
echo json_encode(['ok' => false, 'error' => 'Datos inválidos']); exit;
}
try {
if ($epId > 0) {
db()->prepare("UPDATE company_endpoints SET endpoint_key=?,name=?,direction=?,url=?,method=?,is_active=?,params=?,body_fields=? WHERE id=? AND company_id=?")
->execute([$key, $name, $direction, $url, $method, $isActive, $params ?: null, $bodyFields, $epId, $companyId]);
} else {
db()->prepare("INSERT INTO company_endpoints (company_id,endpoint_key,name,direction,url,method,is_active,params,body_fields) VALUES (?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE name=VALUES(name),direction=VALUES(direction),url=VALUES(url),method=VALUES(method),is_active=VALUES(is_active),params=VALUES(params),body_fields=VALUES(body_fields)")
->execute([$companyId, $key, $name, $direction, $url, $method, $isActive, $params ?: null, $bodyFields]);
}
echo json_encode(['ok' => true]);
} catch (\PDOException $e) {
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
exit;
}
// ─── POST /admin/company/endpoint/delete ────────────────────────────────
public static function companyEndpointDelete(): void
{
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$companyId = (int)($_POST['company_id'] ?? 0);
$epId = (int)($_POST['ep_id'] ?? 0);
if ($companyId <= 0 || $epId <= 0) { echo json_encode(['ok'=>false,'error'=>'Datos inválidos']); exit; }
try {
db()->prepare("DELETE FROM company_endpoints WHERE id=? AND company_id=?")->execute([$epId, $companyId]);
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; }
// Resolve {var} placeholders using params config
$stmt = db()->prepare("SELECT params FROM company_endpoints WHERE company_id=? AND endpoint_key=?");
$stmt->execute([$companyId, $key]);
$paramsJson = $stmt->fetchColumn();
$varConfigs = is_string($paramsJson) ? (json_decode($paramsJson, true) ?? []) : [];
$presets = [
'current_date' => date('Y-m-d'),
'month_start' => date('Y-m-01'),
'month_end' => date('Y-m-t'),
'last_7_start' => date('Y-m-d', strtotime('-7 days')),
'last_30_start' => date('Y-m-d', strtotime('-30 days')),
'year_start' => date('Y-01-01'),
];
foreach ($varConfigs as $cfg) {
$k = $cfg['key'] ?? '';
if (!$k) continue;
if (($cfg['mode'] ?? '') === 'fixed') {
$val = $cfg['value'] ?? 'current_date';
$url = str_replace('{' . $k . '}', $presets[$val] ?? $val, $url);
} else {
// ponytail: ask-mode vars get today as test default
$url = str_replace('{' . $k . '}', date('Y-m-d'), $url);
}
}
// Replace any remaining unresolved {vars} with today
$url = preg_replace('/\{[^}]+\}/', date('Y-m-d'), $url);
$company = CompanyRepository::findById($companyId);
$apiKey = $company['api_key'] ?? '';
// Prepend api_base_url for relative paths
if ($url !== '' && !str_starts_with($url, 'http')) {
$base = rtrim($company['api_base_url'] ?? '', '/');
$sep = str_starts_with($url, '?') ? '' : '/';
$url = $base . $sep . ltrim($url, '/');
}
$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; }
$isBinary = !empty($contentType) && !str_contains($contentType, 'json') && !str_contains($contentType, 'text');
$decoded = !$isBinary ? json_decode($resp, true) : null;
if ($isBinary) {
$preview = sprintf('Archivo recibido: %s — %s KB', explode(';', $contentType)[0], round(strlen($resp) / 1024, 1));
} elseif (is_array($decoded)) {
$preview = $decoded;
} else {
$preview = mb_convert_encoding(substr((string)$resp, 0, 500), 'UTF-8', 'UTF-8');
}
// Guardar última respuesta
if ($key !== '') {
$logVal = $isBinary ? "binary:{$contentType}:" . strlen($resp) . 'B' : substr((string)$resp, 0, 2000);
db()->prepare("UPDATE company_endpoints SET last_response=?, last_called_at=NOW() WHERE company_id=? AND endpoint_key=?")
->execute([$logVal, $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;
}
// ─── POST /admin/company/endpoints/clone ─────────────────────────────────
public static function companyEndpointsClone(): void
{
SessionAuth::require();
header('Content-Type: application/json; charset=utf-8');
$sourceId = (int)($_POST['source_company_id'] ?? 0);
$targetId = (int)($_POST['target_company_id'] ?? 0);
$mode = ($_POST['mode'] ?? '') === 'add_only' ? 'add_only' : 'overwrite';
if ($sourceId <= 0 || $targetId <= 0 || $sourceId === $targetId) {
echo json_encode(['ok' => false, 'error' => 'IDs inválidos']);
exit;
}
try {
$stmt = db()->prepare("SELECT endpoint_key, direction, url, method FROM company_endpoints WHERE company_id = ?");
$stmt->execute([$sourceId]);
$sourceEps = $stmt->fetchAll(\PDO::FETCH_ASSOC);
if (empty($sourceEps)) {
echo json_encode(['ok' => false, 'error' => 'La empresa origen no tiene endpoints configurados']);
exit;
}
$existingKeys = [];
if ($mode === 'add_only') {
$ex = db()->prepare("SELECT endpoint_key FROM company_endpoints WHERE company_id = ? AND url IS NOT NULL AND url != ''");
$ex->execute([$targetId]);
$existingKeys = array_column($ex->fetchAll(\PDO::FETCH_ASSOC), 'endpoint_key');
}
$upsert = db()->prepare("INSERT INTO company_endpoints (company_id, endpoint_key, direction, url, method) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE url=VALUES(url), method=VALUES(method)");
$count = 0;
foreach ($sourceEps as $ep) {
if ($mode === 'add_only' && in_array($ep['endpoint_key'], $existingKeys, true)) continue;
$upsert->execute([$targetId, $ep['endpoint_key'], $ep['direction'], $ep['url'], $ep['method']]);
$count++;
}
$label = $mode === 'add_only' ? 'añadidos' : 'sobreescritos';
echo json_encode(['ok' => true, 'count' => $count, 'message' => "{$count} endpoint(s) {$label} correctamente"]);
} catch (\PDOException $e) {
echo json_encode(['ok' => false, 'error' => 'Error de base de datos: ' . $e->getMessage()]);
}
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' => ''],
],
];
// ── Valores globales de IA para el panel custom ────────────────────────
$gAiProvider = $settings['ai_provider'] ?? 'openai';
$gOaiKey = self::h($settings['openai_api_key'] ?? '');
$gOaiModel = $settings['openai_model'] ?? 'gpt-4o-mini';
$gGemKey = self::h($settings['gemini_api_key'] ?? '');
$gGemModel = $settings['gemini_model'] ?? 'gemini-2.5-flash';
$gClaKey = self::h($settings['claude_api_key'] ?? '');
$gClaModel = $settings['claude_model'] ?? 'claude-haiku-4-5';
$gMaxTok = self::h($settings['ai_max_tokens'] ?? '500');
$gPrompt = self::h($settings['ai_default_prompt'] ?? '');
$gSelOai = $gAiProvider === 'openai' ? ' selected' : '';
$gSelGem = $gAiProvider === 'gemini' ? ' selected' : '';
$gSelCla = $gAiProvider === 'claude' ? ' selected' : '';
$gSelMock = $gAiProvider === 'mock' ? ' selected' : '';
$gShowOai = ($gAiProvider === 'openai' || $gAiProvider === '') ? '' : 'display:none';
$gShowGem = $gAiProvider === 'gemini' ? '' : 'display:none';
$gShowCla = $gAiProvider === 'claude' ? '' : 'display:none';
$sOaiMini = $gOaiModel === 'gpt-4o-mini' ? ' selected' : '';
$sOai41m = $gOaiModel === 'gpt-4.1-mini' ? ' selected' : '';
$sOai4o = $gOaiModel === 'gpt-4o' ? ' selected' : '';
$sOai41 = $gOaiModel === 'gpt-4.1' ? ' selected' : '';
$sOai35 = $gOaiModel === 'gpt-3.5-turbo' ? ' selected' : '';
$sGemF25 = $gGemModel === 'gemini-2.5-flash' ? ' selected' : '';
$sGemF20 = $gGemModel === 'gemini-2.0-flash' ? ' selected' : '';
$sGemF15 = $gGemModel === 'gemini-1.5-flash' ? ' selected' : '';
$sGemP15 = $gGemModel === 'gemini-1.5-pro' ? ' selected' : '';
$sClaH = $gClaModel === 'claude-haiku-4-5' ? ' selected' : '';
$sClaS = $gClaModel === 'claude-sonnet-5' ? ' selected' : '';
$sClaO = $gClaModel === 'claude-opus-4-8' ? ' selected' : '';
$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
<!-- IA Global custom card (inside form, saves with the rest) -->
<div class="card">
<div class="card-h">🤖 Inteligencia Artificial (Global)</div>
<div class="card-b">
<div class="form-grid">
<div>
<label class="lbl">Proveedor por defecto</label>
<select class="inp-sel" name="ai_provider" id="g_ai_provider" onchange="gAiChange(this.value)" style="width:100%">
<option value="openai"{$gSelOai}>OpenAI (GPT)</option>
<option value="gemini"{$gSelGem}>Google Gemini</option>
<option value="claude"{$gSelCla}>Anthropic Claude</option>
<option value="mock"{$gSelMock}>Mock (simulado)</option>
</select>
</div>
<div>
<label class="lbl">Máximo de tokens</label>
<input class="inp" type="number" name="ai_max_tokens" value="{$gMaxTok}" placeholder="500">
</div>
<div class="fw">
<label class="lbl">System Prompt por defecto</label>
<textarea class="inp" name="ai_default_prompt" rows="3" placeholder="Eres un asistente...">{$gPrompt}</textarea>
</div>
</div>
<!-- OpenAI -->
<div id="g_oai" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowOai}">
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🔵 OpenAI</div>
<div class="form-grid">
<div>
<label class="lbl">API Key</label>
<input class="inp" type="password" name="openai_api_key" id="g_oai_key" value="{$gOaiKey}" placeholder="sk-...">
</div>
<div>
<label class="lbl">Modelo</label>
<select class="inp-sel" name="openai_model" id="g_oai_model" style="width:100%">
<option value="gpt-4o-mini"{$sOaiMini}>GPT-4o Mini</option>
<option value="gpt-4.1-mini"{$sOai41m}>GPT-4.1 Mini</option>
<option value="gpt-4o"{$sOai4o}>GPT-4o</option>
<option value="gpt-4.1"{$sOai41}>GPT-4.1</option>
<option value="gpt-3.5-turbo"{$sOai35}>GPT-3.5 Turbo</option>
</select>
</div>
</div>
<div style="margin-top:10px">
<button type="button" class="btn-primary" onclick="testProvider('openai','g_oai_key','g_oai_model','g_oai_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
<span id="g_oai_res" style="font-size:12px;margin-left:10px"></span>
</div>
</div>
<!-- Gemini -->
<div id="g_gem" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowGem}">
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🟢 Google Gemini</div>
<div class="form-grid">
<div>
<label class="lbl">API Key</label>
<input class="inp" type="password" name="gemini_api_key" id="g_gem_key" value="{$gGemKey}" placeholder="AIza...">
</div>
<div>
<label class="lbl">Modelo</label>
<select class="inp-sel" name="gemini_model" id="g_gem_model" style="width:100%">
<option value="gemini-2.5-flash"{$sGemF25}>Gemini 2.5 Flash</option>
<option value="gemini-2.0-flash"{$sGemF20}>Gemini 2.0 Flash</option>
<option value="gemini-1.5-flash"{$sGemF15}>Gemini 1.5 Flash</option>
<option value="gemini-1.5-pro"{$sGemP15}>Gemini 1.5 Pro</option>
</select>
</div>
</div>
<div style="margin-top:10px">
<button type="button" class="btn-primary" onclick="testProvider('gemini','g_gem_key','g_gem_model','g_gem_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
<span id="g_gem_res" style="font-size:12px;margin-left:10px"></span>
</div>
</div>
<!-- Claude -->
<div id="g_cla" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowCla}">
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🟣 Anthropic Claude</div>
<div class="form-grid">
<div>
<label class="lbl">API Key</label>
<input class="inp" type="password" name="claude_api_key" id="g_cla_key" value="{$gClaKey}" placeholder="sk-ant-...">
</div>
<div>
<label class="lbl">Modelo</label>
<select class="inp-sel" name="claude_model" id="g_cla_model" style="width:100%">
<option value="claude-haiku-4-5"{$sClaH}>Claude Haiku 4.5 (rápido)</option>
<option value="claude-sonnet-5"{$sClaS}>Claude Sonnet 5</option>
<option value="claude-opus-4-8"{$sClaO}>Claude Opus 4.8</option>
</select>
</div>
</div>
<div style="margin-top:10px">
<button type="button" class="btn-primary" onclick="testProvider('claude','g_cla_key','g_cla_model','g_cla_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
<span id="g_cla_res" style="font-size:12px;margin-left:10px"></span>
</div>
</div>
</div>
</div>
<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>
<script>
function gAiChange(v) {
document.getElementById('g_oai').style.display = (v === 'openai' || !v) ? '' : 'none';
document.getElementById('g_gem').style.display = v === 'gemini' ? '' : 'none';
document.getElementById('g_cla').style.display = v === 'claude' ? '' : 'none';
}
function testProvider(provider, keyId, modelId, resId) {
var key = document.getElementById(keyId)?.value || '';
var model = document.getElementById(modelId)?.value || '';
var res = document.getElementById(resId);
res.textContent = 'Probando...'; res.style.color = '#666';
var body = 'provider=' + encodeURIComponent(provider) + '&api_key=' + encodeURIComponent(key) + '&model=' + encodeURIComponent(model);
fetch('/admin/settings/test-ai', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body})
.then(function(r){return r.json();})
.then(function(d){
if (d.ok) { res.textContent = '✅ OK: ' + d.response; res.style.color = '#16a34a'; }
else { res.textContent = '❌ ' + d.error; res.style.color = '#dc2626'; }
})
.catch(function(){ res.textContent = 'Error de red'; res.style.color = '#dc2626'; });
}
</script>
</body>
</html>
HTML;
exit;
}
// ─── Chat ────────────────────────────────────────────────────────────────
public static function chat(): void
{
SessionAuth::require();
$user = SessionAuth::user();
$userName = self::h($user['name'] ?? 'Admin');
// Pre-load conversations for inline embedding (no extra HTTP round-trip)
$companyMap = [];
foreach (CompanyRepository::findAll() as $co) {
$companyMap[(int)$co['id']] = $co['name'];
}
$convRows = 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 message_type FROM conversations c2 WHERE c2.phone_number = c.phone_number ORDER BY c2.id DESC LIMIT 1) as last_type,
(SELECT COUNT(*) FROM conversations c3
WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound'
AND c3.created_at > COALESCE(
(SELECT MAX(c4.created_at) FROM conversations c4
WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'), '2000-01-01')) as unread_count
FROM conversations c
GROUP BY c.phone_number
ORDER BY last_time DESC
")->fetchAll();
$initialConvs = [];
foreach ($convRows as $r) {
$cid = (int)$r['company_id'];
$preview = mb_substr(self::extractContent($r['last_message'] ?? '', $r['last_type'] ?? 'text'), 0, 80);
$initialConvs[] = [
'phone' => $r['phone_number'],
'contact_name' => $r['contact_name'] ?? '',
'company_name' => $companyMap[$cid] ?? '',
'last_message' => $preview,
'last_time' => $r['last_time'],
'unread_count' => (int)$r['unread_count'],
];
}
$initialConvsJson = json_encode($initialConvs, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT);
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 = '';
const INITIAL_CONVS = {$initialConvsJson};
// ─── Render conversations list ───────────────────────────────────────────────
function renderConvs(convs) {
const list = document.getElementById('convList');
if (!convs || convs.length === 0) {
list.innerHTML = '<div style="padding:24px 16px;text-align:center;color:#a0a8b8;font-size:13px">Sin conversaciones aún</div>';
return;
}
list.innerHTML = convs.map(c => {
const time = formatTime(c.last_time);
const name = c.contact_name || c.phone || '';
const avatar = name.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('\${esc(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(name || 'Desconocido')} \${company}</div>
<div class="conv-phone">\${esc(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('');
}
// ─── Load conversations ──────────────────────────────────────────────────────
async function loadConvs() {
try {
const r = await fetch('/admin/chat?api=convs');
if (!r.ok) return;
const data = await r.json();
renderConvs(data.conversations || []);
} catch (e) {
console.error('loadConvs error:', e);
}
}
// ─── 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 msgsDiv = document.getElementById('chatMessages');
msgsDiv.innerHTML = '<div style="text-align:center;padding:20px;color:#a0a8b8">Cargando...</div>';
let data;
try {
const r = await fetch('/admin/chat?api=msgs&phone=' + encodeURIComponent(phone));
if (!r.ok) {
msgsDiv.innerHTML = \`<div style="text-align:center;padding:20px;color:#e53e3e">Error \${r.status}</div>\`;
return;
}
data = await r.json();
} catch (e) {
console.error('loadMessages error:', e);
msgsDiv.innerHTML = '<div style="text-align:center;padding:20px;color:#e53e3e">Error de conexión</div>';
return;
}
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', {
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');
if (msgInput) {
msgInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
}
renderConvs(INITIAL_CONVS);
setInterval(loadConvs, 15000);
</script>
</body>
</html>
HTML;
exit;
}
// ─── Helper: extract human-readable text from raw conversation content ───
private static function extractContent(string $raw, string $type): string
{
if ($raw === '') return '';
// Inbound button_reply: "button_reply: {...}"
if (str_starts_with($raw, 'button_reply: ')) {
$j = json_decode(substr($raw, 14), true);
return $j['title'] ?? $raw;
}
// Inbound list_reply: "list_reply: {...}"
if (str_starts_with($raw, 'list_reply: ')) {
$j = json_decode(substr($raw, 12), true);
return $j['title'] ?? $raw;
}
// Try to parse as JSON
$j = json_decode($raw, true);
if (!is_array($j)) return $raw;
// Outbound text: {"text": "..."}
if (isset($j['text']) && is_string($j['text'])) return $j['text'];
// Outbound interactive: {"interactive": {"body": {"text":"..."}, "action":{...}}}
if (isset($j['interactive'])) {
$body = $j['interactive']['body']['text'] ?? '';
$btns = [];
foreach ($j['interactive']['action']['buttons'] ?? [] as $b) {
$btns[] = $b['reply']['title'] ?? '';
}
foreach ($j['interactive']['action']['sections'] ?? [] as $s) {
foreach ($s['rows'] ?? [] as $row) {
$btns[] = $row['title'] ?? '';
}
}
$hint = $btns ? ' [' . implode(' | ', array_filter($btns)) . ']' : '';
return $body . $hint;
}
// Outbound image/document
if (isset($j['caption'])) return '📎 ' . $j['caption'];
if (isset($j['link'])) return '📎 ' . $j['link'];
return $raw;
}
// ─── 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 message_type FROM conversations c2 WHERE c2.phone_number = c.phone_number ORDER BY c2.id DESC LIMIT 1) as last_type,
(SELECT COUNT(*) FROM conversations c3
WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound'
AND c3.created_at > COALESCE(
(SELECT MAX(c4.created_at) FROM conversations c4
WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'), '2000-01-01')) 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'];
$raw = $r['last_message'] ?? '';
$preview = mb_substr(self::extractContent($raw, $r['last_type'] ?? 'text'), 0, 80);
$conversations[] = [
'phone' => $r['phone_number'],
'contact_name' => $r['contact_name'] ?? '',
'company_name' => $companies[$cid] ?? '',
'last_message' => $preview,
'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();
// Extract readable text from raw WhatsApp payloads
foreach ($messages as &$m) {
$m['content'] = self::extractContent((string)($m['content'] ?? ''), (string)($m['message_type'] ?? 'text'));
}
unset($m);
jsonResponse(200, ['messages' => $messages]);
}
// ─── 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;
$db = db();
// Look up the WhatsApp phone_number_id for this company
$company = CompanyRepository::findById($companyId);
$phoneNumberId = $company['phone_number_id'] ?? env('WHATSAPP_PHONE_NUMBER_ID', '');
// Send directly — do NOT call processQueue() which would re-send ALL queued/failed messages
$sendResult = WhatsAppSender::sendText($phone, $text, $phoneNumberId);
// Log to conversations regardless of WhatsApp delivery result
$msgId = 'admin_' . time() . '_' . $phone;
$status = $sendResult['success'] ? 'sent' : 'failed';
$db->prepare("INSERT INTO conversations (company_id, message_id, phone_number, direction, message_type, content, created_at) VALUES (?, ?, ?, 'outbound', 'text', ?, NOW())")
->execute([$companyId, $msgId, $phone, $text]);
// Also keep an outbound_queue record for auditing
$db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload, status, wam_id, created_at) VALUES (?, ?, 'text', ?, ?, ?, NOW())")
->execute([$companyId, $phone, json_encode(['text' => $text]), $status, $sendResult['wam_id'] ?? null]);
if (!$sendResult['success']) {
jsonResponse(500, ['error' => $sendResult['error'] ?? 'Error al enviar']);
}
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>' : '';
$companyEndpoints = [];
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;
}
$epStmt = db()->prepare('SELECT endpoint_key, name, url, method, direction FROM company_endpoints WHERE company_id=? AND is_active=1 ORDER BY endpoint_key');
$epStmt->execute([$companyId]);
$companyEndpoints = $epStmt->fetchAll(PDO::FETCH_ASSOC);
}
}
$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'] ?? '');
$aiForMediaChecked = ($config['ai_for_media'] ?? true) ? 'checked' : '';
$nluChecked = !empty($config['nlu']) ? 'checked' : '';
$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'] ?? []));
$welcomeMenuVal = self::h($config['welcome_menu'] ?? '');
$commands = $config['commands'] ?? [];
$menus = $config['menus'] ?? [];
$flows = $config['flows'] ?? [];
$welcomeMenuOpts = '';
foreach (array_keys($menus) as $mk) {
$s = ($config['welcome_menu'] ?? '') === $mk ? ' selected' : '';
$welcomeMenuOpts .= '<option value="' . self::h($mk) . '"' . $s . '>' . self::h($mk) . '</option>';
}
$aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : '';
$aiProviderGemini = $aiProviderVal === 'gemini' ? ' selected' : '';
$aiProviderClaude = $aiProviderVal === 'claude' ? ' selected' : '';
$aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : '';
$openaiApiKeyVal = self::h($config['openai_api_key'] ?? '');
$aiModelVal = $config['ai_model'] ?? 'gpt-4o-mini';
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
$aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : '';
$aiModel41 = $aiModelVal === 'gpt-4.1' ? ' selected' : '';
$aiModel41mini = $aiModelVal === 'gpt-4.1-mini' ? ' selected' : '';
$aiModel35 = $aiModelVal === 'gpt-3.5-turbo'? ' selected' : '';
$geminiApiKeyVal = self::h($config['gemini_api_key'] ?? '');
$geminiModelVal = $config['gemini_model'] ?? 'gemini-2.5-flash';
$whisperUrlVal = self::h($config['whisper_url'] ?? '');
$whisperUserVal = self::h($config['whisper_user'] ?? '');
$whisperPassVal = self::h($config['whisper_pass'] ?? '');
$geminiModelF25 = $geminiModelVal === 'gemini-2.5-flash' ? ' selected' : '';
$geminiModelF20 = $geminiModelVal === 'gemini-2.0-flash' ? ' selected' : '';
$geminiModelF15 = $geminiModelVal === 'gemini-1.5-flash' ? ' selected' : '';
$geminiModelP15 = $geminiModelVal === 'gemini-1.5-pro' ? ' selected' : '';
$claudeApiKeyVal = self::h($config['claude_api_key'] ?? '');
$claudeModelVal = $config['claude_model'] ?? 'claude-haiku-4-5';
$claudeModelH = $claudeModelVal === 'claude-haiku-4-5' ? ' selected' : '';
$claudeModelS = $claudeModelVal === 'claude-sonnet-5' ? ' selected' : '';
$claudeModelO = $claudeModelVal === 'claude-opus-4-8' ? ' selected' : '';
// Determine which provider panel to show initially
$showOpenai = ($aiProviderVal === 'openai' || $aiProviderVal === '')
? 'display:flex;flex-direction:column;gap:10px' : 'display:none';
$showGemini = $aiProviderVal === 'gemini'
? 'display:flex;flex-direction:column;gap:10px' : 'display:none';
$showClaude = $aiProviderVal === 'claude'
? 'display:flex;flex-direction:column;gap:10px' : 'display:none';
// Per-category config HTML
$perTypeConfig = $config['per_type'] ?? [];
$menuKeysList = array_keys($menus);
$flowKeysList = array_keys($config['flows'] ?? []);
// Include per_type menus/flows in the option lists
foreach ($config['per_type'] ?? [] as $pt) {
foreach (array_keys($pt['menus'] ?? []) as $k) if (!in_array($k, $menuKeysList)) $menuKeysList[] = $k;
foreach (array_keys($pt['flows'] ?? []) as $k) if (!in_array($k, $flowKeysList)) $flowKeysList[] = $k;
}
$catMeta = [
'1' => ['label' => 'Categoría 1 — Solo reporta', 'icon' => '📤', 'bg' => '#f0fdf4', 'bd' => '#86efac', 'clr' => '#166534'],
'2' => ['label' => 'Categoría 2 — Solo recibe informes', 'icon' => '📥', 'bg' => '#eff6ff', 'bd' => '#93c5fd', 'clr' => '#1e40af'],
'3' => ['label' => 'Categoría 3 — Reporta y recibe', 'icon' => '🔄', 'bg' => '#fdf4ff', 'bd' => '#e879f9', 'clr' => '#701a75'],
];
$categoryTabHtml = '';
// Build shared option lists
$menuOptsBase = '<option value="">— Sin menú —</option>';
foreach ($menuKeysList as $mk) $menuOptsBase .= '<option value="' . self::h($mk) . '">' . self::h($mk) . '</option>';
$flowOptsBase = '<option value="">— Ninguno —</option>';
foreach ($menuKeysList as $mk) $flowOptsBase .= '<option value="' . self::h($mk) . '">📑 ' . self::h($mk) . '</option>';
foreach ($flowKeysList as $fk) $flowOptsBase .= '<option value="' . self::h($fk) . '">🔀 ' . self::h($fk) . '</option>';
foreach ([1, 2, 3] as $cat) {
$catCfg = $perTypeConfig[(string)$cat] ?? [];
$m = $catMeta[(string)$cat];
$greetFlow = self::h($catCfg['greeting_flow'] ?? '');
$fallback = $catCfg['fallback_flow'] ?? '';
$selMenu = $catCfg['greeting_menu'] ?? '';
// greeting_menu selector (insert selected attr)
$menuOpts = str_replace(
'value="' . self::h($selMenu) . '"',
'value="' . self::h($selMenu) . '" selected',
$menuOptsBase
);
// fallback selector (insert selected attr)
$fbOpts = str_replace(
'value="' . self::h($fallback) . '"',
'value="' . self::h($fallback) . '" selected',
$flowOptsBase
);
// Mostrar greeting_menu solo cuando NO hay greeting_flow (cat 1)
$hasGreetFlow = $greetFlow !== '';
$greetFlowStyle = '';
$greetMenuStyle = $hasGreetFlow ? 'display:none' : '';
$label = $m['label']; $icon = $m['icon']; $bg = $m['bg']; $bd = $m['bd']; $clr = $m['clr'];
$categoryTabHtml .= <<<CATHTML
<div style="background:{$bg};border:1px solid {$bd};border-radius:10px;padding:18px;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px">
<span style="font-size:20px">{$icon}</span>
<span style="font-weight:700;font-size:13px;color:{$clr}">{$label}</span>
</div>
<div class="form-group" style="{$greetFlowStyle};margin-bottom:12px">
<label>Flujo de bienvenida</label>
<input type="text" name="per_type_greeting_flow[{$cat}]" value="{$greetFlow}"
list="ptFlowsDatalist" placeholder="ej: ask_finca (se auto-selecciona si hay 1 sola opción)"
style="width:100%;padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
<div style="font-size:11px;color:#7a8291;margin-top:3px">
Flow que se ejecuta al primer mensaje. Si el flow retorna 1 sola opción (finca, empresa, etc.) la selecciona automáticamente sin preguntar.
</div>
</div>
<div id="cat-greet-menu-{$cat}" style="{$greetMenuStyle}" class="form-group" style="margin-bottom:12px">
<label>Menú inicial</label>
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
{$menuOpts}
</select>
<div style="font-size:11px;color:#7a8291;margin-top:3px">Menú que se muestra cuando no hay flujo de bienvenida configurado</div>
</div>
<div class="form-group" style="margin-bottom:0">
<label>Flujo de respaldo (fallback)</label>
<select name="per_type_fallback[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
{$fbOpts}
</select>
<div style="font-size:11px;color:#7a8291;margin-top:3px">Se muestra cuando el usuario envía algo que no corresponde a ninguna opción</div>
</div>
</div>
CATHTML;
}
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
$aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : '';
$aiModel35 = $aiModelVal === 'gpt-3.5-turbo' ? ' selected' : '';
// JS data for addFlowCard — needs CONFIG.menus/flows and ENDPOINTS
$botConfigJson = json_encode(['menus' => $menus, 'flows' => $flows], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG);
$botEndpointsJson = json_encode(
array_column($companyEndpoints, null, 'endpoint_key'),
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG
);
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('categories',this)">👥 Por Categoría</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 directamente. El valor de acción debe coincidir con un ID de menú o flujo.</p>
<datalist id="actionsDatalist">
HTML;
foreach (array_keys($menus) as $mk) echo "<option value=\"" . self::h($mk) . "\">📑 Menú</option>\n";
foreach (array_keys($config['flows'] ?? []) as $fk) echo "<option value=\"" . self::h($fk) . "\">🔀 Flujo</option>\n";
echo <<<HTML
</datalist>
<table class="simple" id="cmdTable">
<thead><tr><th>Palabra clave</th><th>Acción (ID de 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) . "\" list=\"actionsDatalist\" style=\"width:100%;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:13px\" placeholder=\"ej: show_main, salir...\"></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 style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></div>
</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'] ?? 'button';
$mb = self::h($menu['body'] ?? '');
$mh = self::h($menu['header'] ?? '');
$mf = self::h($menu['footer'] ?? '');
$mbtn = self::h($menu['button'] ?? '');
$isBtn = $mt === 'button';
$listStyle = $isBtn ? 'display:none' : '';
$btnStyle = $isBtn ? '' : 'display:none';
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ú\"></span>
<button type=\"button\" class=\"del\" onclick=\"this.closest('.item-card').remove()\">✕ Eliminar</button>
</div>
<div class=\"form-row\">
<div class=\"form-group\" style=\"min-width:110px\"><label>Tipo</label>
<select name=\"menu_type[]\" onchange=\"menuTypeChange(this,{$menuIdx})\">
<option value=\"button\"" . ($isBtn ? ' selected' : '') . ">Botones directos</option>
<option value=\"list\"" . (!$isBtn ? ' selected' : '') . ">Lista (con modal)</option>
</select>
</div>
<div class=\"form-group\" style=\"flex:2\"><label>Texto del mensaje</label>
<input type=\"text\" name=\"menu_body[]\" value=\"{$mb}\" placeholder=\"Selecciona una opción:\">
</div>
<div class=\"form-group list-fields-{$menuIdx}\" style=\"{$listStyle}\"><label>Header</label><input type=\"text\" name=\"menu_header[]\" value=\"{$mh}\" placeholder=\"Título\"></div>
<div class=\"form-group list-fields-{$menuIdx}\" style=\"{$listStyle}\"><label>Footer</label><input type=\"text\" name=\"menu_footer[]\" value=\"{$mf}\" placeholder=\"Footer\"></div>
<div class=\"form-group list-fields-{$menuIdx}\" style=\"{$listStyle}\"><label>Botón abrir</label><input type=\"text\" name=\"menu_button[]\" value=\"{$mbtn}\" placeholder=\"Ver opciones\"></div>
</div>
<div id=\"btn-area-{$menuIdx}\" style=\"{$btnStyle}margin-top:8px\">
<div style=\"font-size:12px;font-weight:600;color:#3d4552;margin-bottom:6px\">Botones (máx 3):</div>
<div id=\"btn-rows-{$menuIdx}\">";
foreach ($menu['buttons'] ?? [] as $bi => $btn) {
$bid = self::h($btn['id'] ?? '');
$btt = self::h($btn['title'] ?? '');
echo "<div class=\"inline-group\" style=\"margin-bottom:4px\">
<input type=\"text\" name=\"menu_btn_id[{$menuIdx}][]\" value=\"{$bid}\" placeholder=\"ID (ej: ver_info)\" style=\"flex:1\">
<input type=\"text\" name=\"menu_btn_title[{$menuIdx}][]\" value=\"{$btt}\" placeholder=\"Título (máx 20 chars)\" style=\"flex:2\">
<button type=\"button\" class=\"btn-danger-sm\" onclick=\"this.closest('.inline-group').remove()\">✕</button>
</div>";
}
echo "</div>
<button type=\"button\" class=\"btn-secondary\" style=\"font-size:11px;padding:4px 10px;margin-top:4px\" onclick=\"addBtnRow({$menuIdx})\">+ Botón</button>
</div>
<div id=\"sections-area-{$menuIdx}\" style=\"{$listStyle}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:</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\" 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 style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></div>
</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 botón de menú es seleccionado.</p>
<div id="flowContainer">
HTML;
// Pre-build select options for menus and endpoints
$menuOptHtml = '<option value="">— Selecciona menú —</option>';
foreach (array_keys($menus) as $mk) {
$menuOptHtml .= '<option value="' . self::h($mk) . '">📑 ' . self::h($mk) . '</option>';
}
// Helper para etiqueta de endpoint: muestra nombre si existe, si no la key
$epLabel = fn(array $ep): string => self::h(
($ep['name'] !== '' && $ep['name'] !== null) ? $ep['name'] : $ep['endpoint_key']
);
$epOptHtml = '<option value="">— Selecciona endpoint —</option>';
foreach ($companyEndpoints as $ep) {
$epOptHtml .= '<option value="' . self::h($ep['endpoint_key']) . '">' . $epLabel($ep) . '</option>';
}
$flowKeys = array_keys($flows);
$flowIdx = 0;
foreach ($flows as $fk => $flow) {
$ft = $flow['type'] ?? 'text';
$fNluDesc = self::h($flow['nlu_description'] ?? '');
$fMsg = self::h($flow['message'] ?? '');
$fFn = $flow['function'] ?? 'forward_to_ai';
$fMenu = $flow['menu'] ?? '';
$params = $flow['params'] ?? [];
$epKey = self::h($params['endpoint_key'] ?? $flow['endpoint_key'] ?? '');
$dMode = self::h($params['date_mode'] ?? '');
$cap = self::h($params['caption'] ?? $flow['caption'] ?? '');
$fname = self::h($params['filename'] ?? $flow['filename'] ?? '');
$prompt = self::h($flow['prompt'] ?? '');
$mGroup = self::h($flow['meta_group'] ?? '');
$mKey = self::h($flow['meta_key'] ?? '');
$nNode = $flow['next_node'] ?? '';
$srcEp = self::h($flow['source_endpoint_key'] ?? '');
$valF = self::h($flow['value_field'] ?? 'id');
$lblF = self::h($flow['label_field'] ?? 'nombre');
$header = self::h($flow['header'] ?? '');
$body = self::h($flow['body'] ?? '');
$secTit = self::h($flow['section_title'] ?? '');
// Build menu select with current selection
$menuSelHtml = '<option value="">— Selecciona menú —</option>';
foreach (array_keys($menus) as $mk) {
$sel = $fMenu === $mk ? ' selected' : '';
$menuSelHtml .= '<option value="' . self::h($mk) . '"' . $sel . '>📑 ' . self::h($mk) . '</option>';
}
// Build endpoint select with current selection
$epSelHtml = '<option value="">— Selecciona endpoint —</option>';
foreach ($companyEndpoints as $ep) {
$sel = $epKey === self::h($ep['endpoint_key']) ? ' selected' : '';
$epSelHtml .= '<option value="' . self::h($ep['endpoint_key']) . '"' . $sel . '>' . $epLabel($ep) . '</option>';
}
// For dynamic_list source endpoint
$dlEpSelHtml = '<option value="">— Selecciona endpoint —</option>';
foreach ($companyEndpoints as $ep) {
$sel = $srcEp === self::h($ep['endpoint_key']) ? ' selected' : '';
$dlEpSelHtml .= '<option value="' . self::h($ep['endpoint_key']) . '"' . $sel . '>' . $epLabel($ep) . '</option>';
}
// For submit_form endpoint
$sfEpSelHtml = '<option value="">— Selecciona endpoint —</option>';
foreach ($companyEndpoints as $ep) {
$sel = $epKey === self::h($ep['endpoint_key']) ? ' selected' : '';
$sfEpSelHtml .= '<option value="' . self::h($ep['endpoint_key']) . '"' . $sel . '>' . $epLabel($ep) . '</option>';
}
// Next-node select
$nnSelHtml = '<option value="">— Ninguno —</option>';
foreach ($flowKeys as $ofk) {
if ($ofk === $fk) continue;
$sel = $nNode === $ofk ? ' selected' : '';
$nnSelHtml .= '<option value="' . self::h($ofk) . '"' . $sel . '>' . self::h($ofk) . '</option>';
}
$showText = $ft === 'text' ? '' : 'display:none';
$showMenu = $ft === 'menu' ? '' : 'display:none';
$showFn = $ft === 'function' ? '' : 'display:none';
$showEp = ($ft === 'function' && $fFn === 'api_report')
? 'display:flex;flex-direction:column;gap:8px;margin-top:8px'
: 'display:none';
$showCollect = $ft === 'collect_input' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none';
$showDynList = $ft === 'dynamic_list' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none';
$showSubmit = $ft === 'submit_form' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none';
$showForEach = $ft === 'collect_for_each' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none';
$showCap = $ft === 'collect_and_post' ? 'display:flex;flex-direction:column;gap:8px' : 'display:none';
// collect_and_post fields
$capFields = $flow['fields'] ?? [];
$capHeader = self::h($flow['header'] ?? '');
$capSucc = self::h($flow['success_text'] ?? '');
$capConfirm = !empty($flow['confirm']);
$capEpKey = self::h($flow['endpoint_key'] ?? '');
$capEpSelHtml = '<option value="">— Selecciona endpoint —</option>';
foreach ($companyEndpoints as $ep) {
$sel = $capEpKey === self::h($ep['endpoint_key']) ? ' selected' : '';
$capEpSelHtml .= '<option value="' . self::h($ep['endpoint_key']) . '"' . $sel . '>' . $epLabel($ep) . '</option>';
}
$capFieldsHtml = '';
foreach ($capFields as $cf) {
$ck = self::h($cf['key'] ?? '');
$cl = self::h($cf['label'] ?? '');
$cp = self::h($cf['prompt'] ?? '');
$capFieldsHtml .= "
<div class=\"cap-field-row\" style=\"display:grid;grid-template-columns:1fr 1fr 2fr auto;gap:6px;align-items:center\">
<input type=\"text\" name=\"cap_field_key[]\" value=\"{$ck}\" placeholder=\"clave (ej: fecha)\" style=\"font-family:monospace\">
<input type=\"text\" name=\"cap_field_label[]\" value=\"{$cl}\" placeholder=\"etiqueta\">
<input type=\"text\" name=\"cap_field_prompt[]\" value=\"{$cp}\" placeholder=\"Pregunta al usuario\">
<button type=\"button\" onclick=\"this.closest('.cap-field-row').remove()\" style=\"background:#fee2e2;border:none;border-radius:4px;padding:4px 8px;cursor:pointer\">✕</button>
</div>";
}
$feEpSelHtml = '<option value="">— Selecciona endpoint lista —</option>';
foreach ($companyEndpoints as $ep) {
$sel = $srcEp === self::h($ep['endpoint_key']) ? ' selected' : '';
$feEpSelHtml .= '<option value="' . self::h($ep['endpoint_key']) . '"' . $sel . '>' . $epLabel($ep) . '</option>';
}
$feUpEpSelHtml = '<option value="">— Selecciona endpoint upload —</option>';
$feUpEpKey = self::h($flow['endpoint_key'] ?? '');
foreach ($companyEndpoints as $ep) {
$sel = $feUpEpKey === self::h($ep['endpoint_key']) ? ' selected' : '';
$feUpEpSelHtml .= '<option value="' . self::h($ep['endpoint_key']) . '"' . $sel . '>' . $epLabel($ep) . '</option>';
}
$feQuestion = self::h($flow['question'] ?? '');
$feSuccessText = self::h($flow['success_text'] ?? '');
$fnOpts = '';
foreach (['goodbye' => '↩️ Salir (goodbye)', 'api_report' => '📄 Descargar informe', 'forward_to_ai' => '🤖 Escalar a IA', 'forward_to_agent' => '👤 Escalar a agente'] as $fv => $fl) {
$fnOpts .= '<option value="' . $fv . '"' . ($fFn === $fv ? ' selected' : '') . '>' . $fl . '</option>';
}
$dmOpts = '';
foreach (['' => 'Sin fecha', 'today' => 'Hoy', 'last_30' => 'Últimos 30 días'] as $dv => $dl) {
$dmOpts .= '<option value="' . $dv . '"' . ($dMode === $dv ? ' selected' : '') . '>' . $dl . '</option>';
}
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:180px;font-size:13px\"></span>
<span style=\"font-size:11px;color:#7a8291;font-weight:400;margin-left:8px\">🧠 NLU:</span>
<input type=\"text\" name=\"flow_nlu_desc[]\" value=\"{$fNluDesc}\" placeholder=\"Descripción para IA (ej: Registrar pluviometría)\" style=\"border:none;background:#f0f4ff;border-radius:4px;padding:2px 8px;font-size:11px;color:#374151;flex:1;min-width:0\">
<button type=\"button\" class=\"del\" onclick=\"this.closest('.item-card').remove()\">✕</button>
</div>
<div class=\"form-row\" style=\"align-items:flex-start;gap:10px\">
<div class=\"form-group\" style=\"min-width:200px\">
<label>¿Qué hace?</label>
<select name=\"flow_type[]\" onchange=\"flowTabChange(this)\">
<optgroup label=\"Respuesta\">
<option value=\"text\"" . ($ft==='text'?' selected':'') . ">💬 Texto fijo</option>
</optgroup>
<optgroup label=\"Navegar\">
<option value=\"menu\"" . ($ft==='menu'?' selected':'') . ">📑 Mostrar menú</option>
</optgroup>
<optgroup label=\"Acción directa\">
<option value=\"function\"" . ($ft==='function'?' selected':'') . ">⚡ Función especial</option>
</optgroup>
<optgroup label=\"Formulario\">
<option value=\"collect_input\"" . ($ft==='collect_input' ?' selected':'') . ">✏️ Pedir dato</option>
<option value=\"dynamic_list\"" . ($ft==='dynamic_list' ?' selected':'') . ">📋 Lista dinámica</option>
<option value=\"collect_for_each\"" . ($ft==='collect_for_each'?' selected':'') . ">🌧 Registro por ítem</option>
<option value=\"collect_and_post\"" . ($ft==='collect_and_post'?' selected':'') . ">📝 Pedir campos y enviar</option>
<option value=\"submit_form\"" . ($ft==='submit_form' ?' selected':'') . ">🚀 Enviar formulario</option>
</optgroup>
</select>
</div>
<div style=\"flex:1;display:flex;flex-direction:column;gap:10px\">
<!-- texto fijo -->
<div class=\"ftg-text\" style=\"{$showText}\">
<textarea name=\"flow_message[]\" rows=\"2\" placeholder=\"Texto que recibirá el usuario\">{$fMsg}</textarea>
</div>
<!-- menú -->
<div class=\"ftg-menu\" style=\"{$showMenu}\">
<label>Menú a mostrar</label>
<select name=\"flow_menu[]\">{$menuSelHtml}</select>
</div>
<!-- función especial -->
<div class=\"ftg-fn\" style=\"{$showFn}\">
<label>Función</label>
<select name=\"flow_function[]\" onchange=\"flowFnTabChange(this)\">{$fnOpts}</select>
<div class=\"ftg-ep\" style=\"{$showEp}\">
<div><label>Endpoint</label><select name=\"flow_ep_key[]\">{$epSelHtml}</select></div>
<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">
<div><label>Periodo</label><select name=\"flow_date_mode[]\">{$dmOpts}</select></div>
<div><label>Nombre archivo</label><input type=\"text\" name=\"flow_filename[]\" value=\"{$fname}\" placeholder=\"reporte.pdf\"></div>
</div>
<div><label>Mensaje al enviar</label><input type=\"text\" name=\"flow_caption[]\" value=\"{$cap}\" placeholder=\"Aquí tienes el informe.\"></div>
</div>
</div>
<!-- collect_input -->
<div class=\"ftg-collect\" style=\"{$showCollect}\">
<div><label>Pregunta al usuario</label><input type=\"text\" name=\"flow_prompt[]\" value=\"{$prompt}\" placeholder=\"¿Desde qué fecha? (YYYY-MM-DD)\"></div>
<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">
<div><label>Grupo del formulario</label><input type=\"text\" name=\"flow_meta_group[]\" value=\"{$mGroup}\" placeholder=\"form_produccion_lotes\"></div>
<div><label>Campo</label><input type=\"text\" name=\"flow_meta_key[]\" value=\"{$mKey}\" placeholder=\"desde\"></div>
</div>
<div><label>Flujo siguiente</label><select name=\"flow_next_node[]\">{$nnSelHtml}</select></div>
</div>
<!-- dynamic_list -->
<div class=\"ftg-dynlist\" style=\"{$showDynList}\">
<div><label>Endpoint que devuelve la lista</label><select name=\"flow_dl_ep[]\">{$dlEpSelHtml}</select></div>
<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">
<div><label>Campo de valor (ID)</label><input type=\"text\" name=\"flow_val_field[]\" value=\"{$valF}\" placeholder=\"id\"></div>
<div><label>Campo etiqueta</label><input type=\"text\" name=\"flow_lbl_field[]\" value=\"{$lblF}\" placeholder=\"nombre\"></div>
</div>
<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">
<div><label>Encabezado</label><input type=\"text\" name=\"flow_header[]\" value=\"{$header}\" placeholder=\"Selecciona la finca\"></div>
<div><label>Título sección</label><input type=\"text\" name=\"flow_sec_title[]\" value=\"{$secTit}\" placeholder=\"Fincas\"></div>
</div>
<div><label>Cuerpo del mensaje</label><input type=\"text\" name=\"flow_body[]\" value=\"{$body}\" placeholder=\"¿De qué finca necesitas el informe?\"></div>
<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">
<div><label>Grupo formulario</label><input type=\"text\" name=\"flow_dl_meta_group[]\" value=\"{$mGroup}\" placeholder=\"form_prod_finca\"></div>
<div><label>Campo</label><input type=\"text\" name=\"flow_dl_meta_key[]\" value=\"{$mKey}\" placeholder=\"finca_id\"></div>
</div>
<div><label>Flujo siguiente</label><select name=\"flow_dl_next[]\">{$nnSelHtml}</select></div>
</div>
<!-- submit_form -->
<div class=\"ftg-submit\" style=\"{$showSubmit}\">
<div><label>Endpoint destino</label><select name=\"flow_sf_ep[]\">{$sfEpSelHtml}</select></div>
<div><label>Grupo del formulario</label><input type=\"text\" name=\"flow_sf_meta_group[]\" value=\"{$mGroup}\" placeholder=\"form_produccion_lotes\"></div>
<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">
<div><label>Nombre archivo</label><input type=\"text\" name=\"flow_sf_filename[]\" value=\"{$fname}\" placeholder=\"reporte.pdf\"></div>
<div><label>Mensaje al enviar</label><input type=\"text\" name=\"flow_sf_caption[]\" value=\"{$cap}\" placeholder=\"Aquí tienes el informe.\"></div>
</div>
</div>
<!-- collect_for_each -->
<div class=\"ftg-foreach\" style=\"{$showForEach}\">
<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">
<div><label>Endpoint lista (fincas)</label><select name=\"flow_fe_src_ep[]\">{$feEpSelHtml}</select></div>
<div><label>Endpoint upload (POST)</label><select name=\"flow_fe_up_ep[]\">{$feUpEpSelHtml}</select></div>
</div>
<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">
<div><label>Campo ID del ítem</label><input type=\"text\" name=\"flow_fe_val_field[]\" value=\"{$valF}\" placeholder=\"id\"></div>
<div><label>Campo nombre del ítem</label><input type=\"text\" name=\"flow_fe_lbl_field[]\" value=\"{$lblF}\" placeholder=\"nombre\"></div>
</div>
<div><label>Encabezado</label><input type=\"text\" name=\"flow_fe_header[]\" value=\"{$header}\" placeholder=\"🌧 Pluviometría\"></div>
<div><label>Pregunta por ítem</label><input type=\"text\" name=\"flow_fe_question[]\" value=\"{$feQuestion}\" placeholder=\"¿Cuál es el valor? (mm)\"></div>
<div><label>Mensaje de éxito</label><input type=\"text\" name=\"flow_fe_success[]\" value=\"{$feSuccessText}\" placeholder=\"✅ Datos registrados correctamente.\"></div>
</div>
<!-- collect_and_post -->
<div class=\"ftg-cap\" style=\"{$showCap}\">
<div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">
<div><label>Endpoint (POST)</label><select name=\"flow_cap_ep[]\">{$capEpSelHtml}</select></div>
<div><label>Encabezado</label><input type=\"text\" name=\"flow_cap_header[]\" value=\"{$capHeader}\" placeholder=\"📋 Registro ciclos\"></div>
</div>
<div style=\"display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center\">
<div><label>Mensaje de éxito</label><input type=\"text\" name=\"flow_cap_success[]\" value=\"{$capSucc}\" placeholder=\"✅ Registrado.\"></div>
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;margin-top:18px\">
<input type=\"checkbox\" name=\"flow_cap_confirm_{$flowIdx}\" value=\"1\"" . ($capConfirm?' checked':'') . "> Pedir confirmación
</label>
</div>
<input type=\"hidden\" name=\"flow_cap_confirm_idx[]\" value=\"{$flowIdx}\">
<div>
<label style=\"margin-bottom:6px;display:block\">Campos a pedir <span style=\"font-size:10px;color:#9ca3af\">(clave · etiqueta · pregunta)</span></label>
<div class=\"cap-fields-container\" id=\"cap-fields-{$flowIdx}\">{$capFieldsHtml}</div>
<button type=\"button\" onclick=\"addCapField('cap-fields-{$flowIdx}')\" style=\"margin-top:6px;font-size:12px;background:#f0f4ff;border:1px solid #c7d7ff;border-radius:4px;padding:4px 10px;cursor:pointer\">+ Campo</button>
</div>
</div>
</div>
</div>
</div>";
$flowIdx++;
}
echo <<<HTML
</div>
<button type="button" class="add-btn" onclick="addFlowCard()">+ Agregar flujo</button>
<div style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></div>
</div>
<!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── -->
<div class="tab-content" id="tab-categories">
<div class="card-h">👥 Configuración por Categoría</div>
<p style="font-size:12px;color:#7a8291;margin-bottom:16px">Define el comportamiento inicial de cada categoría de usuario: si pide finca, qué menú muestra y qué hacer ante texto no reconocido.</p>
<datalist id="ptFlowsDatalist">
HTML;
foreach ($menuKeysList as $mk) echo '<option value="' . self::h($mk) . '">📑 Menú</option>' . "\n";
foreach ($flowKeysList as $fk) echo '<option value="' . self::h($fk) . '">🔀 Flow</option>' . "\n";
echo <<<HTML
</datalist>
{$categoryTabHtml}
<button type="button" id="savePerTypeBtn" onclick="savePerType()" class="btn-primary" style="margin-top:8px">💾 Guardar categorías</button>
</div>
<!-- ─── AI ───────────────────────────────────────────────────────────── -->
<div class="tab-content" id="tab-ai">
<div class="card-h">🤖 Inteligencia Artificial</div>
<div class="form-group">
<label>Proveedor</label>
<select name="ai_provider" onchange="aiProviderChange(this.value)">
<option value="">Usar configuración global</option>
<option value="openai"{$aiProviderOpenai}>OpenAI (GPT)</option>
<option value="gemini"{$aiProviderGemini}>Google Gemini</option>
<option value="claude"{$aiProviderClaude}>Anthropic Claude</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>
<!-- OpenAI fields -->
<div id="ai-openai-fields" style="{$showOpenai}">
<div class="form-row">
<div class="form-group">
<label>API Key OpenAI</label>
<input type="password" name="openai_api_key" value="{$openaiApiKeyVal}" placeholder="sk-...">
</div>
<div class="form-group">
<label>Modelo</label>
<select name="ai_model">
<option value="gpt-4o-mini"{$aiModelMini}>GPT-4o Mini</option>
<option value="gpt-4.1-mini"{$aiModel41mini}>GPT-4.1 Mini</option>
<option value="gpt-4o"{$aiModel4o}>GPT-4o</option>
<option value="gpt-4.1"{$aiModel41}>GPT-4.1</option>
<option value="gpt-3.5-turbo"{$aiModel35}>GPT-3.5 Turbo</option>
</select>
</div>
</div>
</div>
<!-- Gemini fields -->
<div id="ai-gemini-fields" style="{$showGemini}">
<div class="form-row">
<div class="form-group">
<label>API Key Google Gemini</label>
<input type="password" name="gemini_api_key" value="{$geminiApiKeyVal}" placeholder="AIza...">
</div>
<div class="form-group">
<label>Modelo Gemini</label>
<select name="gemini_model">
<option value="gemini-2.5-flash"{$geminiModelF25}>Gemini 2.5 Flash</option>
<option value="gemini-2.0-flash"{$geminiModelF20}>Gemini 2.0 Flash</option>
<option value="gemini-1.5-flash"{$geminiModelF15}>Gemini 1.5 Flash</option>
<option value="gemini-1.5-pro"{$geminiModelP15}>Gemini 1.5 Pro</option>
</select>
</div>
</div>
</div>
<!-- Claude fields -->
<div id="ai-claude-fields" style="{$showClaude}">
<div class="form-row">
<div class="form-group">
<label>API Key Anthropic</label>
<input type="password" name="claude_api_key" value="{$claudeApiKeyVal}" placeholder="sk-ant-...">
</div>
<div class="form-group">
<label>Modelo Claude</label>
<select name="claude_model">
<option value="claude-haiku-4-5"{$claudeModelH}>Claude Haiku 4.5 (rápido)</option>
<option value="claude-sonnet-5"{$claudeModelS}>Claude Sonnet 5</option>
<option value="claude-opus-4-8"{$claudeModelO}>Claude Opus 4.8</option>
</select>
</div>
</div>
</div>
<!-- Whisper server (auto-hosted) -->
<div class="form-group" style="margin-top:12px;padding:12px;background:#f0f7f0;border:1px solid #c6e0c6;border-radius:8px">
<label style="font-weight:600">🎙️ Servidor Whisper propio (transcripción de audio)</label>
<div style="font-size:11px;color:#4a5568;margin-bottom:8px">Si se configura, el audio se transcribe aquí en vez del proveedor seleccionado arriba.</div>
<div class="form-group">
<label>URL del servidor</label>
<input type="text" name="whisper_url" value="{$whisperUrlVal}" placeholder="https://whisper.u-s.app/asr">
</div>
<div class="form-row">
<div class="form-group">
<label>Usuario</label>
<input type="text" name="whisper_user" value="{$whisperUserVal}" placeholder="whisper">
</div>
<div class="form-group">
<label>Contraseña</label>
<input type="password" name="whisper_pass" value="{$whisperPassVal}">
</div>
</div>
</div>
<div class="form-row" style="margin-top:4px">
<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 class="form-group">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
<input type="checkbox" name="ai_for_media" value="1" {$aiForMediaChecked}>
<span>Procesar imágenes y archivos con IA</span>
</label>
<div style="font-size:11px;color:#7a8291;margin-top:3px">
Cuando el usuario envía imagen, audio o documento, la IA analiza el contenido según los permisos del usuario.
Si está desactivado, el bot muestra el menú de categoría directamente.
</div>
</div>
<div class="form-group" style="margin-top:12px;padding:14px;background:#f0f4ff;border:1px solid #c7d7f7;border-radius:10px">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
<input type="checkbox" name="nlu" value="1" {$nluChecked}>
<span style="font-weight:700">🧠 Habilitar NLU — enrutamiento por IA</span>
</label>
<div style="font-size:11px;color:#4a5568;margin-top:6px;line-height:1.6">
Cuando el usuario escribe algo que el bot no reconoce, la IA lo ubica en el menú o flujo correcto según sus permisos.<br>
Audio → transcripción automática (Whisper/Gemini) → enrutado.<br>
Imagen → la IA extrae el contexto → enrutado.<br>
<strong>Requiere bot_type = hybrid y proveedor de IA configurado.</strong>
</div>
</div>
<div style="margin-top:14px"><button type="button" id="saveAiBtn" onclick="saveAiConfig()" class="btn-primary">💾 Guardar IA</button></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>Menú de bienvenida (welcome_menu)</label>
<select name="welcome_menu" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
<option value="">— Ninguno —</option>
{$welcomeMenuOpts}
</select>
<div style="font-size:11px;color:#7a8291;margin-top:3px">Menú que se muestra a TODOS los usuarios nuevos antes del menú principal</div>
</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 de texto que recibe un usuario nuevo (alternativo al welcome_menu)</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 style="margin-top:14px"><button type="submit" class="btn-primary">💾 Guardar</button></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>
const CONFIG = {$botConfigJson};
const ENDPOINTS = {$botEndpointsJson};
function savePerType() {
const cid = document.querySelector('input[name="company_id"]').value;
const fd = new FormData();
fd.set('company_id', cid);
[1, 2, 3].forEach(function(cat) {
var gf = document.querySelector('[name="per_type_greeting_flow[' + cat + ']"]');
var sel = document.querySelector('[name="per_type_menu[' + cat + ']"]');
var fb = document.querySelector('[name="per_type_fallback[' + cat + ']"]');
if (gf) fd.append('per_type_greeting_flow[' + cat + ']', gf.value);
if (sel) fd.append('per_type_menu[' + cat + ']', sel.value);
if (fb) fd.append('per_type_fallback[' + cat + ']', fb.value);
});
var btn = document.getElementById('savePerTypeBtn');
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
fetch('/admin/bot-config/save-per-type', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(d) {
if (btn) { btn.textContent = '💾 Guardar categorías'; btn.disabled = false; }
alert(d.ok ? '✅ Categorías guardadas correctamente.' : '❌ Error al guardar: ' + JSON.stringify(d));
})
.catch(function(e) {
if (btn) { btn.textContent = '💾 Guardar categorías'; btn.disabled = false; }
alert('❌ Error de red: ' + e.message);
});
}
function saveAiConfig() {
const cid = document.querySelector('input[name="company_id"]').value;
const fd = new FormData();
fd.set('company_id', cid);
const qs = sel => document.querySelector(sel);
fd.set('ai_provider', qs('select[name="ai_provider"]')?.value || '');
fd.set('ai_model', qs('select[name="ai_model"]')?.value || 'gpt-4o-mini');
fd.set('ai_temperature', qs('input[name="ai_temperature"]')?.value || '0.7');
fd.set('ai_prompt', qs('textarea[name="ai_prompt"]')?.value || '');
fd.set('gemini_model', qs('select[name="gemini_model"]')?.value || '');
fd.set('claude_model', qs('select[name="claude_model"]')?.value || '');
const oKey = qs('input[name="openai_api_key"]')?.value || '';
const gKey = qs('input[name="gemini_api_key"]')?.value || '';
const cKey = qs('input[name="claude_api_key"]')?.value || '';
if (oKey) fd.set('openai_api_key', oKey);
if (gKey) fd.set('gemini_api_key', gKey);
if (cKey) fd.set('claude_api_key', cKey);
fd.set('whisper_url', qs('input[name="whisper_url"]')?.value || '');
fd.set('whisper_user', qs('input[name="whisper_user"]')?.value || '');
const wPass = qs('input[name="whisper_pass"]')?.value || '';
if (wPass) fd.set('whisper_pass', wPass);
if (qs('input[name="ai_for_media"]')?.checked) fd.set('ai_for_media', '1');
if (qs('input[name="nlu"]')?.checked) fd.set('nlu', '1');
const btn = document.getElementById('saveAiBtn');
if (btn) { btn.textContent = 'Guardando...'; btn.disabled = true; }
fetch('/admin/bot-config/save-ai', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => {
if (btn) { btn.textContent = '💾 Guardar IA'; btn.disabled = false; }
if (d.ok) { btn.textContent = '✅ Guardado'; setTimeout(() => { btn.textContent = '💾 Guardar IA'; }, 2000); }
else alert('❌ Error al guardar: ' + JSON.stringify(d));
})
.catch(e => {
if (btn) { btn.textContent = '💾 Guardar IA'; btn.disabled = false; }
alert('❌ Error de red: ' + e.message);
});
}
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[]" list="actionsDatalist" 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 menuTypeChange(sel, mi) {
const isBtn = sel.value === 'button';
document.getElementById('btn-area-' + mi).style.display = isBtn ? '' : 'none';
document.getElementById('sections-area-' + mi).style.display = isBtn ? 'none' : '';
document.querySelectorAll('.list-fields-' + mi).forEach(el => el.style.display = isBtn ? 'none' : '');
}
function addBtnRow(mi) {
const container = document.getElementById('btn-rows-' + mi);
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_btn_id[' + mi + '][]" placeholder="ID (ej: ver_info)" style="flex:1;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px">' +
'<input type="text" name="menu_btn_title[' + mi + '][]" placeholder="Título (máx 20 chars)" style="flex:2;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);
}
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ú"></span>' +
'<button type="button" class="del" onclick="this.closest(\'.item-card\').remove()">✕ Eliminar</button></div>' +
'<div class="form-row">' +
'<div class="form-group" style="min-width:110px"><label>Tipo</label>' +
'<select name="menu_type[]" onchange="menuTypeChange(this,' + idx + ')">' +
'<option value="button" selected>Botones directos</option>' +
'<option value="list">Lista (con modal)</option>' +
'</select></div>' +
'<div class="form-group" style="flex:2"><label>Texto del mensaje</label>' +
'<input type="text" name="menu_body[]" placeholder="Selecciona una opción:"></div>' +
'<div class="form-group list-fields-' + idx + '" style="display:none"><label>Header</label><input type="text" name="menu_header[]" placeholder="Título"></div>' +
'<div class="form-group list-fields-' + idx + '" style="display:none"><label>Footer</label><input type="text" name="menu_footer[]" placeholder="Footer"></div>' +
'<div class="form-group list-fields-' + idx + '" style="display:none"><label>Botón abrir</label><input type="text" name="menu_button[]" placeholder="Ver opciones"></div>' +
'</div>' +
'<div id="btn-area-' + idx + '" style="margin-top:8px">' +
'<div style="font-size:12px;font-weight:600;color:#3d4552;margin-bottom:6px">Botones (máx 3):</div>' +
'<div id="btn-rows-' + idx + '"></div>' +
'<button type="button" class="btn-secondary" style="font-size:11px;padding:4px 10px;margin-top:4px" onclick="addBtnRow(' + idx + ')">+ Botón</button>' +
'</div>' +
'<div id="sections-area-' + idx + '" style="display:none;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 — mostrar/ocultar secciones según tipo seleccionado
function flowTabChange(sel) {
const card = sel.closest('.item-card');
const v = sel.value;
// flex sections need display:flex; block sections use '' (default block)
const sections = {
'ftg-text': {show: v==='text', flex: false},
'ftg-menu': {show: v==='menu', flex: false},
'ftg-fn': {show: v==='function', flex: false},
'ftg-collect': {show: v==='collect_input', flex: true},
'ftg-dynlist': {show: v==='dynamic_list', flex: true},
'ftg-submit': {show: v==='submit_form', flex: true},
'ftg-foreach': {show: v==='collect_for_each', flex: true},
'ftg-cap': {show: v==='collect_and_post', flex: true},
};
for (const [cls, cfg] of Object.entries(sections)) {
const el = card.querySelector('.' + cls);
if (el) el.style.display = cfg.show ? (cfg.flex ? 'flex' : '') : 'none';
}
if (v !== 'function') {
const ep = card.querySelector('.ftg-ep');
if (ep) ep.style.display = 'none';
}
}
function flowFnTabChange(sel) {
const card = sel.closest('.item-card');
const ep = card.querySelector('.ftg-ep');
if (ep) ep.style.display = sel.value === 'api_report' ? 'flex' : 'none';
}
function addFlowCard() {
const container = document.getElementById('flowContainer');
if (!container) return;
const menus = CONFIG?.menus || {};
let menuOpts = '<option value="">— Selecciona menú —</option>';
for (const k of Object.keys(menus)) menuOpts += '<option value="' + k + '">📑 ' + k + '</option>';
const eps = ENDPOINTS || {};
let epOpts = '<option value="">— Ninguno —</option>';
for (const [k, ep] of Object.entries(eps)) epOpts += '<option value="' + k + '">' + (ep.name || k) + '</option>';
const flows = CONFIG?.flows || {};
let nnOpts = '<option value="">— Ninguno —</option>';
for (const k of Object.keys(flows)) nnOpts += '<option value="' + k + '">' + k + '</option>';
const cardId = 'flow-card-' + Date.now();
container.insertAdjacentHTML('beforeend',
'<div class="item-card" id="' + cardId + '">' +
' <div class="item-head">' +
' <span>ID: <input type="text" name="flow_key[]" value="" style="border:none;background:transparent;font-weight:700;color:#0b3d91;width:180px;font-size:13px" placeholder="nuevo_flujo"></span>' +
' <span style="font-size:11px;color:#7a8291;font-weight:400;margin-left:8px">🧠 NLU:</span>' +
' <input type="text" name="flow_nlu_desc[]" value="" placeholder="Descripción para IA (ej: Registrar pluviometría)" style="border:none;background:#f0f4ff;border-radius:4px;padding:2px 8px;font-size:11px;color:#374151;flex:1;min-width:0">' +
' <button type="button" class="del" onclick="this.closest(\'.item-card\').remove()">✕</button>' +
' </div>' +
' <div class="form-row" style="align-items:flex-start;gap:10px">' +
' <div class="form-group" style="min-width:200px">' +
' <label>¿Qué hace?</label>' +
' <select name="flow_type[]" onchange="flowTabChange(this)">' +
' <optgroup label="Respuesta"><option value="text" selected>💬 Texto fijo</option></optgroup>' +
' <optgroup label="Navegar"><option value="menu">📑 Mostrar menú</option></optgroup>' +
' <optgroup label="Acción directa"><option value="function">⚡ Función especial</option></optgroup>' +
' <optgroup label="Formulario">' +
' <option value="collect_input">✏️ Pedir dato</option>' +
' <option value="dynamic_list">📋 Lista dinámica</option>' +
' <option value="collect_for_each">🌧 Registro por ítem</option>' +
' <option value="collect_and_post">📝 Pedir campos y enviar</option>' +
' <option value="submit_form">🚀 Enviar formulario</option>' +
' </optgroup>' +
' </select>' +
' </div>' +
' <div style="flex:1;display:flex;flex-direction:column;gap:10px">' +
' <div class="ftg-text"><textarea name="flow_message[]" rows="2" placeholder="Texto que recibirá el usuario"></textarea></div>' +
' <div class="ftg-menu" style="display:none"><label>Menú a mostrar</label><select name="flow_menu[]">' + menuOpts + '</select></div>' +
' <div class="ftg-fn" style="display:none">' +
' <label>Función</label>' +
' <select name="flow_function[]" onchange="flowFnTabChange(this)">' +
' <option value="goodbye">↩️ Salir (goodbye)</option>' +
' <option value="api_report">📄 Descargar informe</option>' +
' <option value="forward_to_ai">🤖 Escalar a IA</option>' +
' <option value="forward_to_agent">👤 Escalar a agente</option>' +
' </select>' +
' <div class="ftg-ep" style="display:none;flex-direction:column;gap:8px;margin-top:8px">' +
' <div><label>Endpoint</label><select name="flow_ep_key[]">' + epOpts + '</select></div>' +
' <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' +
' <div><label>Periodo</label><select name="flow_date_mode[]"><option value="">Sin fecha</option><option value="today">Hoy</option><option value="last_30">Últimos 30 días</option></select></div>' +
' <div><label>Nombre archivo</label><input type="text" name="flow_filename[]" placeholder="reporte.pdf"></div>' +
' </div>' +
' <div><label>Mensaje al enviar</label><input type="text" name="flow_caption[]" placeholder="Aquí tienes el informe."></div>' +
' </div>' +
' </div>' +
' <div class="ftg-collect" style="display:none;flex-direction:column;gap:8px">' +
' <div><label>Pregunta al usuario</label><input type="text" name="flow_prompt[]" placeholder="¿Desde qué fecha? (YYYY-MM-DD)"></div>' +
' <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' +
' <div><label>Grupo formulario</label><input type="text" name="flow_meta_group[]" placeholder="form_prod"></div>' +
' <div><label>Campo</label><input type="text" name="flow_meta_key[]" placeholder="desde"></div>' +
' </div>' +
' <div><label>Flujo siguiente</label><select name="flow_next_node[]">' + nnOpts + '</select></div>' +
' </div>' +
' <div class="ftg-dynlist" style="display:none;flex-direction:column;gap:8px">' +
' <div><label>Endpoint (lista)</label><select name="flow_dl_ep[]">' + epOpts + '</select></div>' +
' <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' +
' <div><label>Campo ID</label><input type="text" name="flow_val_field[]" placeholder="id"></div>' +
' <div><label>Campo etiqueta</label><input type="text" name="flow_lbl_field[]" placeholder="nombre"></div>' +
' </div>' +
' <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' +
' <div><label>Encabezado</label><input type="text" name="flow_header[]" placeholder="Selecciona..."></div>' +
' <div><label>Título sección</label><input type="text" name="flow_sec_title[]" placeholder="Opciones"></div>' +
' </div>' +
' <div><label>Cuerpo</label><input type="text" name="flow_body[]" placeholder="¿Cuál deseas?"></div>' +
' <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' +
' <div><label>Grupo formulario</label><input type="text" name="flow_dl_meta_group[]" placeholder="form_prod"></div>' +
' <div><label>Campo</label><input type="text" name="flow_dl_meta_key[]" placeholder="finca_id"></div>' +
' </div>' +
' <div><label>Flujo siguiente</label><select name="flow_dl_next[]">' + nnOpts + '</select></div>' +
' </div>' +
' <div class="ftg-submit" style="display:none;flex-direction:column;gap:8px">' +
' <div><label>Endpoint destino</label><select name="flow_sf_ep[]">' + epOpts + '</select></div>' +
' <div><label>Grupo formulario</label><input type="text" name="flow_sf_meta_group[]" placeholder="form_prod"></div>' +
' <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' +
' <div><label>Nombre archivo</label><input type="text" name="flow_sf_filename[]" placeholder="reporte.pdf"></div>' +
' <div><label>Mensaje</label><input type="text" name="flow_sf_caption[]" placeholder="Aquí tienes el informe."></div>' +
' </div>' +
' </div>' +
' <div class="ftg-foreach" style="display:none;flex-direction:column;gap:8px">' +
' <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' +
' <div><label>Endpoint lista</label><select name="flow_fe_src_ep[]">' + epOpts + '</select></div>' +
' <div><label>Endpoint upload (POST)</label><select name="flow_fe_up_ep[]">' + epOpts + '</select></div>' +
' </div>' +
' <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' +
' <div><label>Campo ID</label><input type="text" name="flow_fe_val_field[]" placeholder="id"></div>' +
' <div><label>Campo nombre</label><input type="text" name="flow_fe_lbl_field[]" placeholder="nombre"></div>' +
' </div>' +
' <div><label>Encabezado</label><input type="text" name="flow_fe_header[]" placeholder="🌧 Pluviometría"></div>' +
' <div><label>Pregunta por ítem</label><input type="text" name="flow_fe_question[]" placeholder="¿Cuál es el valor? (mm)"></div>' +
' <div><label>Mensaje de éxito</label><input type="text" name="flow_fe_success[]" placeholder="✅ Datos registrados."></div>' +
' <div class="ftg-cap" style="display:none;flex-direction:column;gap:8px">' +
' <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' +
' <div><label>Endpoint (POST)</label><select name="flow_cap_ep[]">' + epOpts + '</select></div>' +
' <div><label>Encabezado</label><input type="text" name="flow_cap_header[]" placeholder="📋 Registro ciclos"></div>' +
' </div>' +
' <div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center">' +
' <div><label>Mensaje de éxito</label><input type="text" name="flow_cap_success[]" placeholder="✅ Registrado."></div>' +
' <label style="display:flex;align-items:center;gap:6px;font-size:12px;margin-top:18px"><input type="checkbox" name="flow_cap_confirm_new" value="1" checked> Confirmar</label>' +
' </div>' +
' <input type="hidden" name="flow_cap_confirm_idx[]" value="new">' +
' <div>' +
' <label style="margin-bottom:6px;display:block">Campos <span style="font-size:10px;color:#9ca3af">(clave · etiqueta · pregunta)</span></label>' +
' <div class="cap-fields-container" id="cap-fields-new-' + cardId + '"></div>' +
' <button type="button" onclick="addCapField(\'cap-fields-new-' + cardId + '\')" style="margin-top:6px;font-size:12px;background:#f0f4ff;border:1px solid #c7d7ff;border-radius:4px;padding:4px 10px;cursor:pointer">+ Campo</button>' +
' </div>' +
' </div>' +
' </div>' +
' </div>' +
' </div>' +
'</div>'
);
container.lastElementChild.scrollIntoView({behavior:'smooth'});
}
function addCapField(containerId) {
const c = document.getElementById(containerId);
if (!c) return;
const row = document.createElement('div');
row.className = 'cap-field-row';
row.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 2fr auto;gap:6px;align-items:center;margin-bottom:4px';
row.innerHTML =
'<input type="text" name="cap_field_key[]" placeholder="clave" style="font-family:monospace">' +
'<input type="text" name="cap_field_label[]" placeholder="etiqueta">' +
'<input type="text" name="cap_field_prompt[]" placeholder="Pregunta al usuario">' +
'<button type="button" onclick="this.closest(\'.cap-field-row\').remove()" style="background:#fee2e2;border:none;border-radius:4px;padding:4px 8px;cursor:pointer">✕</button>';
c.appendChild(row);
}
function aiProviderChange(v) {
const panels = {openai: 'ai-openai-fields', gemini: 'ai-gemini-fields', claude: 'ai-claude-fields'};
Object.entries(panels).forEach(([key, id]) => {
const el = document.getElementById(id);
if (!el) return;
const show = (key === 'openai' && (v === 'openai' || v === '')) || v === key;
el.style.display = show ? 'flex' : 'none';
el.style.flexDirection = 'column';
el.style.gap = '10px';
});
}
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);
$endpointsJson = json_encode(array_column($companyEndpoints, null, 'endpoint_key'), 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};
const ENDPOINTS = {$endpointsJson};
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 isBtn = (menu.type || 'button') === 'button';
const buttonsHtml = (menu.buttons || []).map((b, bi) => \`
<div style="display:flex;gap:6px;margin-bottom:4px" class="edit-btn-row">
<input type="text" class="ebtn-id-\${bi}" value="\${b.id}" placeholder="ID (ej: ver_info)" style="flex:1;padding:5px 8px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px;font-family:monospace">
<input type="text" class="ebtn-title-\${bi}" value="\${b.title}" placeholder="Título (máx 20)" style="flex:2;padding:5px 8px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px">
<button class="btn-cancel" style="padding:4px 8px" onclick="this.closest('.edit-btn-row').remove()">✕</button>
</div>\`).join('');
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:</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-row" style="margin-bottom:10px">
<div class="form-group" style="min-width:140px"><label>Tipo</label>
<select id="editMenuType" onchange="editMenuTypeToggle()">
<option value="button"\${isBtn?' selected':''}>Botones directos</option>
<option value="list"\${!isBtn?' selected':''}>Lista (con modal)</option>
</select>
</div>
<div class="form-group" style="flex:2"><label>ID del menú</label><input type="text" id="editMenuKey" value="\${key}"></div>
<div class="form-group" style="flex:3"><label>Texto del mensaje</label><input type="text" id="editMenuBody" value="\${menu.body||''}"></div>
</div>
<div id="editListFields" style="\${isBtn?'display:none':''}">
<div class="form-row" style="margin-bottom:10px">
<div class="form-group"><label>Header</label><input type="text" id="editMenuHeader" value="\${menu.header||''}"></div>
<div class="form-group"><label>Footer</label><input type="text" id="editMenuFooter" value="\${menu.footer||''}"></div>
<div class="form-group"><label>Botón abrir</label><input type="text" id="editMenuButton" value="\${menu.button||'Ver opciones'}"></div>
</div>
</div>
<div id="editBtnArea" style="\${isBtn?'':'display:none'}">
<div style="font-size:12px;font-weight:600;color:#3d4552;margin-bottom:6px">Botones (máx 3):</div>
<div id="editBtnRows">\${buttonsHtml}</div>
<button class="btn-sec" style="font-size:11px;padding:4px 10px;margin-top:4px" onclick="addEditBtnRow()">+ Botón</button>
</div>
<div id="editSectionsArea" style="\${isBtn?'display:none':''}">
<div style="font-size:12px;font-weight:600;color:#3d4552;margin:8px 0 6px">Secciones:</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>
<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 editMenuTypeToggle() {
const isBtn = document.getElementById('editMenuType').value === 'button';
document.getElementById('editBtnArea').style.display = isBtn ? '' : 'none';
document.getElementById('editSectionsArea').style.display = isBtn ? 'none' : '';
document.getElementById('editListFields').style.display = isBtn ? 'none' : '';
}
function addEditBtnRow() {
const cont = document.getElementById('editBtnRows');
const bi = cont.querySelectorAll('.edit-btn-row').length;
const div = document.createElement('div');
div.className = 'edit-btn-row';
div.style.cssText = 'display:flex;gap:6px;margin-bottom:4px';
div.innerHTML = \`
<input type="text" class="ebtn-id-\${bi}" placeholder="ID (ej: ver_info)" style="flex:1;padding:5px 8px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px;font-family:monospace">
<input type="text" class="ebtn-title-\${bi}" placeholder="Título (máx 20)" style="flex:2;padding:5px 8px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px">
<button class="btn-cancel" style="padding:4px 8px" onclick="this.closest('.edit-btn-row').remove()">✕</button>\`;
cont.appendChild(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 menuType = document.getElementById('editMenuType').value;
const body = document.getElementById('editMenuBody').value;
let menu;
if (menuType === 'button') {
const buttons = [];
document.getElementById('editBtnRows').querySelectorAll('.edit-btn-row').forEach((row, bi) => {
const idEl = row.querySelector('.ebtn-id-' + bi) || row.querySelector('input[placeholder*="ID"]');
const titleEl = row.querySelector('.ebtn-title-' + bi) || row.querySelector('input[placeholder*="Título"]');
const id = idEl?.value?.trim();
const title = titleEl?.value?.trim();
if (id && title) buttons.push({ id, title: title.substring(0, 20) });
});
menu = { type: 'button', body, buttons };
} else {
const header = document.getElementById('editMenuHeader')?.value || '';
const footer = document.getElementById('editMenuFooter')?.value || '';
const button = document.getElementById('editMenuButton')?.value || 'Ver opciones';
menu = { type: 'list', header, body, footer, button, 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 });
}
} // end else list
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 buildFlowModal(key, flow) {
const isNew = !key;
const menuOpts = Object.keys(CONFIG.menus).map(m =>
'<option value="' + m + '"' + (flow.menu === m ? ' selected' : '') + '>📑 ' + m + '</option>'
).join('');
const allTargets = [
...Object.keys(CONFIG.menus).map(m => '<option value="' + m + '"' + (flow.menu === m ? ' selected' : '') + '>📑 Menú: ' + m + '</option>'),
].join('');
const epOpts = Object.keys(ENDPOINTS).map(k =>
'<option value="' + k + '"' + (((flow.params||{}).endpoint_key) === k ? ' selected' : '') +
'>' + k + '</option>'
).join('');
// Dynamic-list source endpoint
const dlEpOpts = Object.keys(ENDPOINTS).map(k =>
'<option value="' + k + '"' + ((flow.source_endpoint_key) === k ? ' selected' : '') +
'>' + k + '</option>'
).join('');
// Next-node options (other flows)
const flowOpts = Object.keys(CONFIG.flows).filter(f => f !== key).map(f =>
'<option value="' + f + '">' + f + '</option>'
).join('');
const t = flow.type || 'text';
const fn = flow.function || '';
const dateMode = (flow.params||{}).date_mode || '';
const caption = (flow.params||{}).caption || '';
const filename = (flow.params||{}).filename || '';
const metaGroup = flow.meta_group || '';
const metaKey = flow.meta_key || '';
const nextNode = flow.next_node || '';
const prompt = flow.prompt || '';
const header = flow.header || '';
const body = flow.body || '';
const secTitle = flow.section_title || '';
const valField = flow.value_field || 'id';
const lblField = flow.label_field || 'nombre';
const showFn = t === 'function';
const showEp = t === 'function' && fn === 'api_report';
const showCollect = t === 'collect_input';
const showDynList = t === 'dynamic_list';
const showSubmit = t === 'submit_form';
return \`
<h2>\${isNew ? ' Nuevo Flujo' : '🔀 Editar Flujo: ' + key}</h2>
<div class="form-group">
<label>ID del flujo</label>
<input type="text" id="editFlowKey" value="\${key}" placeholder="ej: ver_cosecha">
</div>
<div class="form-group">
<label>¿Qué hace este flujo?</label>
<select id="editFlowType" onchange="flowTypeToggle()">
<optgroup label="Respuesta">
<option value="text" \${t==='text' ?'selected':''}>💬 Texto fijo</option>
</optgroup>
<optgroup label="Navegar">
<option value="menu" \${t==='menu' ?'selected':''}>📑 Mostrar menú</option>
</optgroup>
<optgroup label="Acción directa">
<option value="function" \${t==='function' ?'selected':''}>⚡ Función especial</option>
</optgroup>
<optgroup label="Formulario paso a paso">
<option value="collect_input"\${t==='collect_input'?'selected':''}>✏️ Pedir dato al usuario</option>
<option value="dynamic_list" \${t==='dynamic_list' ?'selected':''}>📋 Lista dinámica (desde endpoint)</option>
<option value="submit_form" \${t==='submit_form' ?'selected':''}>🚀 Enviar formulario a endpoint</option>
</optgroup>
</select>
</div>
<!-- texto fijo -->
<div id="fgText" style="\${t==='text'?'':'display:none'}">
<div class="form-group"><label>Mensaje</label>
<textarea id="editFlowMessage" rows="3" placeholder="Texto que recibirá el usuario">\${flow.message||''}</textarea>
</div>
</div>
<!-- menú -->
<div id="fgMenu" style="\${t==='menu'?'':'display:none'}">
<div class="form-group"><label>Menú a mostrar</label>
<select id="editFlowMenu"><option value="">— Selecciona menú —</option>\${menuOpts}</select>
</div>
</div>
<!-- función especial -->
<div id="fgFunc" style="\${showFn?'':'display:none'}">
<div class="form-group"><label>Función</label>
<select id="editFlowFunction" onchange="flowFuncToggle()">
<option value="goodbye" \${fn==='goodbye' ?'selected':''}>↩️ Salir / Goodbye</option>
<option value="api_report" \${fn==='api_report' ?'selected':''}>📄 Descargar informe (api_report)</option>
<option value="forward_to_ai" \${fn==='forward_to_ai' ?'selected':''}>🤖 Escalar a IA</option>
<option value="forward_to_agent"\${fn==='forward_to_agent'?'selected':''}>👤 Escalar a agente</option>
</select>
</div>
<div id="fgEp" style="\${showEp?'':'display:none'}">
<div class="form-group"><label>Endpoint</label>
<select id="editFlowEpKey">
<option value="">— Selecciona —</option>\${epOpts}
</select>
</div>
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div><label>Periodo de fecha</label>
<select id="editFlowDateMode">
<option value="" \${dateMode==='' ?'selected':''}>Sin fecha</option>
<option value="today" \${dateMode==='today' ?'selected':''}>Hoy</option>
<option value="last_30"\${dateMode==='last_30'?'selected':''}>Últimos 30 días</option>
</select>
</div>
<div><label>Nombre archivo</label>
<input type="text" id="editFlowFilename" value="\${filename}" placeholder="reporte.pdf">
</div>
</div>
<div class="form-group"><label>Mensaje al enviar</label>
<input type="text" id="editFlowCaption" value="\${caption}" placeholder="Aquí tienes el informe.">
</div>
</div>
</div>
<!-- collect_input -->
<div id="fgCollect" style="\${showCollect?'':'display:none'}">
<div class="form-group"><label>Pregunta al usuario</label>
<input type="text" id="editFlowPrompt" value="\${prompt}" placeholder="¿Desde qué fecha? (YYYY-MM-DD)">
</div>
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div><label>Guardar en grupo</label>
<input type="text" id="editFlowMetaGroup" value="\${metaGroup}" placeholder="form_produccion_lotes">
</div>
<div><label>Guardar en campo</label>
<input type="text" id="editFlowMetaKey" value="\${metaKey}" placeholder="desde">
</div>
</div>
<div class="form-group"><label>Flujo siguiente</label>
<select id="editFlowNextNode">
<option value="">— Ninguno (terminar) —</option>\${flowOpts}
</select>
</div>
</div>
<!-- dynamic_list -->
<div id="fgDynList" style="\${showDynList?'':'display:none'}">
<div class="form-group"><label>Endpoint que devuelve la lista</label>
<select id="editFlowDlEp">
<option value="">— Selecciona endpoint —</option>\${dlEpOpts}
</select>
<div style="font-size:11px;color:#7a8291;margin-top:3px">Debe devolver un array JSON: [{id, nombre}, ...]</div>
</div>
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div><label>Campo de valor (ID)</label>
<input type="text" id="editFlowValField" value="\${valField}" placeholder="id">
</div>
<div><label>Campo de etiqueta</label>
<input type="text" id="editFlowLblField" value="\${lblField}" placeholder="nombre">
</div>
</div>
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div><label>Encabezado lista</label>
<input type="text" id="editFlowHeader" value="\${header}" placeholder="Selecciona la finca">
</div>
<div><label>Título sección</label>
<input type="text" id="editFlowSecTitle" value="\${secTitle}" placeholder="Fincas">
</div>
</div>
<div class="form-group"><label>Cuerpo del mensaje</label>
<input type="text" id="editFlowBody" value="\${body}" placeholder="¿De qué finca necesitas el informe?">
</div>
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div><label>Guardar en grupo</label>
<input type="text" id="editFlowDlMetaGroup" value="\${metaGroup}" placeholder="form_produccion_finca">
</div>
<div><label>Guardar en campo</label>
<input type="text" id="editFlowDlMetaKey" value="\${metaKey}" placeholder="finca_id">
</div>
</div>
<div class="form-group"><label>Flujo siguiente</label>
<select id="editFlowDlNext">
<option value="">— Ninguno —</option>\${flowOpts}
</select>
</div>
</div>
<!-- submit_form -->
<div id="fgSubmit" style="\${showSubmit?'':'display:none'}">
<div class="form-group"><label>Endpoint destino</label>
<select id="editFlowSubmitEp">
<option value="">— Selecciona endpoint —</option>\${epOpts}
</select>
</div>
<div class="form-group"><label>Grupo del formulario (debe coincidir con collect_input / dynamic_list)</label>
<input type="text" id="editFlowSubmitGroup" value="\${metaGroup}" placeholder="form_produccion_lotes">
</div>
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div><label>Nombre archivo</label>
<input type="text" id="editFlowSubmitFilename" value="\${filename}" placeholder="reporte.pdf">
</div>
<div><label>Mensaje al enviar</label>
<input type="text" id="editFlowSubmitCaption" value="\${caption}" placeholder="Aquí tienes el informe.">
</div>
</div>
</div>
<div class="btn-row">
\${isNew ? '' : '<button class="btn-del-modal" onclick="deleteFlow(\'' + key + '\')">Eliminar</button>'}
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
<button class="btn-save" onclick="saveFlow('\${key}')">Guardar</button>
</div>\`;
}
function editFlow(key) {
const flow = CONFIG.flows[key];
if (!flow) return;
document.getElementById('modalContent').innerHTML = buildFlowModal(key, flow);
document.getElementById('modalOverlay').classList.add('show');
// Restore next-node selection for collect_input
const nn = flow.next_node || '';
const nnSel = document.getElementById('editFlowNextNode');
if (nnSel) { for (const o of nnSel.options) if (o.value === nn) { o.selected = true; break; } }
const dnSel = document.getElementById('editFlowDlNext');
if (dnSel) { for (const o of dnSel.options) if (o.value === flow.next_node||'') { o.selected = true; break; } }
}
function addNewFlow() {
document.getElementById('modalContent').innerHTML = buildFlowModal('', {type:'text'});
document.getElementById('modalOverlay').classList.add('show');
}
function flowTypeToggle() {
const t = document.getElementById('editFlowType').value;
document.getElementById('fgText').style.display = t === 'text' ? '' : 'none';
document.getElementById('fgMenu').style.display = t === 'menu' ? '' : 'none';
document.getElementById('fgFunc').style.display = t === 'function' ? '' : 'none';
document.getElementById('fgCollect').style.display = t === 'collect_input' ? '' : 'none';
document.getElementById('fgDynList').style.display = t === 'dynamic_list' ? '' : 'none';
document.getElementById('fgSubmit').style.display = t === 'submit_form' ? '' : 'none';
if (t !== 'function') document.getElementById('fgEp').style.display = 'none';
}
function flowFuncToggle() {
const fn = document.getElementById('editFlowFunction').value;
document.getElementById('fgEp').style.display = fn === 'api_report' ? '' : 'none';
}
function saveFlow(oldKey) {
const newKey = document.getElementById('editFlowKey').value.trim();
if (!newKey) { alert('El ID del flujo es requerido'); return; }
const type = document.getElementById('editFlowType').value;
const flow = { type };
if (type === 'text') {
flow.message = document.getElementById('editFlowMessage').value.trim();
} else if (type === 'menu') {
flow.menu = document.getElementById('editFlowMenu').value;
} else if (type === 'function') {
flow.function = document.getElementById('editFlowFunction').value;
if (flow.function === 'api_report') {
flow.params = {
endpoint_key: document.getElementById('editFlowEpKey').value,
date_mode: document.getElementById('editFlowDateMode').value,
caption: document.getElementById('editFlowCaption').value.trim(),
filename: document.getElementById('editFlowFilename').value.trim(),
};
}
} else if (type === 'collect_input') {
flow.prompt = document.getElementById('editFlowPrompt').value.trim();
flow.meta_group = document.getElementById('editFlowMetaGroup').value.trim();
flow.meta_key = document.getElementById('editFlowMetaKey').value.trim();
flow.next_node = document.getElementById('editFlowNextNode').value || null;
} else if (type === 'dynamic_list') {
flow.source_endpoint_key = document.getElementById('editFlowDlEp').value;
flow.value_field = document.getElementById('editFlowValField').value.trim() || 'id';
flow.label_field = document.getElementById('editFlowLblField').value.trim() || 'nombre';
flow.header = document.getElementById('editFlowHeader').value.trim();
flow.body = document.getElementById('editFlowBody').value.trim();
flow.section_title = document.getElementById('editFlowSecTitle').value.trim();
flow.meta_group = document.getElementById('editFlowDlMetaGroup').value.trim();
flow.meta_key = document.getElementById('editFlowDlMetaKey').value.trim();
flow.next_node = document.getElementById('editFlowDlNext').value || null;
} else if (type === 'submit_form') {
flow.endpoint_key = document.getElementById('editFlowSubmitEp').value;
flow.meta_group = document.getElementById('editFlowSubmitGroup').value.trim();
flow.filename = document.getElementById('editFlowSubmitFilename').value.trim();
flow.caption = document.getElementById('editFlowSubmitCaption').value.trim();
}
if (oldKey && oldKey !== newKey) delete CONFIG.flows[oldKey];
CONFIG.flows[newKey] = flow;
// Sync menu row IDs if key changed
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();
}
// 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-row" style="margin-bottom:10px">
<div class="form-group" style="min-width:140px"><label>Tipo</label>
<select id="newMenuType" onchange="newMenuTypeToggle()">
<option value="button" selected>Botones directos</option>
<option value="list">Lista (con modal)</option>
</select>
</div>
<div class="form-group" style="flex:2"><label>ID del menú</label><input type="text" id="newMenuKey" placeholder="ej: show_main, sub_servicios"></div>
<div class="form-group" style="flex:3"><label>Texto del mensaje</label><input type="text" id="newMenuBody" placeholder="Selecciona una opción:"></div>
</div>
<div id="newMenuBtnArea">
<div style="font-size:12px;font-weight:600;color:#3d4552;margin-bottom:6px">Botones (máx 3):</div>
<div id="newMenuBtnRows"></div>
<button class="btn-sec" style="font-size:11px;padding:4px 10px;margin-top:4px" onclick="addNewMenuBtnRow()">+ Botón</button>
</div>
<div id="newMenuListArea" style="display:none">
<div class="form-row" style="margin-bottom:8px">
<div class="form-group"><label>Header</label><input type="text" id="newMenuHeader" placeholder="Título"></div>
<div class="form-group"><label>Footer</label><input type="text" id="newMenuFooter" placeholder="Footer opcional"></div>
<div class="form-group"><label>Botón abrir</label><input type="text" id="newMenuButton" value="Ver opciones"></div>
</div>
</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 newMenuTypeToggle() {
const isBtn = document.getElementById('newMenuType').value === 'button';
document.getElementById('newMenuBtnArea').style.display = isBtn ? '' : 'none';
document.getElementById('newMenuListArea').style.display = isBtn ? 'none' : '';
}
function addNewMenuBtnRow() {
const cont = document.getElementById('newMenuBtnRows');
const bi = cont.querySelectorAll('.new-btn-row').length;
const div = document.createElement('div');
div.className = 'new-btn-row';
div.style.cssText = 'display:flex;gap:6px;margin-bottom:4px';
div.innerHTML = \`<input type="text" class="nbtn-id-\${bi}" placeholder="ID (ej: ver_info)" style="flex:1;padding:5px 8px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px;font-family:monospace">
<input type="text" class="nbtn-title-\${bi}" placeholder="Título (máx 20 chars)" style="flex:2;padding:5px 8px;border:1px solid #e2e5ea;border-radius:6px;font-size:12px">
<button class="btn-cancel" style="padding:4px 8px" onclick="this.closest('.new-btn-row').remove()">✕</button>\`;
cont.appendChild(div);
}
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; }
const menuType = document.getElementById('newMenuType').value;
const body = document.getElementById('newMenuBody').value.trim();
if (menuType === 'button') {
const buttons = [];
document.getElementById('newMenuBtnRows').querySelectorAll('.new-btn-row').forEach((row, bi) => {
const id = (row.querySelector('.nbtn-id-' + bi) || row.querySelector('input:first-child'))?.value?.trim();
const title = (row.querySelector('.nbtn-title-' + bi) || row.querySelector('input:nth-child(2)'))?.value?.trim();
if (id && title) buttons.push({ id, title: title.substring(0, 20) });
});
CONFIG.menus[key] = { type: 'button', body, buttons };
} else {
CONFIG.menus[key] = {
type: 'list',
header: document.getElementById('newMenuHeader')?.value?.trim() || '',
body,
footer: document.getElementById('newMenuFooter')?.value?.trim() || '',
button: document.getElementById('newMenuButton')?.value?.trim() || 'Ver opciones',
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]) => {
const mtype = menu.type || 'button';
fd.append('menu_key[]', mk);
fd.append('menu_type[]', mtype);
fd.append('menu_body[]', menu.body || '');
if (mtype === 'button') {
(menu.buttons || []).forEach((btn, bi) => {
fd.append('menu_btn_id[' + mi + '][]', btn.id || '');
fd.append('menu_btn_title[' + mi + '][]', btn.title || '');
});
} else {
fd.append('menu_header[]', menu.header || '');
fd.append('menu_footer[]', menu.footer || '');
fd.append('menu_button[]', menu.button || 'Ver opciones');
(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 || '');
// AI fields — leer del DOM porque el usuario puede editarlos sin tocar el CONFIG JS
const _qs = sel => document.querySelector(sel);
fd.set('ai_provider', _qs('select[name="ai_provider"]')?.value || fullConfig.ai_provider || '');
fd.set('ai_prompt', _qs('textarea[name="ai_prompt"]')?.value || fullConfig.ai_prompt || '');
fd.set('ai_model', _qs('select[name="ai_model"]')?.value || fullConfig.ai_model || 'gpt-4o-mini');
fd.set('ai_temperature', _qs('input[name="ai_temperature"]')?.value || String(fullConfig.ai_temperature || 0.7));
fd.set('openai_api_key', _qs('input[name="openai_api_key"]')?.value || '');
fd.set('gemini_api_key', _qs('input[name="gemini_api_key"]')?.value || '');
fd.set('gemini_model', _qs('select[name="gemini_model"]')?.value || '');
fd.set('claude_api_key', _qs('input[name="claude_api_key"]')?.value || '');
fd.set('claude_model', _qs('select[name="claude_model"]')?.value || '');
if (_qs('input[name="ai_for_media"]')?.checked) fd.set('ai_for_media', '1');
if (_qs('input[name="nlu"]')?.checked) fd.set('nlu', '1');
fd.set('approval_webhook', fullConfig.approval_webhook || '');
fd.set('ignore_prefixes', (fullConfig.ignore_prefixes || []).join('\\n'));
// Per-category menus — read from DOM so they're preserved on every AJAX save
[1, 2, 3].forEach(cat => {
const sel = document.querySelector('select[name="per_type_menu[' + cat + ']"]');
if (sel) fd.append('per_type_menu[' + cat + ']', sel.value);
});
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 item) ──
$linkedFlows = [];
foreach ($menus as $mk => $menu) {
if (($menu['type'] ?? 'button') === 'button') {
foreach ($menu['buttons'] ?? [] as $btn) {
if (isset($flows[$btn['id']])) $linkedFlows[$btn['id']] = true;
}
} else {
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
{
$isButton = ($menu['type'] ?? 'list') === 'button';
$typeLabel = $isButton ? 'Botones' : 'Lista';
$typeBadge = $isButton ? 'badge-button' : 'badge-list';
// Unifica items: botones directos o filas de secciones
$items = [];
if ($isButton) {
foreach ($menu['buttons'] ?? [] as $btn) {
$items[] = ['id' => $btn['id'], 'title' => $btn['title'], 'description' => ''];
}
} else {
foreach ($menu['sections'] ?? [] as $section) {
foreach ($section['rows'] ?? [] as $row) {
$items[] = ['id' => $row['id'], 'title' => $row['title'], 'description' => $row['description'] ?? ''];
}
}
}
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 (!empty($menu['header'])) echo '<div class="n-info"><span>📌 ' . self::h($menu['header']) . '</span></div>';
if (!empty($menu['body'])) echo '<div class="n-sub">' . self::h($menu['body']) . '</div>';
echo '<div class="actions"><button class="edit-btn" onclick="editMenu(\'' . self::h($menuKey) . '\')">✎ Editar</button></div>';
if (!empty($items)) {
echo '<div class="row-list">';
foreach ($items as $item) {
$flow = $flows[$item['id']] ?? null;
$flowLabel = $flow
? match ($flow['type']) {
'text' => '📝 Respuesta',
'function' => '⚡ ' . ($flow['function'] ?? ''),
'menu' => '📑 → ' . ($flow['menu'] ?? ''),
default => '?',
}
: '⚠️ Sin flujo';
echo '<div class="row-item" data-flow-target="' . self::h($item['id']) . '" onmouseenter="highlightRow(this)" onmouseleave="unhighlightRow()" onclick="editFlow(\'' . self::h($item['id']) . '\')">';
echo '<span class="r-icon">' . ($isButton ? '🔲' : '🔘') . '</span>';
echo '<span class="r-title">' . self::h($item['title']) . '</span>';
if ($item['description']) echo '<span style="font-size:10px;color:#9ca3af;flex-shrink:0">' . self::h($item['description']) . '</span>';
echo '<span class="r-id">' . self::h($item['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
// Mostrar primer submenú en árbol
foreach ($items as $item) {
$flow = $flows[$item['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($item['title']) . ' →</div>';
self::renderFlowNode($item['id'], $flow, $allMenus, $companyId, true);
echo '</div>';
break;
}
}
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>';
// Mostrar items del sub-menú (buttons o sections[].rows)
$subIsBtn = ($subMenu['type'] ?? 'list') === 'button';
$subItems = $subIsBtn
? ($subMenu['buttons'] ?? [])
: array_merge(...array_map(fn($s) => $s['rows'] ?? [], $subMenu['sections'] ?? []));
if (!empty($subItems)) {
echo '<div class="row-list">';
foreach ($subItems as $item) {
echo '<div class="row-item" style="cursor:default">';
echo '<span class="r-icon">' . ($subIsBtn ? '🔲' : '🔘') . '</span>';
echo '<span class="r-title">' . self::h($item['title']) . '</span>';
echo '<span class="r-id">' . self::h($item['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;
}
public static function aiLogs(): void
{
SessionAuth::require();
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = 50;
$offset = ($page - 1) * $limit;
$provider = $_GET['provider'] ?? '';
$company = (int)($_GET['company'] ?? 0);
$where = [];
$params = [];
if ($provider !== '') { $where[] = 'provider = ?'; $params[] = $provider; }
if ($company > 0) { $where[] = 'company_id = ?'; $params[] = $company; }
$wSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$stmt = db()->prepare("SELECT COUNT(*) FROM ai_logs $wSql");
$stmt->execute($params);
$total = (int)$stmt->fetchColumn();
$stmt2 = db()->prepare("SELECT * FROM ai_logs $wSql ORDER BY id DESC LIMIT $limit OFFSET $offset");
$stmt2->execute($params);
$rows = $stmt2->fetchAll(\PDO::FETCH_ASSOC);
$companies = db()->query("SELECT id, name FROM companies ORDER BY name")->fetchAll(\PDO::FETCH_ASSOC);
$companyMap = [];
foreach ($companies as $c) $companyMap[(int)$c['id']] = self::h($c['name']);
$pages = (int)ceil($total / $limit);
$companyOptions = '<option value="">Todas las empresas</option>';
foreach ($companies as $c) {
$sel = ($company === (int)$c['id']) ? ' selected' : '';
$companyOptions .= '<option value="' . (int)$c['id'] . '"' . $sel . '>' . self::h($c['name']) . '</option>';
}
$providerOptions = '';
foreach (['', 'openai', 'gemini', 'claude', 'whisper-own'] as $p) {
$label = $p === '' ? 'Todos' : ucfirst($p);
$sel = $provider === $p ? ' selected' : '';
$providerOptions .= "<option value=\"{$p}\"{$sel}>{$label}</option>";
}
$rows_html = '';
foreach ($rows as $r) {
$cid = (int)($r['company_id'] ?? 0);
$cname = $companyMap[$cid] ?? '<span style="color:#aaa">—</span>';
$badge = match($r['provider'] ?? '') {
'openai' => 'background:#10a37f;color:#fff',
'gemini' => 'background:#4285f4;color:#fff',
'claude' => 'background:#d97706;color:#fff',
'whisper-own' => 'background:#7c3aed;color:#fff',
default => 'background:#6b7280;color:#fff',
};
$tok = ($r['tokens_in'] ?? null) !== null
? self::h($r['tokens_in']) . '+' . self::h($r['tokens_out'])
: '—';
$rows_html .= '<tr>'
. '<td style="color:#9ca3af;font-size:11px">' . self::h(substr($r['created_at'] ?? '', 0, 16)) . '</td>'
. '<td>' . $cname . '</td>'
. '<td>' . self::h($r['phone_number'] ?? '') . '</td>'
. '<td><span style="' . $badge . ';border-radius:4px;padding:2px 7px;font-size:11px">' . self::h($r['provider']) . '</span></td>'
. '<td style="font-size:11px">' . self::h($r['model'] ?? '') . '</td>'
. '<td><span style="background:#f3f4f6;border-radius:4px;padding:2px 6px;font-size:11px">' . self::h($r['call_type'] ?? '') . '</span></td>'
. '<td style="font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' . self::h($r['input_preview'] ?? '') . '">' . self::h(mb_substr($r['input_preview'] ?? '', 0, 60)) . '</td>'
. '<td style="font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' . self::h($r['output_preview'] ?? '') . '">' . self::h(mb_substr($r['output_preview'] ?? '', 0, 60)) . '</td>'
. '<td style="text-align:right;font-size:11px">' . $tok . '</td>'
. '<td style="text-align:right;font-size:11px">' . number_format((int)($r['duration_ms'] ?? 0)) . 'ms</td>'
. '</tr>';
}
$pager = '';
for ($i = 1; $i <= $pages; $i++) {
$active = $i === $page ? ' style="font-weight:bold"' : '';
$url = '/admin/ai-logs?page=' . $i . ($provider ? '&provider=' . urlencode($provider) : '') . ($company ? '&company=' . $company : '');
$pager .= "<a href=\"{$url}\"{$active} class=\"btn-sm\">{$i}</a> ";
}
$user = SessionAuth::user();
echo Layout::open('IA Logs', 'ai-logs', $user['name'] ?? 'Admin');
?>
<style>
.filter-bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:16px}
.filter-bar select{padding:5px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:13px}
.log-table{width:100%;border-collapse:collapse;font-size:13px}
.log-table th{background:#f9fafb;padding:8px 10px;text-align:left;border-bottom:2px solid #e5e7eb;font-size:11px;text-transform:uppercase;color:#6b7280;white-space:nowrap}
.log-table td{padding:7px 10px;border-bottom:1px solid #f3f4f6;vertical-align:middle}
.log-table tr:hover td{background:#fafafa}
@media(prefers-color-scheme:dark){.log-table th{background:#1f2937;border-color:#374151;color:#9ca3af}.log-table td{border-color:#1f2937}.log-table tr:hover td{background:#111827}}
</style>
<div class="card" style="padding:20px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">🤖 IA Logs <span style="font-size:14px;color:#9ca3af;font-weight:400">(<?= number_format($total) ?> registros)</span></h2>
</div>
<form class="filter-bar" method="get">
<select name="provider" onchange="this.form.submit()"><?= $providerOptions ?></select>
<select name="company" onchange="this.form.submit()"><?= $companyOptions ?></select>
</form>
<div style="overflow-x:auto">
<table class="log-table">
<thead><tr>
<th>Fecha</th><th>Empresa</th><th>Teléfono</th><th>Proveedor</th><th>Modelo</th>
<th>Tipo</th><th>Entrada</th><th>Salida</th><th>Tokens</th><th>Duración</th>
</tr></thead>
<tbody><?= $rows_html ?: '<tr><td colspan="10" style="text-align:center;color:#9ca3af;padding:30px">Sin registros</td></tr>' ?></tbody>
</table>
</div>
<?php if ($pages > 1): ?><div style="margin-top:12px"><?= $pager ?></div><?php endif; ?>
</div>
<?php
echo Layout::close();
}
}