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}
ID HoraNúmero Nombre Tipo Preview
{$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 '
'; } } }