- Pass ENDPOINTS to JS context from company_endpoints table - Add api_report and goodbye options to flow function select - When api_report selected: show endpoint dropdown + date period + filename + caption fields - saveFlow() persists params.endpoint_key, date_mode, caption, filename - flowFuncToggle() shows/hides the endpoint panel on function change Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4127 lines
212 KiB
PHP
4127 lines
212 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
class DashboardController
|
||
{
|
||
private const PER_PAGE = 25;
|
||
|
||
// ─── GET /admin/dashboard ─────────────────────────────────────────────────
|
||
|
||
public static function index(): void
|
||
{
|
||
SessionAuth::require();
|
||
|
||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||
$filter = trim($_GET['type'] ?? '');
|
||
$search = trim($_GET['search'] ?? '');
|
||
$date = trim($_GET['date'] ?? date('Y-m-d'));
|
||
$companyId = isset($_GET['company_id']) ? (int)$_GET['company_id'] : null;
|
||
$user = SessionAuth::user();
|
||
|
||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||
$date = date('Y-m-d');
|
||
}
|
||
|
||
try {
|
||
$db = db();
|
||
$stats = self::getStats($db, $companyId);
|
||
$pending = PendingApproval::countPending($companyId);
|
||
$companies = CompanyRepository::findAll();
|
||
[$logs, $total] = self::getLogs($db, $page, $filter, $search, $date, $companyId);
|
||
} catch (\PDOException $e) {
|
||
$stats = ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0];
|
||
$pending = 0;
|
||
$companies = [];
|
||
$logs = [];
|
||
$total = 0;
|
||
}
|
||
|
||
$pages = $total > 0 ? (int)ceil($total / self::PER_PAGE) : 1;
|
||
$companyCount = count($companies);
|
||
self::render(compact('stats', 'pending', 'companies', 'companyCount', 'companyId', 'logs', 'total', 'page', 'pages', 'filter', 'search', 'date', 'user'));
|
||
}
|
||
|
||
// ─── GET /admin/live ────────────────────────────────────────────────────
|
||
|
||
public static function live(): void
|
||
{
|
||
SessionAuth::require();
|
||
$user = SessionAuth::user();
|
||
$userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8');
|
||
|
||
http_response_code(200);
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo 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()">▮▮ 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>📦 Payload JSON</span><button class="mx" onclick="closeModal()">✕</button></div>
|
||
<div class="mb"><pre id="mpre">Cargando...</pre></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const ICONS = {text:'💬',image:'📷',audio:'🎵',video:'🎬',document:'📄',sticker:'🌞',reaction:'👍',location:'📍',interactive:'🔘',button:'🔲',status:'📊',raw:'🔌'};
|
||
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 ? '▶ Reanudar' : '▮▮ 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] || '📡';
|
||
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 => ({'&':'&','<':'<','>':'>','"':'"'})[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 '💬 Texto';
|
||
case 'image': return '📷 Imagen';
|
||
case 'audio': return '🎵 Audio';
|
||
case 'video': return '🎬 Video';
|
||
case 'document': return '📄 Documento';
|
||
case 'location': return '📍 Ubicación';
|
||
case 'interactive': return '🔘 Interactivo';
|
||
case 'button': return '🔲 Botón';
|
||
default: return self::h($type);
|
||
}
|
||
}
|
||
|
||
// ─── Render ───────────────────────────────────────────────────────────────
|
||
|
||
private static function render(array $v): void
|
||
{
|
||
http_response_code(200);
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
|
||
// Variables para el heredoc
|
||
$userName = self::h($v['user']['name'] ?? 'Admin');
|
||
$dateVal = self::h($v['date']);
|
||
$maxDate = date('Y-m-d');
|
||
$filterVal = self::h($v['filter']);
|
||
$searchVal = self::h($v['search']);
|
||
$totalStr = number_format((int)$v['total']);
|
||
$selMessages = $v['filter'] === 'messages' ? 'selected' : '';
|
||
$selStatuses = $v['filter'] === 'statuses' ? 'selected' : '';
|
||
$sTotal = (int)$v['stats']['total'];
|
||
$sMsgs = (int)$v['stats']['msgs'];
|
||
$sMedia = (int)$v['stats']['media'];
|
||
$sStatuses = (int)$v['stats']['statuses'];
|
||
$sPending = (int)($v['pending'] ?? 0);
|
||
$companyCount = (int)($v['companyCount'] ?? 0);
|
||
|
||
// Opciones del filtro de empresa
|
||
$companyOptions = '<option value="">Todas las empresas</option>';
|
||
foreach ($v['companies'] ?? [] as $c) {
|
||
$sel = (int)($v['companyId'] ?? 0) === (int)$c['id'] ? 'selected' : '';
|
||
$companyOptions .= "<option value=\"{$c['id']}\" {$sel}>" . self::h($c['name'] ?? $c['display_name'] ?? '') . '</option>';
|
||
}
|
||
$showCompany = empty($v['companyId']);
|
||
$companyTh = $showCompany ? '<th>Empresa</th>' : '';
|
||
|
||
// 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">✕</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()">✕</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">➕ Agregar Empresa</a>
|
||
</div>
|
||
<div class="card">
|
||
HTML;
|
||
if (empty($companies)) {
|
||
echo '<div class="empty">No hay empresas registradas. <a href="/admin/sync-companies">Sincronizar desde el ERP</a></div>';
|
||
} else {
|
||
echo '<table><thead><tr>
|
||
<th>ID</th><th>Nombre</th><th>WhatsApp Phone ID</th><th>Teléfono</th><th>Bot Type</th><th>Aprueba</th><th>Activo</th><th>API URL</th><th>Acciones</th>
|
||
</tr></thead><tbody>';
|
||
foreach ($companies as $c) {
|
||
$id = (int)$c['id'];
|
||
$name = self::h($c['name'] ?? '');
|
||
$dname = self::h($c['display_name'] ?? '');
|
||
$pid = self::h((string)($c['phone_number_id'] ?? ''));
|
||
$phone = self::h($c['display_phone'] ?? '');
|
||
$bot = $c['bot_type'] ?? 'normal';
|
||
$apr = !empty($c['requires_approval']);
|
||
$act = !empty($c['is_active']);
|
||
$url = self::h($c['api_base_url'] ?? '');
|
||
$btag = match($bot) { 'hybrid' => 'hybrid', 'ai' => 'ai', default => 'normal' };
|
||
$eName = self::h($c['name'] ?? '');
|
||
echo "<tr>
|
||
<td><code>{$id}</code></td>
|
||
<td><strong>{$name}</strong><br><small style='color:#888'>{$dname}</small></td>
|
||
<td><code>{$pid}</code></td>
|
||
<td>{$phone}</td>
|
||
<td><span class=\"tag tag-{$btag}\">{$bot}</span></td>
|
||
<td><span class=\"tag " . ($apr ? 'tag-yes' : 'tag-no') . '">' . ($apr ? 'Sí' : 'No') . "</span></td>
|
||
<td><span class=\"tag " . ($act ? 'tag-on' : 'tag-off') . '">' . ($act ? 'Sí' : 'No') . "</span></td>
|
||
<td style='max-width:200px;overflow:hidden;text-overflow:ellipsis'><code>{$url}</code></td>
|
||
<td style='white-space:nowrap'>
|
||
<a href=\"/admin/company/edit?id={$id}\" class=\"btn-sm\">✎ Editar</a>
|
||
<a href=\"/admin/company/delete?id={$id}\" class=\"btn-danger\" onclick=\"return confirm('¿Eliminar empresa {$eName}?')\">🗑 Eliminar</a>
|
||
</td>
|
||
</tr>";
|
||
}
|
||
echo '</tbody></table>';
|
||
}
|
||
echo <<<HTML
|
||
</div>
|
||
<div class="count-row">Total: {$companyCount} empresa(s)</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
HTML;
|
||
exit;
|
||
}
|
||
|
||
// ─── GET /admin/company/edit ─────────────────────────────────────────────
|
||
|
||
// ── 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;
|
||
|
||
$cv = fn(string $k, string $d = '') => self::h($company[$k] ?? $d);
|
||
$name = $cv('name');
|
||
$display_name = $cv('display_name');
|
||
$api_base_url = $cv('api_base_url');
|
||
$api_key = $cv('api_key');
|
||
$config_json = $cv('config_json');
|
||
$bot_type = $company['bot_type'] ?? 'normal';
|
||
$requires_approval = !empty($company['requires_approval']);
|
||
$is_active = !empty($company['is_active']);
|
||
$erp_active_users = (int)($company['erp_active_users'] ?? 0);
|
||
$reqAprChecked = $requires_approval ? 'checked' : '';
|
||
$isActChecked = $is_active ? 'checked' : '';
|
||
|
||
$botOptions = '';
|
||
foreach (['normal' => 'Normal', 'ai' => 'AI', 'hybrid' => 'Híbrido'] as $val => $lbl) {
|
||
$sel = $bot_type === $val ? 'selected' : '';
|
||
$botOptions .= "<option value=\"{$val}\" {$sel}>{$lbl}</option>\n";
|
||
}
|
||
|
||
$pageTitle = $isEdit ? 'Editar Empresa' : 'Nueva Empresa';
|
||
echo Layout::open($pageTitle, 'companies', $user['name'] ?? 'Admin');
|
||
|
||
// ── Tab: Números WhatsApp ─────────────────────────────────────────────
|
||
$phonesHtml = '';
|
||
$epHtml = '';
|
||
if ($isEdit) {
|
||
// Phones
|
||
$phones = db()->prepare("SELECT * FROM company_phones WHERE company_id=? ORDER BY permission_type, wa_number");
|
||
$phones->execute([$id]);
|
||
$phones = $phones->fetchAll(\PDO::FETCH_ASSOC);
|
||
|
||
$limitType1 = max(1, $erp_active_users * 10);
|
||
$limitType23 = max(1, $erp_active_users);
|
||
$countT1 = count(array_filter($phones, fn($p) => (int)$p['permission_type'] === 1));
|
||
$countT23 = count(array_filter($phones, fn($p) => (int)$p['permission_type'] !== 1));
|
||
|
||
$pTypeName = ['1' => 'Solo reporta', '2' => 'Solo recibe', '3' => 'Reporta y recibe'];
|
||
$pTypeCls = ['1' => 'badge-blue', '2' => 'badge-green', '3' => 'badge-teal'];
|
||
$rows = '';
|
||
foreach ($phones as $ph) {
|
||
$t = (string)$ph['permission_type'];
|
||
$tc = $pTypeCls[$t] ?? 'badge-gray';
|
||
$tn = $pTypeName[$t] ?? '?';
|
||
$lbl = self::h($ph['label'] ?? '');
|
||
$num = self::h($ph['wa_number']);
|
||
$act = $ph['is_active'] ? '<span class="badge badge-green">Activo</span>' : '<span class="badge badge-gray">Inactivo</span>';
|
||
$rows .= "<tr>
|
||
<td style='font-family:monospace;font-size:13px'>+{$num}</td>
|
||
<td>{$lbl}</td>
|
||
<td><span class='badge {$tc}'>{$tn}</span></td>
|
||
<td>{$act}</td>
|
||
<td><button class='btn-danger-sm' onclick='deletePhone({$ph['id']},this)'>Eliminar</button></td>
|
||
</tr>";
|
||
}
|
||
if (!$rows) $rows = '<tr><td colspan="5" class="empty">Sin números registrados</td></tr>';
|
||
|
||
$warnERP = $erp_active_users === 0
|
||
? '<div class="toast toast-error" style="margin-bottom:12px">⚠ Usuarios activos ERP = 0. Configura el campo en la pestaña General o ejecuta sincronización.</div>'
|
||
: '';
|
||
|
||
$pTypeOpts = '';
|
||
foreach ($pTypeName as $v => $n) $pTypeOpts .= "<option value='{$v}'>{$n}</option>";
|
||
|
||
$phonesHtml = <<<HTML
|
||
{$warnERP}
|
||
<div style="display:flex;align-items:center;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
|
||
$epRows = db()->prepare("SELECT endpoint_key, url, method, last_response, last_called_at FROM company_endpoints WHERE company_id=?");
|
||
$epRows->execute([$id]);
|
||
$epMap = [];
|
||
foreach ($epRows->fetchAll(\PDO::FETCH_ASSOC) as $r) $epMap[$r['endpoint_key']] = $r;
|
||
|
||
$catalog = self::endpointCatalog();
|
||
$renderEpSection = function(string $dir, string $dirLabel, string $badge) use ($catalog, $epMap, $id): string {
|
||
$html = "<h3 style='font-size:13px;font-weight:600;color:#111827;margin:0 0 12px'>{$badge} {$dirLabel}</h3>";
|
||
foreach ($catalog[$dir] as $key => $label) {
|
||
$saved = $epMap[$key] ?? [];
|
||
$url = self::h($saved['url'] ?? '');
|
||
$meth = $saved['method'] ?? 'GET';
|
||
$lastAt = $saved['last_called_at'] ? '<span style="font-size:11px;color:#9ca3af">' . self::h($saved['last_called_at']) . '</span>' : '';
|
||
$mSel = fn($v) => $meth === $v ? 'selected' : '';
|
||
$html .= <<<EP
|
||
<div style="border:1px solid #e5e7eb;border-radius:8px;padding:14px;margin-bottom:10px">
|
||
<div style="font-size:13px;font-weight:500;color:#111827;margin-bottom:8px">{$label} {$lastAt}</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
|
||
<select id="m_{$key}" style="width:90px;padding:7px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px">
|
||
<option {$mSel('GET')}>GET</option><option {$mSel('POST')}>POST</option>
|
||
</select>
|
||
<input type="text" id="u_{$key}" value="{$url}" placeholder="https://..." style="flex:1;min-width:200px;padding:7px 10px;border:1px solid #e5e7eb;border-radius:6px;font-size:13px">
|
||
<button class="btn-secondary" onclick="saveEp({$id},'{$key}','{$dir}')">Guardar</button>
|
||
<button class="btn-sm" onclick="testEp({$id},'{$key}')">Probar</button>
|
||
</div>
|
||
<div id="ep_resp_{$key}" style="margin-top:8px;display:none"></div>
|
||
</div>
|
||
EP;
|
||
}
|
||
return $html;
|
||
};
|
||
|
||
$epHtml = $renderEpSection('upload', 'Subir información (WhatsApp → ERP)', '<span class="badge badge-blue">↑ Upload</span>')
|
||
. '<div style="margin:20px 0;border-top:1px solid #e5e7eb"></div>'
|
||
. $renderEpSection('download', 'Bajar información (ERP → WhatsApp)', '<span class="badge badge-green">↓ Download</span>');
|
||
}
|
||
|
||
$tabsHtml = $isEdit ? <<<HTML
|
||
<div style="display:flex;gap:0;border-bottom:1px solid #e5e7eb;margin-bottom:20px">
|
||
<button class="tab-btn" id="tb-general" onclick="showTab('general')">General</button>
|
||
<button class="tab-btn" id="tb-phones" onclick="showTab('phones')">Números WhatsApp</button>
|
||
<button class="tab-btn" id="tb-endpoints" onclick="showTab('endpoints')">Endpoints API</button>
|
||
</div>
|
||
HTML : '';
|
||
|
||
echo <<<HTML
|
||
<style>
|
||
.tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:10px 18px;font-size:13px;font-weight:500;color:#6b7280;cursor:pointer;transition:color .12s,border-color .12s;margin-bottom:-1px}
|
||
.tab-btn.active{color:#111827;border-bottom-color:#111827}
|
||
.tab-panel{display:none}.tab-panel.active{display:block}
|
||
</style>
|
||
<div class="wrap" style="max-width:860px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
|
||
<h1 style="font-size:18px;font-weight:700;color:#111827">{$pageTitle}</h1>
|
||
<a href="/admin/companies" class="btn-secondary" style="font-size:12px">← Empresas</a>
|
||
</div>
|
||
{$tabsHtml}
|
||
|
||
<!-- Tab General -->
|
||
<div id="panel-general" class="tab-panel">
|
||
<div class="card">
|
||
<div class="card-b">
|
||
<form method="POST" action="/admin/company/save">
|
||
HTML;
|
||
if ($isEdit) echo '<input type="hidden" name="id" value="' . $id . '">';
|
||
echo <<<HTML
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Nombre *</label>
|
||
<input type="text" name="name" value="{$name}" required placeholder="Comercializadora ABC">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Nombre mostrado</label>
|
||
<input type="text" name="display_name" value="{$display_name}" placeholder="ABC S.A.">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>API Base URL *</label>
|
||
<input type="text" name="api_base_url" value="{$api_base_url}" required placeholder="https://erp.empresa.com/api/">
|
||
<div class="hint">Base URL del ERP de esta empresa</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>API Key</label>
|
||
<input type="text" name="api_key" value="{$api_key}" placeholder="Token Bearer">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Usuarios activos en ERP</label>
|
||
<input type="number" name="erp_active_users" value="{$erp_active_users}" min="0" placeholder="0">
|
||
<div class="hint">Define los límites de números WA. Se actualiza con sincronización ERP.</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Tipo de Bot</label>
|
||
<select name="bot_type">{$botOptions}</select>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:20px;margin-bottom:14px;flex-wrap:wrap">
|
||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
||
<input type="checkbox" name="requires_approval" value="1" {$reqAprChecked}> Requiere aprobación manual
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
||
<input type="checkbox" name="is_active" value="1" {$isActChecked}> Activa
|
||
</label>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Configuración JSON adicional</label>
|
||
<textarea name="config_json" rows="5" style="font-family:monospace;font-size:12px">{$config_json}</textarea>
|
||
</div>
|
||
<div style="display:flex;gap:10px;padding-top:16px;border-top:1px solid #e5e7eb">
|
||
<button type="submit" class="btn-primary">Guardar</button>
|
||
<a href="/admin/companies" class="btn-secondary">Cancelar</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
HTML;
|
||
|
||
if ($isEdit) {
|
||
echo <<<HTML
|
||
<!-- Tab Números WhatsApp -->
|
||
<div id="panel-phones" class="tab-panel">
|
||
{$phonesHtml}
|
||
</div>
|
||
|
||
<!-- Tab Endpoints API -->
|
||
<div id="panel-endpoints" class="tab-panel">
|
||
{$epHtml}
|
||
</div>
|
||
|
||
<script>
|
||
function showTab(name) {
|
||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.getElementById('panel-' + name).classList.add('active');
|
||
document.getElementById('tb-' + name).classList.add('active');
|
||
history.replaceState(null,'',location.pathname+'?id={$id}&tab='+name);
|
||
}
|
||
const initTab = new URLSearchParams(location.search).get('tab') || 'general';
|
||
showTab(initTab);
|
||
|
||
async function 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; }
|
||
}
|
||
|
||
async function saveEp(cid, key, dir) {
|
||
const url = document.getElementById('u_'+key).value.trim();
|
||
const meth = document.getElementById('m_'+key).value;
|
||
const fd=new FormData();
|
||
fd.append('company_id',cid); fd.append('endpoint_key',key);
|
||
fd.append('direction',dir); fd.append('url',url); fd.append('method',meth);
|
||
const r=await fetch('/admin/company/endpoint/save',{method:'POST',body:fd});
|
||
const j=await r.json();
|
||
const box=document.getElementById('ep_resp_'+key);
|
||
box.style.display='';
|
||
box.innerHTML=j.ok ? '<span style="color:#166534;font-size:12px">✓ Guardado</span>' : '<span style="color:#991b1b;font-size:12px">'+j.error+'</span>';
|
||
setTimeout(()=>box.style.display='none', 2000);
|
||
}
|
||
|
||
async function testEp(cid, key) {
|
||
const url=document.getElementById('u_'+key).value.trim();
|
||
const meth=document.getElementById('m_'+key).value;
|
||
const box=document.getElementById('ep_resp_'+key);
|
||
box.style.display=''; box.innerHTML='<span style="color:#6b7280;font-size:12px">Probando...</span>';
|
||
const fd=new FormData();
|
||
fd.append('company_id',cid); fd.append('endpoint_key',key);
|
||
fd.append('url',url); fd.append('method',meth);
|
||
const r=await fetch('/admin/company/endpoint/test',{method:'POST',body:fd});
|
||
const j=await r.json();
|
||
if (j.ok) {
|
||
const preview=JSON.stringify(j.response,null,2).substring(0,800);
|
||
box.innerHTML='<pre style="font-size:11px;max-height:200px;overflow-y:auto;margin:0">'+preview+'</pre>';
|
||
} else {
|
||
box.innerHTML='<div class="toast toast-error" style="margin:0;font-size:12px">'+j.error+'</div>';
|
||
}
|
||
}
|
||
</script>
|
||
HTML;
|
||
}
|
||
|
||
echo '</div>';
|
||
echo Layout::close();
|
||
}
|
||
|
||
// ─── POST /admin/company/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
|
||
$apiKey = $company['api_key'] ?? '';
|
||
$ch = curl_init($ep['url']);
|
||
$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);
|
||
$key = preg_replace('/[^a-z0-9_]/', '', $_POST['endpoint_key'] ?? '');
|
||
$direction = in_array($_POST['direction'] ?? '', ['upload','download']) ? $_POST['direction'] : null;
|
||
$url = trim($_POST['url'] ?? '');
|
||
$method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET';
|
||
|
||
if ($companyId <= 0 || $key === '' || $direction === null) {
|
||
echo json_encode(['ok' => false, 'error' => 'Datos inválidos']);
|
||
exit;
|
||
}
|
||
|
||
try {
|
||
db()->prepare("INSERT INTO company_endpoints (company_id, endpoint_key, direction, url, method) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE url=VALUES(url), method=VALUES(method)")
|
||
->execute([$companyId, $key, $direction, $url, $method]);
|
||
echo json_encode(['ok' => true]);
|
||
} catch (\PDOException $e) {
|
||
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// ─── POST /admin/company/endpoint/test ───────────────────────────────────
|
||
|
||
public static function companyEndpointTest(): void
|
||
{
|
||
SessionAuth::require();
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
|
||
$companyId = (int)($_POST['company_id'] ?? 0);
|
||
$key = preg_replace('/[^a-z0-9_]/', '', $_POST['endpoint_key'] ?? '');
|
||
$url = trim($_POST['url'] ?? '');
|
||
$method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET';
|
||
|
||
if ($url === '') { echo json_encode(['ok' => false, 'error' => 'URL vacía']); exit; }
|
||
|
||
$company = CompanyRepository::findById($companyId);
|
||
$apiKey = $company['api_key'] ?? '';
|
||
|
||
$ch = curl_init($url);
|
||
$opts = [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 10,
|
||
CURLOPT_SSL_VERIFYPEER => false,
|
||
CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json'],
|
||
];
|
||
if ($apiKey !== '') $opts[CURLOPT_HTTPHEADER][] = 'Authorization: Bearer ' . $apiKey;
|
||
if ($method === 'POST') { $opts[CURLOPT_POST] = true; $opts[CURLOPT_POSTFIELDS] = '{}'; }
|
||
curl_setopt_array($ch, $opts);
|
||
$resp = curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$err = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($err) { echo json_encode(['ok' => false, 'error' => 'cURL: ' . $err]); exit; }
|
||
|
||
$decoded = json_decode($resp, true);
|
||
$preview = is_array($decoded) ? $decoded : (string)$resp;
|
||
|
||
// Guardar última respuesta
|
||
if ($key !== '') {
|
||
db()->prepare("UPDATE company_endpoints SET last_response=?, last_called_at=NOW() WHERE company_id=? AND endpoint_key=?")
|
||
->execute([is_string($resp) ? substr($resp, 0, 2000) : '', $companyId, $key]);
|
||
}
|
||
|
||
if ($httpCode >= 200 && $httpCode < 300) {
|
||
echo json_encode(['ok' => true, 'http_code' => $httpCode, 'response' => $preview]);
|
||
} else {
|
||
echo json_encode(['ok' => false, 'error' => "HTTP {$httpCode}", 'response' => $preview]);
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// ─── GET /admin/sync-companies ──────────────────────────────────────────
|
||
|
||
public static function syncCompanies(): void
|
||
{
|
||
SessionAuth::require();
|
||
$user = SessionAuth::user();
|
||
$userName = self::h($user['name'] ?? 'Admin');
|
||
$result = ErpSync::sync();
|
||
$hasError = isset($result['error']);
|
||
$icon = $hasError ? '❌' : '✅';
|
||
$title = $hasError ? 'Error en Sincronización' : 'Sincronización Exitosa';
|
||
$titleClass = $hasError ? 'error' : 'success';
|
||
$resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||
|
||
http_response_code(200);
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo 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 ? '⚠' : '✅';
|
||
$title = $hasError ? 'Cola Procesada con Advertencias' : 'Cola Procesada Exitosamente';
|
||
$titleClass = $hasError ? 'error' : 'success';
|
||
$resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||
|
||
http_response_code(200);
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo Layout::open('Procesar Cola de Mensajes', 'dashboard', $user['name'] ?? 'Admin');
|
||
echo <<<HTML
|
||
<style>
|
||
.wrap{padding:24px;max-width:800px;margin:0 auto}
|
||
.btn-row{text-align:center;margin-top:24px}
|
||
</style>
|
||
<div class="wrap">
|
||
<div class="result-card">
|
||
<div class="result-icon">{$icon}</div>
|
||
<div class="result-title {$titleClass}">{$title}</div>
|
||
<pre>{$resultJson}</pre>
|
||
<div class="btn-row">
|
||
<a href="/admin/dashboard" class="btn-primary">Volver a dashboard</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
HTML;
|
||
exit;
|
||
}
|
||
|
||
// ─── GET /admin/settings ────────────────────────────────────────────────
|
||
|
||
public static function settings(): void
|
||
{
|
||
SessionAuth::require();
|
||
$user = SessionAuth::user();
|
||
$userName = self::h($user['name'] ?? 'Admin');
|
||
$settings = Settings::all();
|
||
$msg = $_GET['msg'] ?? '';
|
||
$toastHtml = '';
|
||
if ($msg === 'saved') {
|
||
$toastHtml = '<div class="toast toast-success">Configuración guardada exitosamente.</div>';
|
||
}
|
||
|
||
$fields = [
|
||
'WhatsApp Cloud API' => [
|
||
['key' => 'whatsapp_access_token', 'label' => 'Access Token', 'type' => 'password', 'placeholder' => 'EAAMzB...'],
|
||
['key' => 'whatsapp_app_secret', 'label' => 'App Secret', 'type' => 'password', 'placeholder' => 'a4f82c1d...'],
|
||
['key' => 'whatsapp_verify_token', 'label' => 'Verify Token', 'type' => 'text', 'placeholder' => 'PLM360_WH_...'],
|
||
['key' => 'whatsapp_business_account_id', 'label' => 'Business Account ID', 'type' => 'text', 'placeholder' => '920641783052817'],
|
||
['key' => 'whatsapp_default_phone_number_id', 'label' => 'Phone Number ID (default)', 'type' => 'text', 'placeholder' => '384710295638401'],
|
||
['key' => 'verify_hmac_signature', 'label' => 'Validar firma HMAC de Meta', 'type' => 'checkbox', 'placeholder' => ''],
|
||
],
|
||
'Inteligencia Artificial' => [
|
||
['key' => 'ai_provider', 'label' => 'Proveedor', 'type' => 'select', 'options' => ['mock' => 'Mock (simulado)', 'openai' => 'OpenAI', 'gemini' => 'Google Gemini']],
|
||
['key' => 'openai_api_key', 'label' => 'OpenAI API Key', 'type' => 'password', 'placeholder' => 'sk-...'],
|
||
['key' => 'openai_model', 'label' => 'Modelo OpenAI', 'type' => 'select', 'options' => ['gpt-4o-mini' => 'GPT-4o Mini', 'gpt-4o' => 'GPT-4o', 'gpt-3.5-turbo' => 'GPT-3.5 Turbo']],
|
||
['key' => 'gemini_api_key', 'label' => 'Google Gemini API Key', 'type' => 'password', 'placeholder' => 'AIza...'],
|
||
['key' => 'gemini_model', 'label' => 'Modelo Gemini', 'type' => 'select', 'options' => ['gemini-2.0-flash' => 'Gemini 2.0 Flash', 'gemini-1.5-flash' => 'Gemini 1.5 Flash', 'gemini-1.5-pro' => 'Gemini 1.5 Pro']],
|
||
['key' => 'ai_max_tokens', 'label' => 'Máximo de tokens', 'type' => 'number', 'placeholder' => '500'],
|
||
['key' => 'ai_default_prompt', 'label' => 'System Prompt por defecto', 'type' => 'textarea', 'placeholder' => 'Eres un asistente...'],
|
||
],
|
||
];
|
||
|
||
$versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 8);
|
||
|
||
http_response_code(200);
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo Layout::open('Configuración', 'settings', $user['name'] ?? 'Admin');
|
||
echo <<<HTML
|
||
<style>
|
||
.wrap{padding:24px;max-width:900px;margin:0 auto}
|
||
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||
.fw{grid-column:1/-1}
|
||
.lbl{display:block;font-size:12px;font-weight:600;color:#555;margin-bottom:4px}
|
||
.inp{width:100%;padding:9px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;outline:none;transition:border .2s}
|
||
.inp:focus{border-color:#1565c0}
|
||
.inp-sel{padding:9px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;outline:none;background:#fff;cursor:pointer}
|
||
textarea.inp{min-height:90px;resize:vertical;font-family:inherit}
|
||
.btn-row{display:flex;justify-content:flex-end;gap:10px;margin-top:18px}
|
||
.chk-lbl{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500;color:#333;cursor:pointer;padding:8px 0}
|
||
.chk-lbl input[type=checkbox]{width:18px;height:18px;accent-color:#0b3d91}
|
||
.version-info{margin-top:16px;padding:12px;background:#f8f9fb;border-radius:8px;font-size:12px;color:#888;text-align:center}
|
||
small{color:#888;font-size:11px;display:block;margin-top:2px}
|
||
@media(max-width:640px){.form-grid{grid-template-columns:1fr}.wrap{padding:12px}}
|
||
</style>
|
||
<div class="wrap">
|
||
{$toastHtml}
|
||
<form method="POST" action="/admin/settings/save">
|
||
HTML;
|
||
foreach ($fields as $sectionTitle => $sectionFields) {
|
||
echo '<div class="card">';
|
||
echo '<div class="card-h">' . self::h($sectionTitle) . '</div>';
|
||
echo '<div class="card-b"><div class="form-grid">';
|
||
foreach ($sectionFields as $f) {
|
||
$key = $f['key'];
|
||
$val = self::h($settings[$key] ?? '');
|
||
$label = self::h($f['label']);
|
||
$placeholder = self::h($f['placeholder'] ?? '');
|
||
$full = ($f['type'] === 'textarea') ? ' fw' : '';
|
||
echo "<div class=\"{$full}\">";
|
||
echo "<label class=\"lbl\" for=\"{$key}\">{$label}</label>";
|
||
if ($f['type'] === 'select' && !empty($f['options'])) {
|
||
echo "<select class=\"inp-sel\" id=\"{$key}\" name=\"{$key}\" style=\"width:100%\">";
|
||
foreach ($f['options'] as $optVal => $optLabel) {
|
||
$sel = ($settings[$key] ?? '') === $optVal ? ' selected' : '';
|
||
echo "<option value=\"" . self::h($optVal) . "\"{$sel}>" . self::h($optLabel) . "</option>";
|
||
}
|
||
echo "</select>";
|
||
} elseif ($f['type'] === 'textarea') {
|
||
echo "<textarea class=\"inp\" id=\"{$key}\" name=\"{$key}\" placeholder=\"{$placeholder}\">{$val}</textarea>";
|
||
} elseif ($f['type'] === 'number') {
|
||
echo "<input class=\"inp\" type=\"number\" id=\"{$key}\" name=\"{$key}\" value=\"{$val}\" placeholder=\"{$placeholder}\">";
|
||
} elseif ($f['type'] === 'checkbox') {
|
||
$checked = !empty($settings[$key]) ? ' checked' : '';
|
||
echo "<label class=\"chk-lbl\"><input type=\"checkbox\" name=\"{$key}\" value=\"1\"{$checked}> {$label}</label>";
|
||
} else {
|
||
echo "<input class=\"inp\" type=\"{$f['type']}\" id=\"{$key}\" name=\"{$key}\" value=\"{$val}\" placeholder=\"{$placeholder}\">";
|
||
}
|
||
echo "</div>";
|
||
}
|
||
echo '</div></div></div>';
|
||
}
|
||
echo <<<HTML
|
||
<div class="btn-row">
|
||
<a href="/admin/dashboard" class="btn-out" style="background:#ddd;color:#333;padding:10px 24px;border-radius:8px;text-decoration:none;font-size:14px">Cancelar</a>
|
||
<button type="submit" class="btn-primary">Guardar configuración</button>
|
||
</div>
|
||
</form>
|
||
|
||
<div class="card" style="margin-top:16px">
|
||
<div class="card-h">Probar conexión IA</div>
|
||
<div class="card-b">
|
||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||
<button type="button" class="btn-primary" onclick="testAiConn()" id="btnTestAi">Probar conexión</button>
|
||
<span id="aiTestResult" style="font-size:13px"></span>
|
||
</div>
|
||
<small style="margin-top:8px;color:#888">Guarda primero la configuración, luego prueba la conexión. Se enviará un mensaje de prueba al proveedor activo.</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="version-info">Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.</div>
|
||
</div>
|
||
<script>
|
||
function testAiConn() {
|
||
var btn = document.getElementById('btnTestAi');
|
||
var res = document.getElementById('aiTestResult');
|
||
btn.disabled = true;
|
||
res.textContent = 'Probando...';
|
||
res.style.color = '#666';
|
||
fetch('/admin/settings/test-ai', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:''})
|
||
.then(function(r){return r.json();})
|
||
.then(function(d){
|
||
if (d.ok) {
|
||
res.textContent = 'Conexion exitosa (' + d.provider + '): ' + d.response;
|
||
res.style.color = '#16a34a';
|
||
} else {
|
||
res.textContent = 'Error (' + d.provider + '): ' + d.error;
|
||
res.style.color = '#dc2626';
|
||
}
|
||
})
|
||
.catch(function(e){ res.textContent = 'Error de red'; res.style.color = '#dc2626'; })
|
||
.finally(function(){ btn.disabled = false; });
|
||
}
|
||
</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 COUNT(*) FROM conversations c3 WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound'
|
||
AND c3.id > COALESCE((SELECT MAX(c4.id) FROM conversations c4 WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'),0)) as unread_count
|
||
FROM conversations c
|
||
GROUP BY c.phone_number
|
||
ORDER BY last_time DESC
|
||
")->fetchAll();
|
||
$initialConvs = [];
|
||
foreach ($convRows as $r) {
|
||
$cid = (int)$r['company_id'];
|
||
$initialConvs[] = [
|
||
'phone' => $r['phone_number'],
|
||
'contact_name' => $r['contact_name'] ?? '',
|
||
'company_name' => $companyMap[$cid] ?? '',
|
||
'last_message' => mb_substr($r['last_message'] ?? '', 0, 80),
|
||
'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">←</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()">➤</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();
|
||
const convs = data.conversations || [];
|
||
if (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 => {
|
||
renderConvs(convs);
|
||
} 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({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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;
|
||
}
|
||
|
||
// ─── API: lista de conversaciones ────────────────────────────────────────
|
||
|
||
public static function chatConversations(): void
|
||
{
|
||
SessionAuth::require();
|
||
$db = db();
|
||
// Get unique conversations with last message and unread count
|
||
$rows = $db->query("
|
||
SELECT c.phone_number,
|
||
MAX(c.contact_name) as contact_name,
|
||
MAX(c.company_id) as company_id,
|
||
MAX(c.created_at) as last_time,
|
||
(SELECT content FROM conversations c2 WHERE c2.phone_number = c.phone_number ORDER BY c2.id DESC LIMIT 1) as last_message,
|
||
(SELECT COUNT(*) FROM conversations c3 WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound' AND c3.id > COALESCE((SELECT MAX(c4.id) FROM conversations c4 WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'), 0)) as unread_count
|
||
FROM conversations c
|
||
GROUP BY c.phone_number
|
||
ORDER BY last_time DESC
|
||
")->fetchAll();
|
||
|
||
$companies = [];
|
||
foreach (CompanyRepository::findAll() as $co) {
|
||
$companies[(int)$co['id']] = $co['name'];
|
||
}
|
||
|
||
$conversations = [];
|
||
foreach ($rows as $r) {
|
||
$cid = (int)$r['company_id'];
|
||
$conversations[] = [
|
||
'phone' => $r['phone_number'],
|
||
'contact_name' => $r['contact_name'] ?? '',
|
||
'company_name' => $companies[$cid] ?? '',
|
||
'last_message' => mb_substr($r['last_message'] ?? '', 0, 80),
|
||
'last_time' => $r['last_time'],
|
||
'unread_count' => (int)$r['unread_count'],
|
||
];
|
||
}
|
||
|
||
jsonResponse(200, ['conversations' => $conversations]);
|
||
}
|
||
|
||
// ─── API: mensajes de una conversación ───────────────────────────────────
|
||
|
||
public static function chatMessages(): void
|
||
{
|
||
SessionAuth::require();
|
||
$phone = trim($_GET['phone'] ?? '');
|
||
if ($phone === '') jsonResponse(400, ['error' => 'phone requerido']);
|
||
|
||
$stmt = db()->prepare("
|
||
SELECT direction, message_type, content, media_id, status, created_at
|
||
FROM conversations
|
||
WHERE phone_number = ?
|
||
ORDER BY id ASC
|
||
");
|
||
$stmt->execute([$phone]);
|
||
$messages = $stmt->fetchAll();
|
||
|
||
// Also include outbound_queue items for this phone
|
||
$stmt2 = db()->prepare("
|
||
SELECT 'outbound' as direction, message_type, payload as content, NULL as media_id, status, created_at
|
||
FROM outbound_queue
|
||
WHERE to_number = ? AND status IN ('sent','delivered','failed')
|
||
AND id > COALESCE((SELECT MAX(c.id) FROM conversations c WHERE c.phone_number = ?), 0)
|
||
ORDER BY id ASC
|
||
");
|
||
$stmt2->execute([$phone, $phone]);
|
||
$queueMsgs = $stmt2->fetchAll();
|
||
|
||
foreach ($queueMsgs as &$qm) {
|
||
$pl = json_decode($qm['content'], true);
|
||
$qm['content'] = $pl['text'] ?? $pl['caption'] ?? $qm['content'];
|
||
}
|
||
|
||
$all = array_merge($messages, $queueMsgs);
|
||
usort($all, fn($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? ''));
|
||
|
||
jsonResponse(200, ['messages' => $all]);
|
||
}
|
||
|
||
// ─── API: enviar mensaje desde el chat ───────────────────────────────────
|
||
|
||
public static function chatSend(): void
|
||
{
|
||
SessionAuth::require();
|
||
$input = json_decode(file_get_contents('php://input'), true);
|
||
$phone = trim($input['phone'] ?? '');
|
||
$text = trim($input['text'] ?? '');
|
||
if ($phone === '' || $text === '') {
|
||
jsonResponse(400, ['error' => 'phone y text requeridos']);
|
||
}
|
||
|
||
// Find company for this phone (lookup by latest conversation)
|
||
$stmt = db()->prepare("SELECT company_id FROM conversations WHERE phone_number = ? ORDER BY id DESC LIMIT 1");
|
||
$stmt->execute([$phone]);
|
||
$conv = $stmt->fetch();
|
||
$companyId = $conv ? (int)$conv['company_id'] : 1;
|
||
|
||
// Enqueue the message
|
||
$db = db();
|
||
$payload = json_encode(['text' => $text]);
|
||
$qStmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload, status, created_at) VALUES (?, ?, 'text', ?, 'queued', NOW())");
|
||
$qStmt->execute([$companyId, $phone, $payload]);
|
||
|
||
// Add to conversations
|
||
$msgId = 'admin_' . time() . '_' . $phone;
|
||
$cStmt = $db->prepare("INSERT INTO conversations (company_id, message_id, phone_number, direction, message_type, content, created_at) VALUES (?, ?, ?, 'outbound', 'text', ?, NOW())");
|
||
$cStmt->execute([$companyId, $msgId, $phone, $text]);
|
||
|
||
jsonResponse(200, ['status' => 'sent']);
|
||
}
|
||
|
||
// ─── GET /admin/bot-config ──────────────────────────────────────────────
|
||
|
||
public static function botConfig(): void
|
||
{
|
||
SessionAuth::require();
|
||
$user = SessionAuth::user();
|
||
$userName = self::h($user['name'] ?? 'Admin');
|
||
$companyId = (int)($_GET['id'] ?? 0);
|
||
$companies = CompanyRepository::findAll();
|
||
$company = null;
|
||
$config = ['commands' => [], 'menus' => [], 'flows' => [], 'ai_prompt' => '', 'fallback' => '', 'greeting' => ''];
|
||
$msg = $_GET['msg'] ?? '';
|
||
$toastHtml = $msg === 'saved' ? '<div class="toast toast-success">Configuración guardada exitosamente.</div>' : '';
|
||
|
||
$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, url, method 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' : '';
|
||
$aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini');
|
||
$aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7));
|
||
$aiProviderVal = self::h($config['ai_provider'] ?? '');
|
||
$approvalWebhookVal = self::h($config['approval_webhook'] ?? '');
|
||
$ignoreVal = self::h(implode("\n", $config['ignore_prefixes'] ?? []));
|
||
$commands = $config['commands'] ?? [];
|
||
$menus = $config['menus'] ?? [];
|
||
$flows = $config['flows'] ?? [];
|
||
$aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : '';
|
||
$aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : '';
|
||
|
||
// Per-category config HTML
|
||
$perTypeConfig = $config['per_type'] ?? [];
|
||
$menuKeysList = array_keys($menus);
|
||
$catLabels = ['1' => 'Categoría 1 — Solo recibe info', '2' => 'Categoría 2 — Solo descarga informes', '3' => 'Categoría 3 — Reporta y recibe'];
|
||
$categoryTabHtml = '';
|
||
foreach ([1, 2, 3] as $cat) {
|
||
$catCfg = $perTypeConfig[(string)$cat] ?? [];
|
||
$selMenu = $catCfg['greeting_menu'] ?? '';
|
||
$opts = '<option value="">— Usa configuración global —</option>';
|
||
foreach ($menuKeysList as $mk) {
|
||
$s = $selMenu === $mk ? ' selected' : '';
|
||
$opts .= '<option value="' . self::h($mk) . '"' . $s . '>' . self::h($mk) . '</option>';
|
||
}
|
||
$label = $catLabels[(string)$cat];
|
||
$categoryTabHtml .= <<<CATHTML
|
||
<div style="background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:18px;margin-bottom:16px">
|
||
<div style="font-weight:700;font-size:13px;color:#0b3d91;margin-bottom:12px">{$label}</div>
|
||
<div class="form-group" style="margin-bottom:0">
|
||
<label>Menú de bienvenida (greeting menu)</label>
|
||
<select name="per_type_menu[{$cat}]" style="width:100%;padding:9px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px">
|
||
{$opts}
|
||
</select>
|
||
<div style="font-size:11px;color:#7a8291;margin-top:4px">Menú en botones que se muestra cuando el usuario escribe por primera vez o sin contexto activo</div>
|
||
</div>
|
||
</div>
|
||
CATHTML;
|
||
}
|
||
$aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : '';
|
||
$aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : '';
|
||
$aiModel35 = $aiModelVal === 'gpt-3.5-turbo' ? ' selected' : '';
|
||
|
||
echo <<<HTML
|
||
<form method="POST" action="/admin/bot-config/save">
|
||
<input type="hidden" name="company_id" value="{$cId}">
|
||
|
||
<div class="tabs">
|
||
<button type="button" class="tab-btn active" onclick="switchTab('commands',this)">📋 Comandos</button>
|
||
<button type="button" class="tab-btn" onclick="switchTab('menus',this)">📑 Menús</button>
|
||
<button type="button" class="tab-btn" onclick="switchTab('flows',this)">🔀 Flujos</button>
|
||
<button type="button" class="tab-btn" onclick="switchTab('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. Ej: "menu", "info", "contacto"</p>
|
||
<table class="simple" id="cmdTable">
|
||
<thead><tr><th>Palabra clave</th><th>Acción (menú o flujo)</th><th></th></tr></thead>
|
||
<tbody id="cmdBody">
|
||
HTML;
|
||
$cmdIdx = 0;
|
||
foreach ($commands as $keyword => $action) {
|
||
echo "<tr id=\"cmd-row-{$cmdIdx}\">
|
||
<td><input type=\"text\" name=\"cmd_keyword[]\" value=\"" . self::h($keyword) . "\" style=\"width:100%;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:13px\"></td>
|
||
<td><input type=\"text\" name=\"cmd_action[]\" value=\"" . self::h($action) . "\" style=\"width:100%;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:13px\" placeholder=\"ej: show_main, servicios...\"></td>
|
||
<td><button type=\"button\" class=\"btn-danger-sm\" onclick=\"this.closest('tr').remove()\">✕</button></td>
|
||
</tr>";
|
||
$cmdIdx++;
|
||
}
|
||
echo <<<HTML
|
||
</tbody>
|
||
</table>
|
||
<button type="button" class="add-btn" onclick="addCmd()">+ Agregar comando</button>
|
||
</div>
|
||
|
||
<!-- ─── MENUS ────────────────────────────────────────────────────────── -->
|
||
<div class="tab-content" id="tab-menus">
|
||
<div class="card-h">📑 Menús Interactivos</div>
|
||
<p style="font-size:12px;color:#7a8291;margin-bottom:14px">Menús tipo lista o botones que se envían al usuario. Cada menú tiene un ID (ej: show_main) y puede tener secciones con filas.</p>
|
||
<div id="menuContainer">
|
||
HTML;
|
||
$menuIdx = 0;
|
||
foreach ($menus as $mk => $menu) {
|
||
$mt = $menu['type'] ?? '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>
|
||
|
||
<!-- ─── FLOWS ────────────────────────────────────────────────────────── -->
|
||
<div class="tab-content" id="tab-flows">
|
||
<div class="card-h">🔀 Flujos</div>
|
||
<p style="font-size:12px;color:#7a8291;margin-bottom:14px">Acciones que se ejecutan cuando un comando o fila de menú es seleccionada.</p>
|
||
<div id="flowContainer">
|
||
HTML;
|
||
$flowIdx = 0;
|
||
foreach ($flows as $fk => $flow) {
|
||
$ft = $flow['type'] ?? 'text';
|
||
$fMsg = self::h($flow['message'] ?? '');
|
||
$fFn = self::h($flow['function'] ?? 'forward_to_ai');
|
||
$fMenu = self::h($flow['menu'] ?? '');
|
||
echo "<div class=\"item-card\" id=\"flow-card-{$flowIdx}\">
|
||
<div class=\"item-head\">
|
||
<span>ID: <input type=\"text\" name=\"flow_key[]\" value=\"" . self::h($fk) . "\" style=\"border:none;background:transparent;font-weight:700;color:#0b3d91;width:200px;font-size:13px\" placeholder=\"ID del flujo (ej: servicios)\"></span>
|
||
<button type=\"button\" class=\"del\" onclick=\"this.closest('.item-card').remove()\">✕</button>
|
||
</div>
|
||
<div class=\"form-row\">
|
||
<div class=\"form-group\" style=\"min-width:120px\">
|
||
<label>Tipo</label>
|
||
<select name=\"flow_type[]\" onchange=\"flowTypeChange(this)\">
|
||
<option value=\"text\"" . ($ft==='text'?' selected':'') . ">Texto (respuesta fija)</option>
|
||
<option value=\"function\"" . ($ft==='function'?' selected':'') . ">Función (acción especial)</option>
|
||
<option value=\"menu\"" . ($ft==='menu'?' selected':'') . ">Menú (submenú)</option>
|
||
</select>
|
||
</div>
|
||
<div class=\"form-group flow-text\" style=\"" . ($ft==='text'?'':'display:none') . "\">
|
||
<label>Mensaje de respuesta</label>
|
||
<textarea name=\"flow_message[]\" rows=\"2\" placeholder=\"Texto que se enviará al usuario\">{$fMsg}</textarea>
|
||
</div>
|
||
<div class=\"form-group flow-function\" style=\"" . ($ft==='function'?'':'display:none') . "\">
|
||
<label>Función</label>
|
||
<select name=\"flow_function[]\">
|
||
<option value=\"forward_to_ai\"" . ($fFn==='forward_to_ai'?' selected':'') . ">Escalar a IA</option>
|
||
<option value=\"forward_to_agent\"" . ($fFn==='forward_to_agent'?' selected':'') . ">Escalar a agente humano</option>
|
||
<option value=\"webhook\"" . ($fFn==='webhook'?' selected':'') . ">Llamar webhook externo</option>
|
||
</select>
|
||
</div>
|
||
<div class=\"form-group flow-menu\" style=\"" . ($ft==='menu'?'':'display:none') . "\">
|
||
<label>Menú a mostrar</label>
|
||
<input type=\"text\" name=\"flow_menu[]\" value=\"{$fMenu}\" placeholder=\"ID del menú (ej: show_main)\">
|
||
</div>
|
||
</div>
|
||
</div>";
|
||
$flowIdx++;
|
||
}
|
||
echo <<<HTML
|
||
</div>
|
||
<button type="button" class="add-btn" onclick="addFlow()">+ Agregar flujo</button>
|
||
</div>
|
||
|
||
<!-- ─── 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 qué menú de bienvenida ve cada categoría de usuario cuando escribe por primera vez o sin contexto activo. El menú debe ser de tipo <strong>botón</strong>.</p>
|
||
{$categoryTabHtml}
|
||
</div>
|
||
|
||
<!-- ─── AI ───────────────────────────────────────────────────────────── -->
|
||
<div class="tab-content" id="tab-ai">
|
||
<div class="card-h">🤖 Inteligencia Artificial</div>
|
||
<div class="form-group">
|
||
<label>Proveedor (sobrescribe el global)</label>
|
||
<select name="ai_provider">
|
||
<option value="">Usar configuración global</option>
|
||
<option value="openai"{$aiProviderOpenai}>OpenAI</option>
|
||
<option value="mock"{$aiProviderMock}>Mock (simulado)</option>
|
||
</select>
|
||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Si no se selecciona, usa el proveedor de Configuración General</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Modelo</label>
|
||
<select name="ai_model">
|
||
<option value="gpt-4o-mini"{$aiModelMini}>GPT-4o Mini</option>
|
||
<option value="gpt-4o"{$aiModel4o}>GPT-4o</option>
|
||
<option value="gpt-3.5-turbo"{$aiModel35}>GPT-3.5 Turbo</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Temperatura ({$aiTempVal})</label>
|
||
<input type="range" name="ai_temperature" min="0" max="1" step="0.1" value="{$aiTempVal}" oninput="this.previousElementSibling.textContent='Temperatura ('+this.value+')'">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>System Prompt</label>
|
||
<textarea name="ai_prompt" rows="4" placeholder="Eres un asistente virtual de {$cName}...">{$aiPromptVal}</textarea>
|
||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Instrucciones que define el comportamiento de la IA</div>
|
||
</div>
|
||
<div 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>
|
||
|
||
<!-- ─── GENERAL ──────────────────────────────────────────────────────── -->
|
||
<div class="tab-content" id="tab-general">
|
||
<div class="card-h">⚙️ General</div>
|
||
<div style="display:grid;grid-template-columns:1fr 300px;gap:24px;align-items:start">
|
||
<div>
|
||
<div class="form-group">
|
||
<label>Mensaje de bienvenida (greeting)</label>
|
||
<textarea name="greeting" id="prevGreeting" rows="2" placeholder="¡Bienvenido! Escribe 'menu' para ver opciones..." oninput="updatePreview()" style="width:100%;padding:10px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;font-family:inherit;background:#fafbfc;resize:none">{$greetingVal}</textarea>
|
||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Primer mensaje que recibe un usuario nuevo</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Mensaje por defecto (fallback)</label>
|
||
<textarea name="fallback" id="prevFallback" rows="2" placeholder="No entendí. Escribe menu para ver opciones." oninput="updatePreview()" style="width:100%;padding:10px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;font-family:inherit;background:#fafbfc;resize:none">{$fallbackVal}</textarea>
|
||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Cuando el bot no entiende el mensaje</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Webhook de aprobación (approval_webhook)</label>
|
||
<input type="text" name="approval_webhook" value="{$approvalWebhookVal}" placeholder="https://api.ejemplo.com/webhook-aprobacion">
|
||
<div style="font-size:11px;color:#7a8291;margin-top:3px">URL a la que se notificará cuando se requiera aprobación</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Prefijos a ignorar (uno por línea)</label>
|
||
<textarea name="ignore_prefixes" rows="3" placeholder="Ej: 555 111" style="width:100%;padding:10px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;font-family:inherit;background:#fafbfc">{$ignoreVal}</textarea>
|
||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Números que comiencen con estos prefijos no recibirán respuestas del bot</div>
|
||
</div>
|
||
</div>
|
||
<!-- WhatsApp preview -->
|
||
<div style="position:sticky;top:120px">
|
||
<div style="font-size:11px;font-weight:700;color:#7a8291;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Vista previa</div>
|
||
<div style="background:#111;border-radius:36px;padding:8px;box-shadow:0 8px 32px rgba(0,0,0,.18);width:260px">
|
||
<div style="background:#0b3d91;border-radius:28px 28px 0 0;padding:10px 14px 8px;display:flex;align-items:center;gap:8px">
|
||
<div style="width:32px;height:32px;border-radius:50%;background:rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;font-size:14px;color:#fff;font-weight:700">{$cName[0]}</div>
|
||
<div><div style="font-size:12px;font-weight:600;color:#fff">{$cName}</div><div style="font-size:10px;color:rgba(255,255,255,.7)">en línea</div></div>
|
||
</div>
|
||
<div style="background:#e5ddd5 url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAB3RJTUUH5QMTDxIJCVn2bAAAAA1JREFUCNdjYGBg+A8AAQQAAiEBMUQAAAAASUVORK5CYII=");border-radius:0 0 28px 28px;padding:12px 10px;min-height:200px;display:flex;flex-direction:column;gap:8px">
|
||
<div style="background:#fff;border-radius:0 8px 8px 8px;padding:8px 10px;max-width:85%;box-shadow:0 1px 2px rgba(0,0,0,.1)">
|
||
<div id="prevGreetingDisplay" style="font-size:12px;color:#1a1d23;line-height:1.4;white-space:pre-wrap">{$greetingVal}</div>
|
||
<div style="font-size:10px;color:#a0a8b8;text-align:right;margin-top:3px">12:00</div>
|
||
</div>
|
||
<div style="background:#fff;border-radius:0 8px 8px 8px;padding:8px 10px;max-width:85%;box-shadow:0 1px 2px rgba(0,0,0,.1)">
|
||
<div id="prevFallbackDisplay" style="font-size:12px;color:#1a1d23;line-height:1.4;white-space:pre-wrap">{$fallbackVal}</div>
|
||
<div style="font-size:10px;color:#a0a8b8;text-align:right;margin-top:3px">fallback</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="font-size:10px;color:#a0a8b8;text-align:center;margin-top:6px">Vista previa aproximada</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:12px;margin-top:16px">
|
||
<button type="submit" class="btn-primary">💾 Guardar configuración</button>
|
||
<a href="/admin/companies" class="btn-secondary" style="text-decoration:none;display:inline-flex;align-items:center">↩ Volver a empresas</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<script>
|
||
function switchTab(tab, btn) {
|
||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-btn').forEach(t => t.classList.remove('active'));
|
||
document.getElementById('tab-' + tab).classList.add('active');
|
||
btn.classList.add('active');
|
||
}
|
||
|
||
// Commands
|
||
function addCmd() {
|
||
const tbody = document.getElementById('cmdBody');
|
||
const idx = tbody.children.length;
|
||
const tr = document.createElement('tr');
|
||
tr.id = 'cmd-row-' + idx;
|
||
tr.innerHTML = '<td><input type="text" name="cmd_keyword[]" style="width:100%;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:13px" placeholder="ej: menu"></td>' +
|
||
'<td><input type="text" name="cmd_action[]" style="width:100%;padding:6px 10px;border:1px solid #e2e5ea;border-radius:6px;font-size:13px" placeholder="ej: show_main"></td>' +
|
||
'<td><button type="button" class="btn-danger-sm" onclick="this.closest(\'tr\').remove()">✕</button></td>';
|
||
tbody.appendChild(tr);
|
||
}
|
||
|
||
// Menus
|
||
function 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
|
||
function addFlow() {
|
||
const container = document.getElementById('flowContainer');
|
||
const idx = container.children.length;
|
||
const div = document.createElement('div');
|
||
div.className = 'item-card';
|
||
div.id = 'flow-card-' + idx;
|
||
div.innerHTML = '<div class="item-head"><span>ID: <input type="text" name="flow_key[]" style="border:none;background:transparent;font-weight:700;color:#0b3d91;width:200px;font-size:13px" placeholder="ID del flujo (ej: servicios)"></span><button type="button" class="del" onclick="this.closest(\'.item-card\').remove()">✕</button></div>' +
|
||
'<div class="form-row">' +
|
||
'<div class="form-group" style="min-width:120px"><label>Tipo</label><select name="flow_type[]" onchange="flowTypeChange(this)"><option value="text">Texto (respuesta fija)</option><option value="function">Función (acción especial)</option><option value="menu">Menú (submenú)</option></select></div>' +
|
||
'<div class="form-group flow-text"><label>Mensaje de respuesta</label><textarea name="flow_message[]" rows="2" placeholder="Texto que se enviará al usuario"></textarea></div>' +
|
||
'<div class="form-group flow-function" style="display:none"><label>Función</label><select name="flow_function[]"><option value="forward_to_ai">Escalar a IA</option><option value="forward_to_agent">Escalar a agente humano</option><option value="webhook">Llamar webhook externo</option></select></div>' +
|
||
'<div class="form-group flow-menu" style="display:none"><label>Menú a mostrar</label><input type="text" name="flow_menu[]" placeholder="ID del menú (ej: show_main)"></div>' +
|
||
'</div>';
|
||
container.appendChild(div);
|
||
}
|
||
|
||
function flowTypeChange(sel) {
|
||
const card = sel.closest('.item-card');
|
||
card.querySelector('.flow-text').style.display = sel.value === 'text' ? '' : 'none';
|
||
card.querySelector('.flow-function').style.display = sel.value === 'function' ? '' : 'none';
|
||
card.querySelector('.flow-menu').style.display = sel.value === 'menu' ? '' : 'none';
|
||
}
|
||
|
||
function updatePreview() {
|
||
const g = document.getElementById('prevGreeting')?.value || '';
|
||
const f = document.getElementById('prevFallback')?.value || '';
|
||
const gd = document.getElementById('prevGreetingDisplay');
|
||
const fd = document.getElementById('prevFallbackDisplay');
|
||
if (gd) gd.textContent = g || '(vacío)';
|
||
if (fd) fd.textContent = f || '(vacío)';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
HTML;
|
||
exit;
|
||
}
|
||
|
||
public static function conversationFlow(): void
|
||
{
|
||
SessionAuth::require();
|
||
$user = SessionAuth::user();
|
||
$userName = self::h($user['name'] ?? 'Admin');
|
||
$companyId = (int)($_GET['id'] ?? 0);
|
||
$companies = CompanyRepository::findAll();
|
||
$company = null;
|
||
$config = ['commands' => [], 'menus' => [], 'flows' => [], 'ai_prompt' => '', 'fallback' => '', 'greeting' => ''];
|
||
$msg = $_GET['msg'] ?? '';
|
||
$toastHtml = $msg === 'saved' ? '<div class="toast toast-success">Configuración guardada exitosamente.</div>' : '';
|
||
|
||
if ($companyId > 0) {
|
||
$company = CompanyRepository::findById($companyId);
|
||
if ($company) {
|
||
$cj = $company['config_json'] ?? '';
|
||
if ($cj) {
|
||
$parsed = json_decode($cj, true);
|
||
if (is_array($parsed)) $config = $parsed;
|
||
}
|
||
}
|
||
}
|
||
|
||
http_response_code(200);
|
||
header('Content-Type: text/html; charset=utf-8');
|
||
echo Layout::open('Hilo Conductor', 'conv-flow', $user['name'] ?? 'Admin');
|
||
echo <<<HTML
|
||
<style>
|
||
body{background:#f0f2f6}
|
||
.wrap{padding:16px 24px 40px;max-width:1300px;margin:0 auto}
|
||
.company-select{display:flex;gap:12px;align-items:center;margin-bottom:16px;flex-wrap:wrap}
|
||
.company-select select{padding:9px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;background:#fff;min-width:250px}
|
||
.company-select select:focus{border-color:#1565c0}
|
||
.company-select label{font-weight:600;font-size:13px}
|
||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;align-items:center}
|
||
.toolbar .btn-pri{background:#0b3d91;color:#fff;border:none;border-radius:8px;padding:9px 20px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:6px}
|
||
.toolbar .btn-pri:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)}
|
||
.toolbar .btn-sec{background:#fff;color:#0b3d91;border:1px solid #d6e4ff;border-radius:8px;padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:6px}
|
||
.toolbar .btn-sec:hover{background:#f0f4ff}
|
||
.legend{display:flex;gap:14px;flex-wrap:wrap;padding:10px 14px;background:#fff;border-radius:10px;border:1px solid #eef1f5;margin-bottom:16px;font-size:12px;align-items:center}
|
||
.legend-item{display:flex;align-items:center;gap:6px}
|
||
.legend-dot{width:12px;height:12px;border-radius:50%;display:inline-block}
|
||
.legend-dot.cmd{background:#6366f1}
|
||
.legend-dot.menu{background:#0ea5e9}
|
||
.legend-dot.row-dot{background:#f59e0b}
|
||
.legend-dot.flow{background:#8b5cf6}
|
||
.legend-dot.sub{background:#10b981}
|
||
.legend-dot.action{background:#ef4444}
|
||
/* ─── TREE ─── */
|
||
.tree-canvas{background:#fff;border-radius:12px;border:1px solid #eef1f5;padding:24px;overflow-x:auto;min-height:300px;box-shadow:0 1px 4px rgba(0,0,0,.04)}
|
||
.no-data{text-align:center;padding:60px 20px;color:#a0a8b8;font-size:15px}
|
||
.root-node{display:flex;flex-direction:column;align-items:center}
|
||
.entry-node{display:flex;flex-direction:column;align-items:center;position:relative;width:100%}
|
||
.node-group{display:flex;flex-direction:column;align-items:center;width:100%;position:relative}
|
||
.node-card{background:#fff;border-radius:10px;padding:12px 16px;min-width:220px;max-width:340px;position:relative;box-shadow:0 1px 3px rgba(0,0,0,.06),0 4px 12px rgba(0,0,0,.04);transition:all .2s;cursor:pointer}
|
||
.node-card:hover{box-shadow:0 4px 12px rgba(0,0,0,.1),0 8px 24px rgba(0,0,0,.06);transform:translateY(-2px)}
|
||
.node-card .tag{font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;margin-bottom:4px;display:flex;align-items:center;gap:4px}
|
||
.node-card .n-title{font-size:14px;font-weight:700;margin-bottom:2px;word-break:break-word}
|
||
.node-card .n-sub{font-size:11px;color:#6b7280;margin-bottom:4px;word-break:break-word}
|
||
.node-card .n-info{font-size:11px;color:#6b7280;display:flex;gap:4px;flex-wrap:wrap;margin-bottom:6px}
|
||
.node-card .n-info span{background:#f3f4f6;padding:1px 6px;border-radius:4px}
|
||
.node-card .actions{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap}
|
||
.node-card .actions button{font-size:10px;padding:2px 8px;border-radius:4px;border:none;cursor:pointer;font-weight:600;transition:all .12s}
|
||
.node-card .actions .edit-btn{background:#e8f4fd;color:#0369a1}
|
||
.node-card .actions .edit-btn:hover{background:#bae6fd}
|
||
.node-card .actions .del-btn{background:#fef2f2;color:#991b1b}
|
||
.node-card .actions .del-btn:hover{background:#fee2e2}
|
||
.node-card .actions .add-btn-sm{background:#f0fdf4;color:#166534}
|
||
.node-card .actions .add-btn-sm:hover{background:#dcfce7;}
|
||
.cmd-card{border-left:4px solid #6366f1}
|
||
.menu-card{border-left:4px solid #0ea5e9}
|
||
.row-card{border-left:4px solid #f59e0b;min-width:200px}
|
||
.flow-card{border-left:4px solid #8b5cf6}
|
||
.sub-card{border-left:4px solid #10b981}
|
||
.root-card{border-left:4px solid #374151}
|
||
.row-list{display:flex;flex-direction:column;gap:3px;margin-top:6px;width:100%}
|
||
.row-item{display:flex;align-items:center;gap:6px;background:#f9fafb;padding:5px 8px;border-radius:6px;font-size:12px;border:1px solid #f0f1f3;transition:all .12s;cursor:pointer}
|
||
.row-item:hover{background:#f0f4ff;border-color:#d6e4ff}
|
||
.row-item .r-icon{font-size:14px;flex-shrink:0}
|
||
.row-item .r-title{font-weight:600;flex:1;color:#1f2937}
|
||
.row-item .r-id{font-size:10px;color:#6b7280;font-family:monospace;background:#eef1f5;padding:1px 5px;border-radius:3px}
|
||
.row-item .r-arrow{color:#9ca3af;font-size:12px}
|
||
.row-item .r-target{font-size:11px;color:#6366f1;font-weight:500;font-family:monospace}
|
||
.connector-v{width:2px;height:20px;background:#d1d5db;flex-shrink:0}
|
||
.connector-v.thick{height:28px}
|
||
.connector-branch{display:flex;gap:20px;position:relative;padding-top:4px;flex-wrap:wrap;justify-content:center}
|
||
.connector-branch::before{content:'';position:absolute;top:0;left:50%;right:50%;height:2px;background:#d1d5db}
|
||
.connector-branch::after{content:none}
|
||
.branch-item{display:flex;flex-direction:column;align-items:center;position:relative;min-width:0}
|
||
.branch-item::before{content:'';position:absolute;top:0;left:50%;width:2px;height:10px;background:#d1d5db}
|
||
.branch-item:first-child::before{left:50%}
|
||
.branch-item:last-child::before{left:50%}
|
||
.empty-hint{padding:20px;text-align:center;color:#9ca3af;font-size:12px}
|
||
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:1000;backdrop-filter:blur(2px)}
|
||
.overlay.show{display:flex;align-items:center;justify-content:center}
|
||
.modal{background:#fff;border-radius:14px;max-width:520px;width:90%;max-height:85vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.15);padding:24px}
|
||
.modal h2{font-size:16px;font-weight:700;color:#0b3d91;margin-bottom:16px;padding-bottom:10px;border-bottom:2px solid #eef1f5}
|
||
.modal .form-group{margin-bottom:12px}
|
||
.modal .form-group label{display:block;font-size:12px;font-weight:600;color:#3d4552;margin-bottom:4px}
|
||
.modal .form-group input,.modal .form-group select,.modal .form-group textarea{width:100%;padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;font-family:inherit;background:#fafbfc;transition:all .15s;color:#1a1d23}
|
||
.modal .form-group input:focus,.modal .form-group select:focus,.modal .form-group textarea:focus{border-color:#1565c0;background:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)}
|
||
.modal .form-group textarea{min-height:50px;resize:vertical}
|
||
.modal .btn-row{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
||
.modal .btn-row button{padding:8px 20px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:none;transition:all .12s}
|
||
.modal .btn-row .btn-save{background:#0b3d91;color:#fff}
|
||
.modal .btn-row .btn-save:hover{background:#1565c0}
|
||
.modal .btn-row .btn-cancel{background:#f3f4f6;color:#4b5563}
|
||
.modal .btn-row .btn-cancel:hover{background:#e5e7eb}
|
||
.modal .btn-row .btn-del-modal{background:#fef2f2;color:#991b1b}
|
||
.modal .btn-row .btn-del-modal:hover{background:#fee2e2}
|
||
.badge-list{background:#e0f2fe;color:#0369a1}
|
||
.badge-button{background:#fef3c7;color:#92400e}
|
||
.badge-text{background:#f3e8ff;color:#6b21a8}
|
||
.badge-func{background:#d1fae5;color:#065f46}
|
||
.badge-menu{background:#dbeafe;color:#1e40af}
|
||
.badge-ai{background:#fce7f3;color:#9d174d}
|
||
.badge-agent{background:#fff7ed;color:#9a3412}
|
||
.badge-webhook{background:#e0e7ff;color:#3730a3}
|
||
.highlight-path .node-card{opacity:.4}
|
||
.highlight-path .highlighted .node-card{opacity:1;box-shadow:0 0 0 2px #6366f1,0 4px 12px rgba(99,102,241,.15)}
|
||
.row-item.highlighted{background:#eef2ff;border-color:#a5b4fc}
|
||
@media(max-width:768px){.wrap{padding:12px}.tree-canvas{padding:12px}.connector-branch{gap:10px}.node-card{min-width:160px;max-width:260px}}
|
||
</style>
|
||
<div class="wrap">
|
||
{$toastHtml}
|
||
<div class="company-select">
|
||
<label>Seleccionar empresa:</label>
|
||
<select onchange="if(this.value) window.location='?id='+this.value">
|
||
<option value="">— Selecciona —</option>
|
||
HTML;
|
||
foreach ($companies as $c) {
|
||
$sel = $companyId === (int)$c['id'] ? 'selected' : '';
|
||
echo "<option value=\"{$c['id']}\" {$sel}>" . self::h($c['name'] . ' — ' . ($c['display_name'] ?? '')) . "</option>";
|
||
}
|
||
echo '</select></div>';
|
||
|
||
if (!$company) {
|
||
echo '<div class="tree-canvas"><div class="no-data">Selecciona una empresa para ver su hilo conductor.</div></div>';
|
||
echo '</div></body></html>';
|
||
exit;
|
||
}
|
||
|
||
$c = $company;
|
||
$cName = self::h($c['name'] ?? '');
|
||
|
||
// ─── Build flows lookup ─────────────────────────────────────────
|
||
$flows = $config['flows'] ?? [];
|
||
$menus = $config['menus'] ?? [];
|
||
$commands = $config['commands'] ?? [];
|
||
$greeting = $config['greeting'] ?? '';
|
||
$fallback = $config['fallback'] ?? '';
|
||
|
||
echo <<<HTML
|
||
<div class="toolbar">
|
||
<span style="font-weight:600;font-size:14px">{$cName}</span>
|
||
<span style="font-size:12px;color:#6b7280">—</span>
|
||
<a href="/admin/bot-config?id={$companyId}" class="btn-sec">⚙️ Ir a Configuración Completa</a>
|
||
<form method="POST" action="/admin/bot-config/save" id="quickSaveForm" style="display:inline">
|
||
<input type="hidden" name="company_id" value="{$companyId}">
|
||
<input type="hidden" name="config_json_override" id="configOverride" value="">
|
||
<button type="submit" class="btn-pri" style="display:none" id="saveBtn">💾 Guardar Cambios</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="legend">
|
||
<span style="font-weight:600;color:#374151;font-size:12px">Leyenda:</span>
|
||
<span class="legend-item"><span class="legend-dot cmd"></span> Comando</span>
|
||
<span class="legend-item"><span class="legend-dot menu"></span> Menú</span>
|
||
<span class="legend-item"><span class="legend-dot row-dot"></span> Botón / Fila</span>
|
||
<span class="legend-item"><span class="legend-dot flow"></span> Flujo (acción)</span>
|
||
<span class="legend-item"><span class="legend-dot sub"></span> Sub-menú</span>
|
||
<span class="legend-item"><span class="legend-dot action"></span> Acción final</span>
|
||
</div>
|
||
|
||
<div class="tree-canvas" id="treeCanvas">
|
||
HTML;
|
||
|
||
// ─── Render the conversation tree ────────────────────────────────
|
||
self::renderConversationTree($config, $companyId);
|
||
|
||
echo '</div></div>';
|
||
|
||
// ─── Inline JS for interactivity ─────────────────────────────────
|
||
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
|
||
$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 editFlow(key) {
|
||
const flow = CONFIG.flows[key];
|
||
if (!flow) return;
|
||
const menuOpts = Object.keys(CONFIG.menus).map(m => '<option value="' + m + '"' + (flow.menu === m ? ' selected' : '') + '>' + m + '</option>').join('');
|
||
const epOpts = Object.keys(ENDPOINTS).map(k => '<option value="' + k + '"' + (((flow.params||{}).endpoint_key) === k ? ' selected' : '') + '>' + k + ' — ' + (ENDPOINTS[k].url||'').slice(0,50) + '</option>').join('');
|
||
const isApiReport = flow.function === 'api_report';
|
||
const dateMode = (flow.params||{}).date_mode || '';
|
||
const caption = (flow.params||{}).caption || '';
|
||
const filename = (flow.params||{}).filename || '';
|
||
showModal(\`
|
||
<h2>🔀 Editar Flujo: \${key}</h2>
|
||
<div class="form-group"><label>ID del flujo</label><input type="text" id="editFlowKey" value="\${key}"></div>
|
||
<div class="form-group"><label>Tipo</label>
|
||
<select id="editFlowType" onchange="flowTypeToggle()">
|
||
<option value="text"\${flow.type==='text'?' selected':''}>Texto (respuesta fija)</option>
|
||
<option value="function"\${flow.type==='function'?' selected':''}>Función (acción especial)</option>
|
||
<option value="menu"\${flow.type==='menu'?' selected':''}>Menú (submenú)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="flowTextGroup" style="\${flow.type==='text'?'':'display:none'}">
|
||
<label>Mensaje de respuesta</label>
|
||
<textarea id="editFlowMessage" rows="3">\${flow.message||''}</textarea>
|
||
</div>
|
||
<div class="form-group" id="flowFuncGroup" style="\${flow.type==='function'?'':'display:none'}">
|
||
<label>Función</label>
|
||
<select id="editFlowFunction" onchange="flowFuncToggle()">
|
||
<option value="forward_to_ai"\${flow.function==='forward_to_ai'?' selected':''}>Escalar a IA</option>
|
||
<option value="forward_to_agent"\${flow.function==='forward_to_agent'?' selected':''}>Escalar a agente humano</option>
|
||
<option value="api_report"\${flow.function==='api_report'?' selected':''}>Descargar / enviar informe</option>
|
||
<option value="goodbye"\${flow.function==='goodbye'?' selected':''}>Salir (goodbye)</option>
|
||
<option value="webhook"\${flow.function==='webhook'?' selected':''}>Llamar webhook externo</option>
|
||
</select>
|
||
</div>
|
||
<div id="flowEpGroup" style="\${isApiReport?'':'display:none'}">
|
||
<div class="form-group">
|
||
<label>Endpoint</label>
|
||
<select id="editFlowEpKey">
|
||
<option value="">— Selecciona endpoint —</option>\${epOpts}
|
||
</select>
|
||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Configura los endpoints en la pestaña "Endpoints API" de la empresa</div>
|
||
</div>
|
||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||
<div><label>Periodo</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 solicitado.">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" id="flowMenuGroup" style="\${flow.type==='menu'?'':'display:none'}">
|
||
<label>Sub-menú a mostrar</label>
|
||
<select id="editFlowMenu"><option value="">Selecciona...</option>\${menuOpts}</select>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
|
||
<button class="btn-del-modal" onclick="deleteFlow('\${key}')">Eliminar</button>
|
||
<button class="btn-save" onclick="saveFlow('\${key}')">Guardar</button>
|
||
</div>\`);
|
||
}
|
||
|
||
function flowTypeToggle() {
|
||
const t = document.getElementById('editFlowType').value;
|
||
document.getElementById('flowTextGroup').style.display = t === 'text' ? '' : 'none';
|
||
document.getElementById('flowFuncGroup').style.display = t === 'function' ? '' : 'none';
|
||
document.getElementById('flowMenuGroup').style.display = t === 'menu' ? '' : 'none';
|
||
if (t !== 'function') document.getElementById('flowEpGroup').style.display = 'none';
|
||
}
|
||
|
||
function flowFuncToggle() {
|
||
const fn = document.getElementById('editFlowFunction').value;
|
||
document.getElementById('flowEpGroup').style.display = fn === 'api_report' ? '' : 'none';
|
||
}
|
||
|
||
function saveFlow(oldKey) {
|
||
const newKey = document.getElementById('editFlowKey').value.trim();
|
||
const type = document.getElementById('editFlowType').value;
|
||
if (!newKey) { alert('El ID del flujo es requerido'); return; }
|
||
const flow = { type };
|
||
if (type === 'text') flow.message = document.getElementById('editFlowMessage').value.trim();
|
||
else if (type === 'function') {
|
||
flow.function = document.getElementById('editFlowFunction').value;
|
||
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 === 'menu') flow.menu = document.getElementById('editFlowMenu').value;
|
||
delete CONFIG.flows[oldKey];
|
||
CONFIG.flows[newKey] = flow;
|
||
// Update menu rows that reference this flow
|
||
for (const mk in CONFIG.menus) {
|
||
const menu = CONFIG.menus[mk];
|
||
for (const s of (menu.sections || [])) {
|
||
for (const r of (s.rows || [])) {
|
||
if (r.id === oldKey && oldKey !== newKey) r.id = newKey;
|
||
}
|
||
}
|
||
}
|
||
saveConfig();
|
||
}
|
||
|
||
function deleteFlow(key) {
|
||
if (!confirm('¿Eliminar flujo "' + key + '"?')) return;
|
||
delete CONFIG.flows[key];
|
||
saveConfig();
|
||
}
|
||
|
||
// ─── Add new node ───
|
||
function addNew(type) {
|
||
if (type === 'command') {
|
||
showModal(\`
|
||
<h2>➕ Nuevo Comando</h2>
|
||
<div class="form-group"><label>Palabra clave</label><input type="text" id="newCmdKey" placeholder="ej: menu, info, contacto"></div>
|
||
<div class="form-group"><label>Acción</label>
|
||
<select id="newCmdAction">
|
||
<option value="">Selecciona destino...</option>
|
||
<optgroup label="Menús">\${Object.keys(CONFIG.menus).map(m => '<option value="' + m + '">📑 ' + m + '</option>').join('')}</optgroup>
|
||
<optgroup label="Flujos">\${Object.keys(CONFIG.flows).map(f => '<option value="' + f + '">🔀 ' + f + '</option>').join('')}</optgroup>
|
||
</select>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
|
||
<button class="btn-save" onclick="saveNewCommand()">Crear</button>
|
||
</div>\`);
|
||
} else if (type === 'menu') {
|
||
showModal(\`
|
||
<h2>➕ Nuevo Menú</h2>
|
||
<div class="form-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 || '');
|
||
fd.append('ai_prompt', fullConfig.ai_prompt || '');
|
||
fd.append('ai_model', fullConfig.ai_model || 'gpt-4o-mini');
|
||
fd.append('ai_temperature', String(fullConfig.ai_temperature || 0.7));
|
||
fd.append('ai_provider', fullConfig.ai_provider || '');
|
||
fd.append('approval_webhook', fullConfig.approval_webhook || '');
|
||
fd.append('ignore_prefixes', (fullConfig.ignore_prefixes || []).join('\\n'));
|
||
|
||
fetch('/admin/bot-config/save', {
|
||
method: 'POST',
|
||
body: fd
|
||
}).then(r => {
|
||
if (r.redirected) {
|
||
window.location.href = '/admin/conversation-flow?id=' + COMPANY_ID + '&msg=saved';
|
||
} else {
|
||
window.location.reload();
|
||
}
|
||
}).catch(e => {
|
||
alert('Error al guardar: ' + e.message);
|
||
btn.textContent = '💾 Guardar Cambios';
|
||
});
|
||
}
|
||
|
||
// Initial: show save button after any changes
|
||
let configChanged = false;
|
||
</script>
|
||
</body>
|
||
</html>
|
||
HTML;
|
||
exit;
|
||
}
|
||
|
||
private static function renderConversationTree(array $config, int $companyId): void
|
||
{
|
||
$flows = $config['flows'] ?? [];
|
||
$menus = $config['menus'] ?? [];
|
||
$commands = $config['commands'] ?? [];
|
||
$greeting = $config['greeting'] ?? '';
|
||
$fallback = $config['fallback'] ?? '';
|
||
|
||
$hasAny = !empty($commands) || !empty($menus) || !empty($flows) || $greeting !== '';
|
||
|
||
if (!$hasAny) {
|
||
echo '<div class="no-data">🚀 No hay configuración aún. <a href="/admin/bot-config?id=' . $companyId . '" style="color:#0b3d91">Crea comandos, menús y flujos</a> para ver el hilo conductor.</div>';
|
||
return;
|
||
}
|
||
|
||
// ── Build a reverse map: menuKey -> list of commands that point to it ──
|
||
$cmdToMenus = [];
|
||
foreach ($commands as $kw => $action) {
|
||
if (isset($menus[$action])) {
|
||
$cmdToMenus[$action][] = $kw;
|
||
}
|
||
}
|
||
|
||
// ── Render entry points ──
|
||
echo '<div class="root-node">';
|
||
|
||
// Greeting
|
||
if ($greeting !== '') {
|
||
echo '<div class="entry-node">';
|
||
echo '<div class="node-card root-card" style="min-width:260px;text-align:center">';
|
||
echo '<div class="tag" style="justify-content:center">👋 Entrada</div>';
|
||
echo '<div class="n-title" style="color:#374151">Mensaje de Bienvenida</div>';
|
||
echo '<div class="n-sub" style="font-style:italic;color:#6b7280">"' . self::h($greeting) . '"</div>';
|
||
echo '</div>';
|
||
echo '<div class="connector-v"></div>';
|
||
echo '</div>';
|
||
}
|
||
|
||
// ── Render commands (entry points) ──
|
||
$displayedMenus = [];
|
||
|
||
// Render commands that point to menus
|
||
echo '<div class="node-group" style="width:100%">';
|
||
echo '<div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center">';
|
||
|
||
foreach ($commands as $kw => $action) {
|
||
$targetType = isset($menus[$action]) ? 'menu' : (isset($flows[$action]) ? 'flow' : 'unknown');
|
||
$targetIcon = $targetType === 'menu' ? '📑' : ($targetType === 'flow' ? '🔀' : '❓');
|
||
|
||
echo '<div class="branch-item">';
|
||
echo '<div class="node-card cmd-card">';
|
||
echo '<div class="tag" style="color:#6366f1">⌨️ Comando</div>';
|
||
echo '<div class="n-title">' . self::h($kw) . '</div>';
|
||
echo '<div class="n-info"><span>→ ' . $targetIcon . ' ' . self::h($action) . '</span></div>';
|
||
echo '<div class="actions">';
|
||
echo '<button class="edit-btn" onclick="editCommand(\'' . self::h($kw) . '\')">✎ Editar</button>';
|
||
echo '</div>';
|
||
echo '</div>';
|
||
|
||
// Connect to target
|
||
echo '<div class="connector-v thick"></div>';
|
||
|
||
// Show target menu or flow
|
||
if ($targetType === 'menu' && isset($menus[$action])) {
|
||
self::renderMenuNode($action, $menus[$action], $flows, $menus, $companyId);
|
||
$displayedMenus[$action] = true;
|
||
} elseif ($targetType === 'flow' && isset($flows[$action])) {
|
||
self::renderFlowNode($action, $flows[$action], $menus, $companyId);
|
||
}
|
||
echo '</div>';
|
||
}
|
||
|
||
echo '</div>';
|
||
echo '</div>';
|
||
|
||
// ── Render menus that are not linked by any command ──
|
||
$orphanMenus = array_diff_key($menus, $displayedMenus);
|
||
if (!empty($orphanMenus)) {
|
||
echo '<div class="connector-v thick"></div>';
|
||
echo '<div style="font-size:11px;color:#9ca3af;font-weight:600;margin:8px 0 4px">📌 Menús sin comando de entrada</div>';
|
||
echo '<div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center">';
|
||
foreach ($orphanMenus as $mk => $menu) {
|
||
echo '<div class="branch-item">';
|
||
echo '<div class="node-card menu-card" style="border-left-color:#9ca3af">';
|
||
echo '<div class="tag" style="color:#9ca3af">📑 Menú</div>';
|
||
echo '<div class="n-title">' . self::h($mk) . '</div>';
|
||
echo '<div class="n-info"><span>' . ($menu['body'] ? self::h($menu['body']) : 'Sin cuerpo') . '</span></div>';
|
||
echo '<div class="actions">';
|
||
echo '<button class="edit-btn" onclick="editMenu(\'' . self::h($mk) . '\')">✎ Editar</button>';
|
||
echo '</div>';
|
||
echo '</div>';
|
||
echo '<div class="connector-v thick"></div>';
|
||
self::renderMenuNode($mk, $menu, $flows, $menus, $companyId);
|
||
echo '</div>';
|
||
}
|
||
echo '</div>';
|
||
}
|
||
|
||
// ── Render orphan flows (not linked by any menu 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;
|
||
}
|
||
}
|