@@ -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
+
+
+
+
+
Hilo Conductor
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🤖 Bot Config
+ 🔗 Hilo Conductor
+ ⚙️ Configuración
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+
+
+ {$toastHtml}
+
+ Seleccionar empresa:
+
+ — Selecciona —
+HTML;
+ foreach ($companies as $c) {
+ $sel = $companyId === (int)$c['id'] ? 'selected' : '';
+ echo "" . self::h($c['name'] . ' — ' . ($c['display_name'] ?? '')) . " ";
+ }
+ echo '
';
+
+ 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 '
';
+ 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 '✎ Editar ';
+ 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 '✎ Editar ';
+ echo '
';
+ echo '
';
+ echo '
';
+ }
+ echo '
';
+ }
+
+ // ── Add buttons at bottom ──
+ echo '
';
+ echo '
';
+ echo '+ Comando ';
+ echo '+ Menú ';
+ echo '+ Flujo ';
+ echo '
';
+ echo '
';
+ echo '💾 Guardar Todo ';
+ 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 '✎ Editar ';
+ 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 '✎ Editar sub-menú ';
+ 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
+
+
+
+
+
+
Live Feed
+
+
+
👤 {$userName}
+
Salir
+
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ 🤖 Bot Config
+ ⚙️ Configuración
+
+
+
+ Conectado — actualizando cada 3s
+ ▮▮ Pausar
+ 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 = '
Todas las empresas ';
+ foreach ($v['companies'] ?? [] as $c) {
+ $sel = (int)($v['companyId'] ?? 0) === (int)$c['id'] ? 'selected' : '';
+ $companyOptions .= "
" . self::h($c['name'] ?? $c['display_name'] ?? '') . ' ';
+ }
+ $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} "
+ . "JSON "
+ . " \n";
+ }
+ }
+
+ // Paginación
+ $pager = '';
+ if ($v['pages'] > 1) {
+ $pager = '';
+ }
+
+ echo <<
+
+
+
+
+
Admin — Palmas360
+
+
+
+
+
+
+
Palmas360
+
Somos19D
+
+
+
👤 {$userName}
+
Salir
+
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes ({$sPending})
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ 🤖 Bot Config
+ ⚙️ Configuración
+
+
+
+
+
{$sMsgs}
💬 Mensajes texto
+
+
+
{$sPending}
⏳ Pendientes aprobación
+
{$companyCount}
🏢 Empresas
+
+
+
+ 📡 Estado ERP
+ Cargando...
+
+
+
+
+
+
+
+
+
+
+ ID
+ Hora
+ {$companyTh}
+ Número
+ Nombre
+ Tipo
+ Preview
+
+
+
+ {$rows}
+
+ {$pager}
+
+
+
+
+
+
+ 📦 Payload JSON
+ ✕
+
+
+
+
+
+
+
+
+
+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
+
+
+
+
+
+
Pendientes de Aprobación
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ 🤖 Bot Config
+ ⚙️ Configuración
+
+
+
';
+
+ if (empty($items)) {
+ echo '
✅ No hay mensajes pendientes de aprobación.
';
+ } else {
+ echo '
+ ID Empresa De Mensaje recibido Respuesta Estado Acción
+ ';
+ 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 "
+ {$id}
+ {$company}
+ {$from}
+ {$inMsg}
+ {$reply}
+ {$status}
+
+ ✓ Aprobar
+ ✗ Rechazar
+
+ ";
+ }
+ echo '
';
+ }
+ echo <<
+
+
+
+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 = '
' . $text . '
';
+ }
+ }
+
+ http_response_code(200);
+ header('Content-Type: text/html; charset=utf-8');
+ echo <<
+
+
+
+
+
Empresas — Palmas360
+
+
+
+
+
+
Empresas
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ 🤖 Bot Config
+ ⚙️ Configuración
+
+
+ {$toastHtml}
+
+
+HTML;
+ if (empty($companies)) {
+ echo '
';
+ } else {
+ echo '
+ ID Nombre WhatsApp Phone ID Teléfono Bot Type Aprueba Activo API URL Acciones
+ ';
+ 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 "
+ {$id}
+ {$name} {$dname}
+ {$pid}
+ {$phone}
+ {$bot}
+ ' . ($apr ? 'Sí' : 'No') . "
+ ' . ($act ? 'Sí' : 'No') . "
+ {$url}
+
+ ✎ Editar
+ 🗑 Eliminar
+
+ ";
+ }
+ echo '
';
+ }
+ echo <<
+
Total: {$companyCount} empresa(s)
+
+
+
+HTML;
+ exit;
+ }
+
+ // ─── GET /admin/company/edit ─────────────────────────────────────────────
+
+ public static function companyEdit(): void
+ {
+ SessionAuth::require();
+ $user = SessionAuth::user();
+ $userName = self::h($user['name'] ?? 'Admin');
+ $id = (int)($_GET['id'] ?? 0);
+ $isEdit = $id > 0;
+ $company = $isEdit ? CompanyRepository::findById($id) : null;
+
+ $cv = fn(string $k, string $d = '') => self::h($company[$k] ?? $d);
+ $name = $cv('name');
+ $display_name = $cv('display_name');
+ $phone_number_id = $cv('phone_number_id');
+ $display_phone = $cv('display_phone');
+ $api_base_url = $cv('api_base_url');
+ $api_key = $cv('api_key');
+ $config_json = $cv('config_json');
+ $bot_type = $company['bot_type'] ?? 'normal';
+ $requires_approval = !empty($company['requires_approval']);
+ $is_active = !empty($company['is_active']);
+ $reqAprChecked = $requires_approval ? 'checked' : '';
+ $isActChecked = $is_active ? 'checked' : '';
+
+ $botOptions = '';
+ foreach (['normal' => 'Normal', 'ai' => 'AI', 'hybrid' => 'Híbrido'] as $val => $label) {
+ $sel = $bot_type === $val ? 'selected' : '';
+ $botOptions .= "
{$label} \n";
+ }
+
+ $pageTitle = $isEdit ? 'Editar Empresa' : 'Nueva Empresa';
+
+ http_response_code(200);
+ header('Content-Type: text/html; charset=utf-8');
+ echo <<
+
+
+
+
+
{$pageTitle} — Palmas360
+
+
+
+
+
+
{$pageTitle}
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ 🤖 Bot Config
+ ⚙️ Configuración
+
+
+
+
+
+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
+
+
+
+
+
+
Sincronizar Empresas
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ 🤖 Bot Config
+ ⚙️ Configuración
+
+
+
+
{$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
+
+
+
+
+
+
Procesar Cola de Mensajes
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ 🤖 Bot Config
+ ⚙️ Configuración
+
+
+
+
{$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
+
+
+
+
+
+
Configuración
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🤖 Bot Config
+ ⚙️ Configuración
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+ ⚙️ Configuración
+
+
+
+
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
+
+
+
+
+
+
Chat
+
+
+
👤 {$userName}
+
Salir
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🤖 Bot Config
+ ⚙️ Configuración
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+
+
+
+
+
+
+
+
💬
+
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
+
+
+
+
+
Configurar Bot
+
+
+
+ 📊 Dashboard
+ 🔴 Live Feed
+ ⏳ Pendientes
+ 💬 Chat
+ 🏢 Empresas
+ 🤖 Bot Config
+ 🔗 Hilo Conductor
+ ⚙️ Configuración
+ 🔄 Sincronizar
+ 📨 Procesar Cola
+
+
+ {$toastHtml}
+
+ Seleccionar empresa:
+
+ — Selecciona —
+HTML;
+ foreach ($companies as $c) {
+ $sel = $companyId === (int)$c['id'] ? 'selected' : '';
+ echo "" . self::h($c['name'] . ' — ' . ($c['display_name'] ?? '')) . " ";
+ }
+ echo '
';
+
+ 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
+ 📑 Menús
+ 🔀 Flujos
+ 🤖 IA
+ ⚙️ General
+
+
+
+
+
📋 Comandos
+
Palabras clave que el usuario escribe para ejecutar acciones. Ej: "menu", "info", "contacto"
+
+
+ Agregar comando
+
+
+
+
+
+
+ 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 '
';
+ 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 '
'; // 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 '
'; // 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 '
';
+
+ // 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 '
';
+ }
+ }
+}
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();