From daf1947f13a472796c607bad597c021394b222a6 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 13 Jun 2026 12:03:30 -0500 Subject: [PATCH] cambios importantes --- admin/DashboardController.php | 1021 +++++++++ admin/DashboardController.php.bak | 3400 +++++++++++++++++++++++++++++ public/index.php | 1 + 3 files changed, 4422 insertions(+) create mode 100644 admin/DashboardController.php.bak diff --git a/admin/DashboardController.php b/admin/DashboardController.php index bd6cd28..e346bf8 100644 --- a/admin/DashboardController.php +++ b/admin/DashboardController.php @@ -131,6 +131,7 @@ class DashboardController 🔄 Sincronizar 📨 Procesar Cola 🤖 Bot Config + 🔗 Hilo Conductor ⚙️ Configuración
@@ -589,6 +590,7 @@ HTML; 🔄 Sincronizar 📨 Procesar Cola 🤖 Bot Config + 🔗 Hilo Conductor ⚙️ Configuración @@ -783,6 +785,7 @@ HTML; 🔄 Sincronizar 📨 Procesar Cola 🤖 Bot Config + 🔗 Hilo Conductor ⚙️ Configuración
@@ -952,6 +955,7 @@ HTML; 🔄 Sincronizar 📨 Procesar Cola 🤖 Bot Config + 🔗 Hilo Conductor ⚙️ Configuración
@@ -1103,6 +1107,7 @@ HTML; 🔄 Sincronizar 📨 Procesar Cola 🤖 Bot Config + 🔗 Hilo Conductor ⚙️ Configuración
@@ -1251,6 +1256,7 @@ HTML; 🔄 Sincronizar 📨 Procesar Cola 🤖 Bot Config + 🔗 Hilo Conductor ⚙️ Configuración
@@ -1336,6 +1342,7 @@ HTML; 🔄 Sincronizar 📨 Procesar Cola 🤖 Bot Config + 🔗 Hilo Conductor ⚙️ Configuración
@@ -1450,6 +1457,7 @@ HTML; 💬 Chat 🏢 Empresas 🤖 Bot Config + 🔗 Hilo Conductor ⚙️ Configuración 🔄 Sincronizar 📨 Procesar Cola @@ -1619,6 +1627,7 @@ HTML; 💬 Chat 🏢 Empresas 🤖 Bot Config + 🔗 Hilo Conductor ⚙️ Configuración 🔄 Sincronizar 📨 Procesar Cola @@ -2031,6 +2040,7 @@ table.simple td{padding:8px 10px;border-bottom:1px solid #f0f2f5;font-size:13px; 💬 Chat 🏢 Empresas 🤖 Bot Config + 🔗 Hilo Conductor ⚙️ Configuración 🔄 Sincronizar 📨 Procesar Cola @@ -2388,4 +2398,1015 @@ function flowTypeChange(sel) { 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' ? '
Configuración guardada exitosamente.
' : ''; + + 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 << + + + + +Hilo Conductor — Palmas360 + + + +
+

Palmas360 Hilo Conductor

+
👤 {$userName}Salir
+
+ +
+ {$toastHtml} +
+ +
'; + + if (!$company) { + echo '
Selecciona una empresa para ver su hilo conductor.
'; + echo '
'; + 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 << + {$cName} + + ⚙️ Ir a Configuración Completa +
+ + + +
+
+ +
+ Leyenda: + Comando + Menú + Botón / Fila + Flujo (acción) + Sub-menú + Acción final +
+ +
+HTML; + + // ─── Render the conversation tree ──────────────────────────────── + self::renderConversationTree($config, $companyId); + + echo '
'; + + // ─── Inline JS for interactivity ───────────────────────────────── + $configJson = json_encode($config, JSON_UNESCAPED_UNICODE); + echo << +
+ +
+ + + + +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 '
🚀 No hay configuración aún. Crea comandos, menús y flujos para ver el hilo conductor.
'; + 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 '
'; + + // Greeting + if ($greeting !== '') { + echo '
'; + echo '
'; + echo '
👋 Entrada
'; + echo '
Mensaje de Bienvenida
'; + echo '
"' . self::h($greeting) . '"
'; + echo '
'; + echo '
'; + echo '
'; + } + + // ── Render commands (entry points) ── + $displayedMenus = []; + + // Render commands that point to menus + echo '
'; + echo '
'; + + foreach ($commands as $kw => $action) { + $targetType = isset($menus[$action]) ? 'menu' : (isset($flows[$action]) ? 'flow' : 'unknown'); + $targetIcon = $targetType === 'menu' ? '📑' : ($targetType === 'flow' ? '🔀' : '❓'); + + echo '
'; + echo '
'; + echo '
⌨️ Comando
'; + echo '
' . self::h($kw) . '
'; + echo '
→ ' . $targetIcon . ' ' . self::h($action) . '
'; + echo '
'; + echo ''; + echo '
'; + echo '
'; + + // Connect to target + echo '
'; + + // 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 '
'; + } + + echo '
'; + echo '
'; + + // ── Render menus that are not linked by any command ── + $orphanMenus = array_diff_key($menus, $displayedMenus); + if (!empty($orphanMenus)) { + echo '
'; + echo '
📌 Menús sin comando de entrada
'; + echo '
'; + foreach ($orphanMenus as $mk => $menu) { + echo '
'; + echo ''; + echo '
'; + self::renderMenuNode($mk, $menu, $flows, $menus, $companyId); + echo '
'; + } + echo '
'; + } + + // ── Render orphan flows (not linked by any menu row) ── + $linkedFlows = []; + foreach ($menus as $mk => $menu) { + foreach ($menu['sections'] ?? [] as $section) { + foreach ($section['rows'] ?? [] as $row) { + if (isset($flows[$row['id']])) { + $linkedFlows[$row['id']] = true; + } + } + } + } + foreach ($commands as $kw => $action) { + if (isset($flows[$action])) $linkedFlows[$action] = true; + } + $orphanFlows = array_diff_key($flows, $linkedFlows); + if (!empty($orphanFlows)) { + echo '
'; + echo '
📌 Flujos sin conectar
'; + echo '
'; + foreach ($orphanFlows as $fk => $flow) { + echo '
'; + echo '
'; + echo '
🔀 Flujo
'; + echo '
' . self::h($fk) . '
'; + echo '
'; + 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 '
'; + echo '
'; + echo ''; + echo '
'; + echo '
'; + echo '
'; + } + echo '
'; + } + + // ── Add buttons at bottom ── + echo '
'; + echo '
'; + echo ''; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo ''; + echo '
'; + + echo '
'; // root-node + } + + private static function renderMenuNode(string $menuKey, array $menu, array $flows, array $allMenus, int $companyId): void + { + $typeLabel = ($menu['type'] ?? 'list') === 'list' ? 'Lista' : 'Botones'; + $typeBadge = ($menu['type'] ?? 'list') === 'list' ? 'badge-list' : 'badge-button'; + $hasRows = false; + foreach ($menu['sections'] ?? [] as $section) { + if (!empty($section['rows'])) { $hasRows = true; break; } + } + + echo '
'; + echo ''; // node-card + + // ── Show first sub-menu connection inline ── + foreach ($menu['sections'] ?? [] as $section) { + foreach ($section['rows'] ?? [] as $row) { + $flow = $flows[$row['id']] ?? null; + if ($flow && $flow['type'] === 'menu' && isset($flow['menu'])) { + echo '
'; + echo '
'; + echo '
🔘 ' . self::h($row['title']) . ' →
'; + self::renderFlowNode($row['id'], $flow, $allMenus, $companyId, true); + echo '
'; + break 2; + } + } + } + + echo '
'; // 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 '
'; + echo '
' . $icon . ' ' . $typeLabel . '
'; + echo '
' . self::h($flowKey) . '
'; + + if ($flow['type'] === 'text') { + echo '
' . self::h(mb_substr($flow['message'] ?? '', 0, 80)) . '
'; + } elseif ($flow['type'] === 'menu' && isset($flow['menu']) && isset($allMenus[$flow['menu']])) { + echo '
→ ' . self::h($flow['menu']) . '
'; + } elseif ($flow['type'] === 'function') { + echo '
' . ($flow['function'] ?? 'forward_to_ai') . '
'; + } + + echo '
'; + echo ''; + echo '
'; + echo '
'; + + // 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 '
'; + echo '
'; + echo '
📑 Sub-menú: ' . self::h($subKey) . '
'; + if ($subMenu['body']) echo '
' . self::h($subMenu['body']) . '
'; + + // Show sub-menu's rows + $hasRows = false; + foreach ($subMenu['sections'] ?? [] as $section) { + if (!empty($section['rows'])) { $hasRows = true; break; } + } + if ($hasRows) { + echo '
'; + foreach ($subMenu['sections'] ?? [] as $section) { + foreach ($section['rows'] ?? [] as $row) { + echo '
'; + echo '🔘'; + echo '' . self::h($row['title']) . ''; + echo '' . self::h($row['id']) . ''; + echo '
'; + } + } + echo '
'; + } else { + echo '
Sin botones
'; + } + echo '
'; + echo ''; + echo '
'; + echo '
'; + } + } } diff --git a/admin/DashboardController.php.bak b/admin/DashboardController.php.bak new file mode 100644 index 0000000..c9e71a8 --- /dev/null +++ b/admin/DashboardController.php.bak @@ -0,0 +1,3400 @@ + 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 << + + + + + Live — Palmas360 + + + +
+
+

Palmas360 Live Feed

+
+
+ 👤 {$userName} + Salir +
+
+ + +
+ + Conectado — actualizando cada 3s + + 0 eventos +
+

Esperando eventos en tiempo real...

+ + + + + + +HTML; + exit; + } + + // ─── GET /admin/webhook/stream?after=N ─────────────────────────────────── + + public static function stream(): void + { + SessionAuth::require(); + $afterId = max(0, (int)($_GET['after'] ?? 0)); + $isInit = isset($_GET['init']); + + try { + if ($isInit) { + // En init devolvemos solo el último ID (sin datos) para anclar + $stmt = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1'); + $row = $stmt->fetch(); + $afterId = $row ? (int)$row['id'] : 0; + // Devolvemos array vacío — solo queremos anclar lastId en el cliente + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([]); + exit; + } + + $stmt = db()->prepare(" + SELECT id, event_field, from_number, contact_name, + message_type, message_preview, received_at + FROM webhook_logs + WHERE id > ? + ORDER BY id ASC + LIMIT 50 + "); + $stmt->execute([$afterId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (\PDOException $e) { + header('Content-Type: application/json; charset=utf-8'); + echo '[]'; + exit; + } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($rows, JSON_UNESCAPED_UNICODE); + exit; + } + + // ─── GET /admin/webhook/raw?id=N ───────────────────────────────────────── + + public static function getRaw(): void + { + SessionAuth::require(); + $id = max(0, (int)($_GET['id'] ?? 0)); + if ($id === 0) { + jsonResponse(400, ['error' => 'ID inválido']); + } + try { + $stmt = db()->prepare('SELECT raw_payload FROM webhook_logs WHERE id = ? LIMIT 1'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + } catch (\PDOException $e) { + jsonResponse(500, ['error' => 'Error de base de datos']); + } + if (!$row) { + jsonResponse(404, ['error' => 'Registro no encontrado']); + } + header('Content-Type: application/json; charset=utf-8'); + echo $row['raw_payload']; + exit; + } + + // ─── Consultas ──────────────────────────────────────────────────────────── + + private static function getStats(PDO $db, ?int $companyId = null): array + { + if ($companyId !== null) { + $stmt = $db->prepare(" + SELECT + COUNT(*) AS total, + COALESCE(SUM(event_field='messages' AND message_type='text'),0) AS msgs, + COALESCE(SUM(event_field='messages' AND message_type<>'text'),0) AS media, + COALESCE(SUM(event_field='statuses'),0) AS statuses + FROM webhook_logs + WHERE DATE(received_at) = CURDATE() AND company_id = ? + "); + $stmt->execute([$companyId]); + } else { + $stmt = $db->query(" + SELECT + COUNT(*) AS total, + COALESCE(SUM(event_field='messages' AND message_type='text'),0) AS msgs, + COALESCE(SUM(event_field='messages' AND message_type<>'text'),0) AS media, + COALESCE(SUM(event_field='statuses'),0) AS statuses + FROM webhook_logs + WHERE DATE(received_at) = CURDATE() + "); + } + return $stmt->fetch() ?: ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0]; + } + + private static function getLogs(PDO $db, int $page, string $filter, string $search, string $date, ?int $companyId = null): array + { + $where = ['DATE(received_at) = ?']; + $params = [$date]; + + if ($companyId !== null) { + $where[] = 'company_id = ?'; + $params[] = $companyId; + } + if ($filter !== '') { + $where[] = 'event_field = ?'; + $params[] = $filter; + } + if ($search !== '') { + $where[] = '(from_number LIKE ? OR contact_name LIKE ? OR message_preview LIKE ?)'; + $like = '%' . $search . '%'; + array_push($params, $like, $like, $like); + } + + $w = implode(' AND ', $where); + + $cnt = $db->prepare("SELECT COUNT(*) FROM webhook_logs WHERE $w"); + $cnt->execute($params); + $total = (int)$cnt->fetchColumn(); + + $limit = self::PER_PAGE; + $offset = ($page - 1) * $limit; + + $stmt = $db->prepare(" + SELECT wh.id, wh.event_field, wh.from_number, wh.contact_name, + wh.message_type, wh.message_preview, wh.received_at, + c.name AS company_name + FROM webhook_logs wh + LEFT JOIN companies c ON c.id = wh.company_id + WHERE $w + ORDER BY wh.received_at DESC + LIMIT $limit OFFSET $offset + "); + $stmt->execute($params); + return [$stmt->fetchAll(), $total]; + } + + // ─── Helpers de vista ───────────────────────────────────────────────────── + + private static function h($v): string + { + return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); + } + + private static function typeLabel(string $field, string $type): string + { + if ($field === 'statuses') { + switch ($type) { + case 'sent': $cls = 'badge-blue'; break; + case 'delivered': $cls = 'badge-green'; break; + case 'read': $cls = 'badge-teal'; break; + case 'failed': $cls = 'badge-red'; break; + default: $cls = 'badge-gray'; break; + } + return '' . self::h($type) . ''; + } + 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 = ''; + foreach ($v['companies'] ?? [] as $c) { + $sel = (int)($v['companyId'] ?? 0) === (int)$c['id'] ? 'selected' : ''; + $companyOptions .= "'; + } + $showCompany = empty($v['companyId']); + $companyTh = $showCompany ? 'Empresa' : ''; + + // Active nav link + $navDashboard = 'active'; + $navLive = ''; + $navPending = ''; + $navCompanies = ''; + + // Filas de la tabla + $rows = ''; + if (empty($v['logs'])) { + $colspan = $showCompany ? 8 : 7; + $rows = "Sin eventos para esta fecha."; + } 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 .= "" + . "#{$id}" + . "{$time}" + . ($showCompany ? "{$company}" : '') + . "{$from}" + . "{$name}" + . "{$type}" + . "{$preview}" + . "" + . "\n"; + } + } + + // Paginación + $pager = ''; + if ($v['pages'] > 1) { + $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 .= "{$i}"; + } + $pager .= '
'; + } + + echo << + + + + + Admin — Palmas360 + + + + +
+
+

Palmas360 Palmas360

+ Somos19D +
+
+ 👤 {$userName} + Salir +
+
+ + + +
+
{$sTotal}
📡 Total hoy
+
{$sMsgs}
💬 Mensajes texto
+
{$sMedia}
📎 Multimedia
+
{$sStatuses}
📊 Estados
+
{$sPending}
⏳ Pendientes aprobación
+
{$companyCount}
🏢 Empresas
+
+ +
+ 📡 Estado ERP + Cargando... +
+ +
+
+ + + + + + {$totalStr} resultado(s) +
+
+ +
+ + + + + + {$companyTh} + + + + + + + + {$rows} +
IDHoraNúmeroNombreTipoPreview
+ {$pager} +
+ + + + + + + + +HTML; + exit; + } + + // ─── GET /admin/pending ──────────────────────────────────────────────── + + public static function pending(): void + { + SessionAuth::require(); + $user = SessionAuth::user(); + $userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8'); + $companyId = isset($_GET['company_id']) ? (int)$_GET['company_id'] : null; + $items = $companyId ? PendingApproval::findByCompany($companyId, 'pending') : PendingApproval::findAll('pending'); + $companies = CompanyRepository::findAll(); + + http_response_code(200); + header('Content-Type: text/html; charset=utf-8'); + echo << + + + + + Pendientes — Palmas360 + + + +
+
+

Palmas360 Pendientes de Aprobación

+
+
+ 👤 {$userName} + Salir +
+
+ +
+
+ + '; + } + echo << +
+ + +
Nombre interno de la empresa
+
+
+ + +
Nombre visible en reportes (opcional)
+
+
+
+
+ + +
ID numérico del número de teléfono de WhatsApp Business
+
+
+ + +
Número telefónico visible (opcional)
+
+
+
+
+ + +
Endpoint base del ERP para esta empresa
+
+
+ + +
Clave API para autenticación ERP ↔ Bot (opcional)
+
+
+
+
+ + +
Comportamiento del bot: normal (reglas), AI (inteligente), híbrido (combinado)
+
+
+ + +
+
+ + +
+
+
+ + +
Configuración adicional del bot en formato JSON
+
+
+ + Cancelar +
+ +
+
+ + +HTML; + exit; + } + + // ─── GET /admin/sync-companies ────────────────────────────────────────── + + public static function syncCompanies(): void + { + SessionAuth::require(); + $user = SessionAuth::user(); + $userName = self::h($user['name'] ?? 'Admin'); + $result = ErpSync::sync(); + $hasError = isset($result['error']); + $icon = $hasError ? '❌' : '✅'; + $title = $hasError ? 'Error en Sincronización' : 'Sincronización Exitosa'; + $titleClass = $hasError ? 'error' : 'success'; + $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + + http_response_code(200); + header('Content-Type: text/html; charset=utf-8'); + echo << + + + + + Sincronizar — Palmas360 + + + +
+
+

Palmas360 Sincronizar Empresas

+
+
+ 👤 {$userName} + Salir +
+
+ +
+
+
{$icon}
+
{$title}
+
{$resultJson}
+ +
+
+ + +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 << + + + + + Procesar Cola — Palmas360 + + + +
+
+

Palmas360 Procesar Cola de Mensajes

+
+
+ 👤 {$userName} + Salir +
+
+ +
+
+
{$icon}
+
{$title}
+
{$resultJson}
+ +
+
+ + +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 = '
Configuración guardada exitosamente.
'; + } + + $fields = [ + 'WhatsApp Cloud API' => [ + ['key' => 'whatsapp_access_token', 'label' => 'Access Token', 'type' => 'password', 'placeholder' => 'EAAMzB...'], + ['key' => 'whatsapp_app_secret', 'label' => 'App Secret', 'type' => 'password', 'placeholder' => 'a4f82c1d...'], + ['key' => 'whatsapp_verify_token', 'label' => 'Verify Token', 'type' => 'text', 'placeholder' => 'PLM360_WH_...'], + ['key' => 'whatsapp_business_account_id', 'label' => 'Business Account ID', 'type' => 'text', 'placeholder' => '920641783052817'], + ['key' => 'whatsapp_default_phone_number_id', 'label' => 'Phone Number ID (default)', 'type' => 'text', 'placeholder' => '384710295638401'], + ['key' => 'verify_hmac_signature', 'label' => 'Validar firma HMAC de Meta', 'type' => 'checkbox', 'placeholder' => ''], + ], + 'Inteligencia Artificial' => [ + ['key' => 'ai_provider', 'label' => 'Proveedor', 'type' => 'select', 'options' => ['mock' => 'Mock (simulado)', 'openai' => 'OpenAI']], + ['key' => 'openai_api_key', 'label' => 'OpenAI API Key', 'type' => 'password', 'placeholder' => 'sk-...'], + ['key' => 'openai_model', 'label' => 'Modelo', 'type' => 'select', 'options' => ['gpt-4o-mini' => 'GPT-4o Mini', 'gpt-4o' => 'GPT-4o', 'gpt-3.5-turbo' => 'GPT-3.5 Turbo']], + ['key' => 'ai_max_tokens', 'label' => 'Máximo de tokens', 'type' => 'number', 'placeholder' => '500'], + ['key' => 'ai_default_prompt', 'label' => 'System Prompt por defecto', 'type' => 'textarea', 'placeholder' => 'Eres un asistente...'], + ], + ]; + + $versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 8); + + http_response_code(200); + header('Content-Type: text/html; charset=utf-8'); + echo << + + + + + Configuración — Palmas360 + + + +
+
+

Palmas360 Configuración

+
+
+ 👤 {$userName} + Salir +
+
+ +
+ {$toastHtml} +
+HTML; + foreach ($fields as $sectionTitle => $sectionFields) { + echo '
'; + echo '
' . self::h($sectionTitle) . '
'; + echo '
'; + 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 "
"; + echo ""; + if ($f['type'] === 'select' && !empty($f['options'])) { + echo ""; + } elseif ($f['type'] === 'textarea') { + echo ""; + } elseif ($f['type'] === 'number') { + echo ""; + } elseif ($f['type'] === 'checkbox') { + $checked = !empty($settings[$key]) ? ' checked' : ''; + echo ""; + } else { + echo ""; + } + echo "
"; + } + echo '
'; + } + echo << + Cancelar + +
+ +
Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.
+
+ + +HTML; + exit; + } + + // ─── Chat ──────────────────────────────────────────────────────────────── + + public static function chat(): void + { + SessionAuth::require(); + $user = SessionAuth::user(); + $userName = self::h($user['name'] ?? 'Admin'); + $companies = CompanyRepository::findAll(); + + http_response_code(200); + header('Content-Type: text/html; charset=utf-8'); + echo << + + + + + Chat — Palmas360 + + + +
+
+

Palmas360 Chat

+
+
+ 👤 {$userName} + Salir +
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
💬
+
Selecciona una conversación
+
+
+ + +
+
+
+ + + +HTML; + exit; + } + + // ─── API: lista de conversaciones ──────────────────────────────────────── + + public static function chatConversations(): void + { + SessionAuth::require(); + $db = db(); + // Get unique conversations with last message and unread count + $rows = $db->query(" + SELECT c.phone_number, c.contact_name, c.company_id, + MAX(c.created_at) as last_time, + (SELECT content FROM conversations c2 WHERE c2.phone_number = c.phone_number ORDER BY c2.id DESC LIMIT 1) as last_message, + (SELECT COUNT(*) FROM conversations c3 WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound' AND c3.id > COALESCE((SELECT MAX(c4.id) FROM conversations c4 WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'), 0)) as unread_count + FROM conversations c + GROUP BY c.phone_number, c.contact_name, c.company_id + ORDER BY last_time DESC + ")->fetchAll(); + + $companies = []; + foreach (CompanyRepository::findAll() as $co) { + $companies[(int)$co['id']] = $co['name']; + } + + $conversations = []; + foreach ($rows as $r) { + $cid = (int)$r['company_id']; + $conversations[] = [ + 'phone' => $r['phone_number'], + 'contact_name' => $r['contact_name'] ?? '', + 'company_name' => $companies[$cid] ?? '', + 'last_message' => mb_substr($r['last_message'] ?? '', 0, 80), + 'last_time' => $r['last_time'], + 'unread_count' => (int)$r['unread_count'], + ]; + } + + jsonResponse(200, ['conversations' => $conversations]); + } + + // ─── API: mensajes de una conversación ─────────────────────────────────── + + public static function chatMessages(): void + { + SessionAuth::require(); + $phone = trim($_GET['phone'] ?? ''); + if ($phone === '') jsonResponse(400, ['error' => 'phone requerido']); + + $stmt = db()->prepare(" + SELECT direction, message_type, content, media_id, status, created_at + FROM conversations + WHERE phone_number = ? + ORDER BY id ASC + "); + $stmt->execute([$phone]); + $messages = $stmt->fetchAll(); + + // Also include outbound_queue items for this phone + $stmt2 = db()->prepare(" + SELECT 'outbound' as direction, message_type, payload as content, NULL as media_id, status, created_at + FROM outbound_queue + WHERE to_number = ? AND status IN ('sent','delivered','failed') + AND id > COALESCE((SELECT MAX(c.id) FROM conversations c WHERE c.phone_number = ?), 0) + ORDER BY id ASC + "); + $stmt2->execute([$phone, $phone]); + $queueMsgs = $stmt2->fetchAll(); + + foreach ($queueMsgs as &$qm) { + $pl = json_decode($qm['content'], true); + $qm['content'] = $pl['text'] ?? $pl['caption'] ?? $qm['content']; + } + + $all = array_merge($messages, $queueMsgs); + usort($all, fn($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? '')); + + jsonResponse(200, ['messages' => $all]); + } + + // ─── API: enviar mensaje desde el chat ─────────────────────────────────── + + public static function chatSend(): void + { + SessionAuth::require(); + $input = json_decode(file_get_contents('php://input'), true); + $phone = trim($input['phone'] ?? ''); + $text = trim($input['text'] ?? ''); + if ($phone === '' || $text === '') { + jsonResponse(400, ['error' => 'phone y text requeridos']); + } + + // Find company for this phone (lookup by latest conversation) + $stmt = db()->prepare("SELECT company_id FROM conversations WHERE phone_number = ? ORDER BY id DESC LIMIT 1"); + $stmt->execute([$phone]); + $conv = $stmt->fetch(); + $companyId = $conv ? (int)$conv['company_id'] : 1; + + // Enqueue the message + $db = db(); + $payload = json_encode(['text' => $text]); + $qStmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload, status, created_at) VALUES (?, ?, 'text', ?, 'queued', NOW())"); + $qStmt->execute([$companyId, $phone, $payload]); + + // Add to conversations + $msgId = 'admin_' . time() . '_' . $phone; + $cStmt = $db->prepare("INSERT INTO conversations (company_id, message_id, phone_number, direction, message_type, content, created_at) VALUES (?, ?, ?, 'outbound', 'text', ?, NOW())"); + $cStmt->execute([$companyId, $msgId, $phone, $text]); + + jsonResponse(200, ['status' => 'sent']); + } + + // ─── GET /admin/bot-config ────────────────────────────────────────────── + + public static function botConfig(): void + { + SessionAuth::require(); + $user = SessionAuth::user(); + $userName = self::h($user['name'] ?? 'Admin'); + $companyId = (int)($_GET['id'] ?? 0); + $companies = CompanyRepository::findAll(); + $company = null; + $config = ['commands' => [], 'menus' => [], 'flows' => [], 'ai_prompt' => '', 'fallback' => '', 'greeting' => '']; + $msg = $_GET['msg'] ?? ''; + $toastHtml = $msg === 'saved' ? '
Configuración guardada exitosamente.
' : ''; + + if ($companyId > 0) { + $company = CompanyRepository::findById($companyId); + if ($company) { + $cj = $company['config_json'] ?? ''; + if ($cj) { + $parsed = json_decode($cj, true); + if (is_array($parsed)) $config = $parsed; + } + } + } + + $configJson = self::h(json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + http_response_code(200); + header('Content-Type: text/html; charset=utf-8'); + echo << + + + + +Configurar Bot — Palmas360 + + + +
+

Palmas360 Configurar Bot

+
👤 {$userName}Salir
+
+ +
+ {$toastHtml} +
+ +
'; + + if (!$company) { + echo '
Selecciona una empresa para configurar su bot.
'; + echo '
'; + exit; + } + + $c = $company; + $cId = (int)$c['id']; + $cName = self::h($c['name'] ?? ''); + + // Pre-fill form values from config + $greetingVal = self::h($config['greeting'] ?? ''); + $fallbackVal = self::h($config['fallback'] ?? ''); + $aiPromptVal = self::h($config['ai_prompt'] ?? ''); + $aiModelVal = self::h($config['ai_model'] ?? 'gpt-4o-mini'); + $aiTempVal = self::h((string)($config['ai_temperature'] ?? 0.7)); + $aiProviderVal = self::h($config['ai_provider'] ?? ''); + $approvalWebhookVal = self::h($config['approval_webhook'] ?? ''); + $ignoreVal = self::h(implode("\n", $config['ignore_prefixes'] ?? [])); + $commands = $config['commands'] ?? []; + $menus = $config['menus'] ?? []; + $flows = $config['flows'] ?? []; + $aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : ''; + $aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : ''; + $aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : ''; + $aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : ''; + $aiModel35 = $aiModelVal === 'gpt-3.5-turbo' ? ' selected' : ''; + + echo << + + +
+ + + + + +
+ + +
+
📋 Comandos
+

Palabras clave que el usuario escribe para ejecutar acciones. Ej: "menu", "info", "contacto"

+ + + +HTML; + $cmdIdx = 0; + foreach ($commands as $keyword => $action) { + echo " + + + + "; + $cmdIdx++; + } + echo << +
Palabra claveAcción (menú o flujo)
+ +
+ + +
+
📑 Menús Interactivos
+

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.

+ + + +
+
🔀 Flujos
+

Acciones que se ejecutan cuando un comando o fila de menú es seleccionada.

+
+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 "
+
+ ID: + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
"; + $flowIdx++; + } + echo << + +
+ + +
+
🤖 Inteligencia Artificial
+
+ + +
Si no se selecciona, usa el proveedor de Configuración General
+
+
+
+ + +
+
+ + +
+
+
+ + +
Instrucciones que define el comportamiento de la IA
+
+
+ + +
+
⚙️ General
+
+
+ + +
Primer mensaje que recibe un usuario nuevo
+
+
+ + +
Cuando el bot no entiende el mensaje
+
+
+
+ + +
URL a la que se notificará cuando se requiera aprobación
+
+
+ + +
Números que comiencen con estos prefijos no recibirán respuestas del bot
+
+
+ +
+ + ↩ Volver a empresas +
+ +
+ + + + +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' ? '
Configuración guardada exitosamente.
' : ''; + + 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 << + + + + +Hilo Conductor — Palmas360 + + + +
+

Palmas360 Hilo Conductor

+
👤 {$userName}Salir
+
+ +
+ {$toastHtml} +
+ +
'; + + if (!$company) { + echo '
Selecciona una empresa para ver su hilo conductor.
'; + echo '
'; + 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 << + {$cName} + + ⚙️ Ir a Configuración Completa +
+ + + +
+
+ +
+ Leyenda: + Comando + Menú + Botón / Fila + Flujo (acción) + Sub-menú + Acción final +
+ +
+HTML; + + // ─── Render the conversation tree ──────────────────────────────── + self::renderConversationTree($config, $companyId); + + echo '
'; + + // ─── Inline JS for interactivity ───────────────────────────────── + $configJson = json_encode($config, JSON_UNESCAPED_UNICODE); + echo << +
+ +
+ + + + +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 '
🚀 No hay configuración aún. Crea comandos, menús y flujos para ver el hilo conductor.
'; + 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 '
'; + + // Greeting + if ($greeting !== '') { + echo '
'; + echo '
'; + echo '
👋 Entrada
'; + echo '
Mensaje de Bienvenida
'; + echo '
"' . self::h($greeting) . '"
'; + echo '
'; + echo '
'; + echo '
'; + } + + // ── Render commands (entry points) ── + $displayedMenus = []; + + // Render commands that point to menus + echo '
'; + echo '
'; + + foreach ($commands as $kw => $action) { + $targetType = isset($menus[$action]) ? 'menu' : (isset($flows[$action]) ? 'flow' : 'unknown'); + $targetIcon = $targetType === 'menu' ? '📑' : ($targetType === 'flow' ? '🔀' : '❓'); + + echo '
'; + echo '
'; + echo '
⌨️ Comando
'; + echo '
' . self::h($kw) . '
'; + echo '
→ ' . $targetIcon . ' ' . self::h($action) . '
'; + echo '
'; + echo ''; + echo '
'; + echo '
'; + + // Connect to target + echo '
'; + + // 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 '
'; + } + + echo '
'; + echo '
'; + + // ── Render menus that are not linked by any command ── + $orphanMenus = array_diff_key($menus, $displayedMenus); + if (!empty($orphanMenus)) { + echo '
'; + echo '
📌 Menús sin comando de entrada
'; + echo '
'; + foreach ($orphanMenus as $mk => $menu) { + echo '
'; + echo ''; + echo '
'; + self::renderMenuNode($mk, $menu, $flows, $menus, $companyId); + echo '
'; + } + echo '
'; + } + + // ── Render orphan flows (not linked by any menu row) ── + $linkedFlows = []; + foreach ($menus as $mk => $menu) { + foreach ($menu['sections'] ?? [] as $section) { + foreach ($section['rows'] ?? [] as $row) { + if (isset($flows[$row['id']])) { + $linkedFlows[$row['id']] = true; + } + } + } + } + foreach ($commands as $kw => $action) { + if (isset($flows[$action])) $linkedFlows[$action] = true; + } + $orphanFlows = array_diff_key($flows, $linkedFlows); + if (!empty($orphanFlows)) { + echo '
'; + echo '
📌 Flujos sin conectar
'; + echo '
'; + foreach ($orphanFlows as $fk => $flow) { + echo '
'; + echo '
'; + echo '
🔀 Flujo
'; + echo '
' . self::h($fk) . '
'; + echo '
'; + 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 '
'; + echo '
'; + echo ''; + echo '
'; + echo '
'; + echo '
'; + } + echo '
'; + } + + // ── Add buttons at bottom ── + echo '
'; + echo '
'; + echo ''; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo ''; + echo '
'; + + echo '
'; // root-node + } + + private static function renderMenuNode(string $menuKey, array $menu, array $flows, array $allMenus, int $companyId): void + { + $typeLabel = ($menu['type'] ?? 'list') === 'list' ? 'Lista' : 'Botones'; + $typeBadge = ($menu['type'] ?? 'list') === 'list' ? 'badge-list' : 'badge-button'; + $hasRows = false; + foreach ($menu['sections'] ?? [] as $section) { + if (!empty($section['rows'])) { $hasRows = true; break; } + } + + echo '
'; + echo ''; // node-card + + // ── Show first sub-menu connection inline ── + foreach ($menu['sections'] ?? [] as $section) { + foreach ($section['rows'] ?? [] as $row) { + $flow = $flows[$row['id']] ?? null; + if ($flow && $flow['type'] === 'menu' && isset($flow['menu'])) { + echo '
'; + echo '
'; + echo '
🔘 ' . self::h($row['title']) . ' →
'; + self::renderFlowNode($row['id'], $flow, $allMenus, $companyId, true); + echo '
'; + break 2; + } + } + } + + echo '
'; // 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 '
'; + echo '
' . $icon . ' ' . $typeLabel . '
'; + echo '
' . self::h($flowKey) . '
'; + + if ($flow['type'] === 'text') { + echo '
' . self::h(mb_substr($flow['message'] ?? '', 0, 80)) . '
'; + } elseif ($flow['type'] === 'menu' && isset($flow['menu']) && isset($allMenus[$flow['menu']])) { + echo '
→ ' . self::h($flow['menu']) . '
'; + } elseif ($flow['type'] === 'function') { + echo '
' . ($flow['function'] ?? 'forward_to_ai') . '
'; + } + + echo '
'; + echo ''; + echo '
'; + echo '
'; + + // 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 '
'; + echo '
'; + echo '
📑 Sub-menú: ' . self::h($subKey) . '
'; + if ($subMenu['body']) echo '
' . self::h($subMenu['body']) . '
'; + + // Show sub-menu's rows + $hasRows = false; + foreach ($subMenu['sections'] ?? [] as $section) { + if (!empty($section['rows'])) { $hasRows = true; break; } + } + if ($hasRows) { + echo '
'; + foreach ($subMenu['sections'] ?? [] as $section) { + foreach ($section['rows'] ?? [] as $row) { + echo '
'; + echo '🔘'; + echo '' . self::h($row['title']) . ''; + echo '' . self::h($row['id']) . ''; + echo '
'; + } + } + echo '
'; + } else { + echo '
Sin botones
'; + } + echo '
'; + echo ''; + echo '
'; + echo '
'; + } + } +} diff --git a/public/index.php b/public/index.php index b391368..b3b8e3d 100644 --- a/public/index.php +++ b/public/index.php @@ -290,6 +290,7 @@ $routes = [ // ─── Admin: configurador visual del bot ──────────────────────────────── ['GET', '/admin/bot-config', fn() => DashboardController::botConfig()], + ['GET', '/admin/conversation-flow', fn() => DashboardController::conversationFlow()], ['POST', '/admin/bot-config/save', fn() => (function () { SessionAuth::require();