0, 'msgs' => 0, 'media' => 0, 'statuses' => 0]; $pending = 0; $companies = []; $logs = []; $total = 0; } $pages = $total > 0 ? (int)ceil($total / self::PER_PAGE) : 1; $companyCount = count($companies); self::render(compact('stats', 'pending', 'companies', 'companyCount', 'companyId', 'logs', 'total', 'page', 'pages', 'filter', 'search', 'date', 'user')); } // ─── GET /admin/live ──────────────────────────────────────────────────── public static function live(): void { SessionAuth::require(); $user = SessionAuth::user(); $userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8'); http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo Layout::open('Live Feed', 'live', $user['name'] ?? 'Admin'); echo << .toolbar{padding:14px 24px;display:flex;align-items:center;gap:12px;background:#fff;margin:14px 24px 0;border-radius:10px;box-shadow:0 1px 5px rgba(0,0,0,.06)} .dot{width:10px;height:10px;border-radius:50%;background:#e74c3c;animation:pulse 1.2s infinite} @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}} .dot.paused{background:#aaa;animation:none} #status{font-size:13px;color:#555} .btn-pause{background:#0b3d91;color:#fff;border:none;border-radius:7px;padding:6px 16px;font-size:13px;cursor:pointer} .btn-pause:hover{background:#1565c0} .counter{margin-left:auto;font-size:12px;color:#888} .feed{padding:0 24px 24px;margin-top:12px;display:flex;flex-direction:column;gap:6px} .feed-card{background:#fff;border-radius:10px;padding:13px 16px;box-shadow:0 1px 4px rgba(0,0,0,.07);display:flex;align-items:flex-start;gap:12px;animation:slidein .3s ease} @keyframes slidein{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}} .card-icon{font-size:22px;line-height:1;flex-shrink:0;margin-top:2px} .card-body{flex:1;min-width:0} .card-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .card-phone{font-family:monospace;font-size:13px;font-weight:600;color:#0b3d91} .card-name{font-size:12px;color:#888} .card-time{margin-left:auto;font-size:11px;color:#bbb;white-space:nowrap} .card-preview{font-size:13px;color:#444;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .b-text{background:#e8f4fd;color:#1565c0} .b-image{background:#fce8fd;color:#7b1fa2} .b-audio{background:#e8fdf0;color:#1b5e20} .b-video{background:#fdf3e8;color:#e65100} .b-document{background:#e8eaf6;color:#283593} .b-sticker{background:#fff8e1;color:#f57f17} .b-reaction{background:#fce4ec;color:#880e4f} .b-location{background:#e0f2f1;color:#004d40} .b-interactive{background:#ede7f6;color:#4527a0} .b-button{background:#e3f2fd;color:#0d47a1} .b-status{background:#f3f4f6;color:#6b7280} .b-raw{background:#fef9c3;color:#854d0e} .b-other{background:#f5f5f5;color:#555}
Conectando... 0 eventos

Esperando eventos en tiempo real...

HTML; exit; } // ─── GET /admin/webhook/stream/sse — Server-Sent Events ───────────────── public static function streamSse(): void { SessionAuth::require(); header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); // disable nginx buffering header('Connection: keep-alive'); set_time_limit(35); ignore_user_abort(false); $lastId = max(0, (int)($_GET['after'] ?? 0)); if ($lastId === 0) { try { $row = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1')->fetch(); $lastId = $row ? (int)$row['id'] : 0; } catch (\PDOException $e) {} } // Tell the client to reconnect after 3s if connection drops echo "retry: 3000\n\n"; ob_flush(); flush(); $deadline = time() + 28; // release worker after 28s, client auto-reconnects while (!connection_aborted() && time() < $deadline) { try { $stmt = db()->prepare( 'SELECT id, event_field, from_number, contact_name, message_type, message_preview, received_at FROM webhook_logs WHERE id > ? ORDER BY id ASC LIMIT 20' ); $stmt->execute([$lastId]); $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); if ($rows) { $lastId = (int)end($rows)['id']; echo 'data: ' . json_encode($rows, JSON_UNESCAPED_UNICODE) . "\n\n"; } else { echo ": heartbeat\n\n"; } } catch (\PDOException $e) { echo ": db-error\n\n"; } ob_flush(); flush(); // Sleep in 200ms chunks so connection_aborted() is checked frequently for ($i = 0; $i < 10 && !connection_aborted(); $i++) { usleep(200000); } } exit; } // ─── GET /admin/webhook/stream?after=N ─────────────────────────────────── public static function stream(): void { SessionAuth::require(); $afterId = max(0, (int)($_GET['after'] ?? 0)); $isInit = isset($_GET['init']); try { if ($isInit) { $row = db()->query('SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1')->fetch(); $lastId = $row ? (int)$row['id'] : 0; header('Content-Type: application/json; charset=utf-8'); echo json_encode(['rows' => [], 'last_id' => $lastId]); exit; } $stmt = db()->prepare(" SELECT id, event_field, from_number, contact_name, message_type, message_preview, received_at FROM webhook_logs WHERE id > ? ORDER BY id ASC LIMIT 50 "); $stmt->execute([$afterId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (\PDOException $e) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['rows' => [], 'last_id' => $afterId]); exit; } header('Content-Type: application/json; charset=utf-8'); echo json_encode(['rows' => $rows, 'last_id' => $rows ? (int)end($rows)['id'] : $afterId], JSON_UNESCAPED_UNICODE); exit; } // ─── GET /admin/webhook/raw?id=N ───────────────────────────────────────── public static function getRaw(): void { SessionAuth::require(); $id = max(0, (int)($_GET['id'] ?? 0)); if ($id === 0) { jsonResponse(400, ['error' => 'ID inválido']); } try { $stmt = db()->prepare('SELECT raw_payload FROM webhook_logs WHERE id = ? LIMIT 1'); $stmt->execute([$id]); $row = $stmt->fetch(); } catch (\PDOException $e) { jsonResponse(500, ['error' => 'Error de base de datos']); } if (!$row) { jsonResponse(404, ['error' => 'Registro no encontrado']); } header('Content-Type: application/json; charset=utf-8'); echo $row['raw_payload']; exit; } // ─── Consultas ──────────────────────────────────────────────────────────── private static function getStats(PDO $db, ?int $companyId = null): array { if ($companyId !== null) { $stmt = $db->prepare(" SELECT COUNT(*) AS total, COALESCE(SUM(event_field='messages' AND message_type='text'),0) AS msgs, COALESCE(SUM(event_field='messages' AND message_type<>'text'),0) AS media, COALESCE(SUM(event_field='statuses'),0) AS statuses FROM webhook_logs WHERE DATE(received_at) = CURDATE() AND company_id = ? "); $stmt->execute([$companyId]); } else { $stmt = $db->query(" SELECT COUNT(*) AS total, COALESCE(SUM(event_field='messages' AND message_type='text'),0) AS msgs, COALESCE(SUM(event_field='messages' AND message_type<>'text'),0) AS media, COALESCE(SUM(event_field='statuses'),0) AS statuses FROM webhook_logs WHERE DATE(received_at) = CURDATE() "); } return $stmt->fetch() ?: ['total' => 0, 'msgs' => 0, 'media' => 0, 'statuses' => 0]; } private static function getLogs(PDO $db, int $page, string $filter, string $search, string $date, ?int $companyId = null): array { $where = ['DATE(received_at) = ?']; $params = [$date]; if ($companyId !== null) { $where[] = 'company_id = ?'; $params[] = $companyId; } if ($filter !== '') { $where[] = 'event_field = ?'; $params[] = $filter; } if ($search !== '') { $where[] = '(from_number LIKE ? OR contact_name LIKE ? OR message_preview LIKE ?)'; $like = '%' . $search . '%'; array_push($params, $like, $like, $like); } $w = implode(' AND ', $where); $cnt = $db->prepare("SELECT COUNT(*) FROM webhook_logs WHERE $w"); $cnt->execute($params); $total = (int)$cnt->fetchColumn(); $limit = self::PER_PAGE; $offset = ($page - 1) * $limit; $stmt = $db->prepare(" SELECT wh.id, wh.event_field, wh.from_number, wh.contact_name, wh.message_type, wh.message_preview, wh.received_at, c.name AS company_name FROM webhook_logs wh LEFT JOIN companies c ON c.id = wh.company_id WHERE $w ORDER BY wh.received_at DESC LIMIT $limit OFFSET $offset "); $stmt->execute($params); return [$stmt->fetchAll(), $total]; } // ─── Helpers de vista ───────────────────────────────────────────────────── private static function h($v): string { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); } private static function typeLabel(string $field, string $type): string { if ($field === 'statuses') { switch ($type) { case 'sent': $cls = 'badge-blue'; break; case 'delivered': $cls = 'badge-green'; break; case 'read': $cls = 'badge-teal'; break; case 'failed': $cls = 'badge-red'; break; default: $cls = 'badge-gray'; break; } return '' . 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' : ''; // 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 Layout::open('Admin', 'dashboard', $v['user']['name'] ?? 'Admin', $sPending); echo << /* Stats */ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;padding:20px 28px 0} .sc{background:#fff;border-radius:12px;padding:18px 20px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03);border-left:4px solid #1565c0;position:relative;overflow:hidden;text-decoration:none;display:block;transition:transform .15s,box-shadow .15s;cursor:pointer} .sc:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.05)} .sc::after{content:'';position:absolute;inset:0;opacity:.03;background:linear-gradient(135deg,#1565c0,transparent 60%)} .sc .v{font-size:28px;font-weight:800;color:#0b3d91;letter-spacing:-.03em;line-height:1.1} .sc .l{font-size:12px;color:#7a8291;margin-top:4px;font-weight:500} .sc.g{border-color:#27ae60}.sc.g::after{background:linear-gradient(135deg,#27ae60,transparent 60%)}.sc.g .v{color:#1a7a3a} .sc.o{border-color:#e67e22}.sc.o::after{background:linear-gradient(135deg,#e67e22,transparent 60%)}.sc.o .v{color:#b85e0e} .sc.s{border-color:#5f6b7a}.sc.s::after{background:linear-gradient(135deg,#5f6b7a,transparent 60%)}.sc.s .v{color:#3d4552} .sc.r{border-color:#e74c3c}.sc.r::after{background:linear-gradient(135deg,#e74c3c,transparent 60%)}.sc.r .v{color:#e74c3c} /* Filters */ .fbar{padding:14px 24px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;background:#fff;margin:16px 28px 0;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03)} .fbar input,.fbar select{padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;background:#fafbfc;transition:all .15s;color:#1a1d23} .fbar input:focus,.fbar select:focus{border-color:#1565c0;background:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)} .bf{background:#0b3d91;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s} .bf:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)} .rc{margin-left:auto;font-size:12px;color:#7a8291} /* Table */ .tw{margin:14px 28px 28px;background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03);overflow:hidden;border:1px solid #eef1f5} .company-cell{font-weight:500;color:#0b3d91;font-size:12px} .phone{font-family:'SF Mono',Monaco,monospace;font-size:13px;color:#3d4552} .preview{color:#5f6b7a;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .btn-json{background:#f0f4ff;color:#0b3d91;border:1px solid #d6e4ff;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:600;cursor:pointer;transition:all .15s} .btn-json:hover{background:#dbeafe;border-color:#0b3d91} .pager{display:flex;justify-content:center;padding:16px;gap:4px;flex-wrap:wrap} .pg{padding:6px 14px;border:1px solid #e2e5ea;border-radius:8px;text-decoration:none;color:#0b3d91;font-size:13px;background:#fff;transition:all .15s;font-weight:500} .pg.active{background:#0b3d91;color:#fff;border-color:#0b3d91;box-shadow:0 2px 8px rgba(11,61,145,.2)} .pg:hover:not(.active){background:#f0f4ff;border-color:#0b3d91} .erp-bar{display:flex;align-items:center;gap:12px;padding:10px 28px;flex-wrap:wrap} .erp-label{font-size:11px;font-weight:700;color:#7a8291;text-transform:uppercase;letter-spacing:.5px} .erp-load{font-size:12px;color:#7a8291;display:flex;align-items:center;gap:6px;flex-wrap:wrap} .erp-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:3px;box-shadow:0 0 4px rgba(0,0,0,.1)} .erp-dot.up{background:#27ae60} .erp-dot.down{background:#e74c3c} .erp-dot.degraded{background:#e67e22} .erp-dot.unknown{background:#95a5a6} .erp-badge{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:20px;font-size:11px;font-weight:600;margin:2px 4px} .erp-badge.up{background:#e8f8f0;color:#1e8449;border:1px solid #a3e4bc} .erp-badge.down{background:#fdecea;color:#c0392b;border:1px solid #f5b7b1} .erp-badge.degraded{background:#fef5e7;color:#ca6f1e;border:1px solid #f9e79f} .erp-badge.unknown{background:#f3f4f6;color:#616a6b;border:1px solid #e2e5ea} /* Drawer cards */ .dp-card{background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:14px;transition:opacity .3s} .dp-top{display:flex;align-items:center;gap:8px;margin-bottom:6px} .dp-phone{font-family:monospace;font-size:12px;font-weight:600;color:#3d4552} .dp-name{font-size:12px;color:#5f6b7a;flex:1} .dp-time{font-size:11px;color:#a0a8b8} .dp-in{font-size:12px;color:#5f6b7a;background:#fff;border-radius:6px;padding:6px 10px;margin-bottom:4px;border:1px solid #eef1f5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .dp-resp{font-size:12px;color:#1a7a3a;background:#e8f8f0;border-radius:6px;padding:6px 10px;margin-bottom:10px;border:1px solid #a3e4bc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .dp-actions{display:flex;gap:8px} .dp-approve{flex:1;background:#166534;color:#fff;border:none;border-radius:8px;padding:8px;font-size:12px;font-weight:700;cursor:pointer;transition:background .15s} .dp-approve:hover{background:#1a7a3a} .dp-reject{flex:1;background:#991b1b;color:#fff;border:none;border-radius:8px;padding:8px;font-size:12px;font-weight:700;cursor:pointer;transition:background .15s} .dp-reject:hover{background:#b91c1c} .dp-approve:disabled,.dp-reject:disabled{opacity:.5;cursor:not-allowed} @media(max-width:768px){ .stats{grid-template-columns:repeat(2,1fr);padding:14px 16px 0} .fbar,.stats,.tw{padding-left:12px;padding-right:12px;margin-left:0;margin-right:0} .tw{margin:8px 12px 16px} th:nth-child(4),td:nth-child(4){display:none} #drawer{width:100vw} }
{$sTotal}
📡 Total hoy
{$sMsgs}
💬 Mensajes texto
{$sMedia}
📎 Multimedia
{$sStatuses}
📊 Estados
{$sPending}
⏳ Pendientes (clic para ver)
{$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 pendingList(): void { SessionAuth::require(); header('Content-Type: application/json'); $items = PendingApproval::findAll('pending'); echo json_encode(['items' => $items], JSON_UNESCAPED_UNICODE); exit; } public static function pending(): void { SessionAuth::require(); $user = SessionAuth::user(); $userName = htmlspecialchars($user['name'] ?? 'Admin', ENT_QUOTES, 'UTF-8'); $companyId = isset($_GET['company_id']) ? (int)$_GET['company_id'] : null; $items = $companyId ? PendingApproval::findByCompany($companyId, 'pending') : PendingApproval::findAll('pending'); $companies = CompanyRepository::findAll(); $sPending = PendingApproval::countPending(null); http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo Layout::open('Pendientes de Aprobación', 'pending', $user['name'] ?? 'Admin', $sPending); echo << .wrap{padding:24px;max-width:1000px;margin:0 auto} .badge-pending{background:#fef3c7;color:#92400e;border:1px solid #f9e79f} .fbar{display:flex;gap:12px;align-items:center;padding:16px 0;flex-wrap:wrap} .fbar select,.fbar button{padding:8px 14px;border-radius:8px;border:1px solid #e2e5ea;font-size:13px;outline:none} .fbar select{background:#fafbfc} .fbar select:focus{border-color:#1565c0;background:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)} .fbar button{background:#0b3d91;color:#fff;border:none;cursor:pointer;font-weight:600;transition:all .15s} .fbar button:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)} .preview{max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#5f6b7a} .btn-sm.rej{background:#991b1b} .btn-sm.rej:hover{background:#b91c1c;box-shadow:0 2px 8px rgba(153,27,27,.2)} .msg{display:inline-block;padding:7px 14px;background:#f0f4ff;border-radius:10px;max-width:300px;font-size:13px;word-break:break-word;color:#1a1d23} @media(max-width:768px){.wrap{padding:12px;overflow-x:auto}}
HTML; // Endpoints $epRows = db()->prepare("SELECT endpoint_key, url, method, last_response, last_called_at FROM company_endpoints WHERE company_id=?"); $epRows->execute([$id]); $epMap = []; foreach ($epRows->fetchAll(\PDO::FETCH_ASSOC) as $r) $epMap[$r['endpoint_key']] = $r; $catalog = self::endpointCatalog(); $renderEpSection = function(string $dir, string $dirLabel, string $badge) use ($catalog, $epMap, $id): string { $html = "

{$badge} {$dirLabel}

"; foreach ($catalog[$dir] as $key => $label) { $saved = $epMap[$key] ?? []; $url = self::h($saved['url'] ?? ''); $meth = $saved['method'] ?? 'GET'; $lastAt = $saved['last_called_at'] ? '' . self::h($saved['last_called_at']) . '' : ''; $mSel = fn($v) => $meth === $v ? 'selected' : ''; $html .= <<
{$label} {$lastAt}
EP; } return $html; }; $epHtml = $renderEpSection('upload', 'Subir información (WhatsApp → ERP)', '↑ Upload') . '
' . $renderEpSection('download', 'Bajar información (ERP → WhatsApp)', '↓ Download'); } $tabsHtml = $isEdit ? << HTML : ''; echo << .tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:10px 18px;font-size:13px;font-weight:500;color:#6b7280;cursor:pointer;transition:color .12s,border-color .12s;margin-bottom:-1px} .tab-btn.active{color:#111827;border-bottom-color:#111827} .tab-panel{display:none}.tab-panel.active{display:block}

{$pageTitle}

← Empresas
{$tabsHtml}
HTML; if ($isEdit) echo ''; echo <<
Base URL del ERP de esta empresa
Define los límites de números WA. Se actualiza con sincronización ERP.
Cancelar
HTML; if ($isEdit) { echo <<
{$phonesHtml}
{$epHtml}
HTML; } echo ''; echo Layout::close(); } // ─── POST /admin/company/phones/sync ───────────────────────────────────── public static function companyPhonesSync(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $companyId = (int)($_POST['company_id'] ?? 0); if ($companyId <= 0) { echo json_encode(['ok' => false, 'error' => 'ID de empresa inválido']); exit; } $company = CompanyRepository::findById($companyId); if (!$company) { echo json_encode(['ok' => false, 'error' => 'Empresa no encontrada']); exit; } // Buscar endpoint numeros_dn configurado para esta empresa $epRow = db()->prepare("SELECT url, method FROM company_endpoints WHERE company_id=? AND endpoint_key='numeros_dn' LIMIT 1"); $epRow->execute([$companyId]); $ep = $epRow->fetch(\PDO::FETCH_ASSOC); if (!$ep || empty($ep['url'])) { echo json_encode(['ok' => false, 'error' => 'El endpoint "Informe de números activos" no está configurado en la pestaña Endpoints API.']); exit; } // Llamar al endpoint $apiKey = $company['api_key'] ?? ''; $ch = curl_init($ep['url']); $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_HTTPHEADER => ['Accept: application/json'], ]; if ($apiKey !== '') $opts[CURLOPT_HTTPHEADER][] = 'Authorization: Bearer ' . $apiKey; if (strtoupper($ep['method'] ?? 'GET') === 'POST') { $opts[CURLOPT_POST] = true; $opts[CURLOPT_POSTFIELDS] = '{}'; $opts[CURLOPT_HTTPHEADER][] = 'Content-Type: application/json'; } curl_setopt_array($ch, $opts); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); if ($curlErr) { echo json_encode(['ok' => false, 'error' => 'No se pudo conectar al ERP.', 'detail' => $curlErr]); exit; } if ($httpCode !== 200) { echo json_encode(['ok' => false, 'error' => "El ERP respondió HTTP {$httpCode}.", 'detail' => substr((string)$resp, 0, 300)]); exit; } $data = json_decode($resp, true); if (!is_array($data)) { echo json_encode(['ok' => false, 'error' => 'La respuesta del ERP no es JSON válido.', 'detail' => substr((string)$resp, 0, 300)]); exit; } // Aceptar root array o {numeros:[...]} $numeros = isset($data['numeros']) ? $data['numeros'] : (array_values($data) && is_array($data[0] ?? null) ? $data : []); if (empty($numeros)) { echo json_encode(['ok' => false, 'error' => 'El JSON no contiene números. Espera un array en "numeros" o un array raíz.', 'detail' => substr($resp, 0, 300)]); exit; } $inserted = 0; $updated = 0; $skipped = 0; $pTypeMap = ['1' => 1, '2' => 2, '3' => 3, 1 => 1, 2 => 2, 3 => 3]; $stmt = db()->prepare(" INSERT INTO company_phones (company_id, wa_number, label, permission_type) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE label=VALUES(label), permission_type=VALUES(permission_type), is_active=1 "); foreach ($numeros as $n) { $waNumber = preg_replace('/\D/', '', (string)($n['wa_number'] ?? $n['numero'] ?? $n['phone'] ?? '')); if (strlen($waNumber) < 7) { $skipped++; continue; } $label = trim((string)($n['nombre'] ?? $n['name'] ?? $n['label'] ?? '')); $permRaw = $n['permiso'] ?? $n['permission_type'] ?? $n['tipo'] ?? 3; $permission = $pTypeMap[(int)$permRaw] ?? 3; try { $before = db()->prepare("SELECT id FROM company_phones WHERE company_id=? AND wa_number=?"); $before->execute([$companyId, $waNumber]); $exists = $before->fetch(); $stmt->execute([$companyId, $waNumber, $label, $permission]); $exists ? $updated++ : $inserted++; } catch (\PDOException $e) { $skipped++; } } echo json_encode([ 'ok' => true, 'message' => "Sincronización completa: {$inserted} nuevos, {$updated} actualizados, {$skipped} omitidos.", ]); exit; } // ─── POST /admin/company/phone/save ────────────────────────────────────── public static function companyPhoneSave(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $companyId = (int)($_POST['company_id'] ?? 0); $waNumber = preg_replace('/\D/', '', $_POST['wa_number'] ?? ''); $label = trim($_POST['label'] ?? ''); $permissionType = (int)($_POST['permission_type'] ?? 1); if ($companyId <= 0 || strlen($waNumber) < 7) { echo json_encode(['ok' => false, 'error' => 'Datos inválidos']); exit; } if (!in_array($permissionType, [1, 2, 3])) { echo json_encode(['ok' => false, 'error' => 'Tipo de permiso inválido']); exit; } $company = CompanyRepository::findById($companyId); if (!$company) { echo json_encode(['ok' => false, 'error' => 'Empresa no encontrada']); exit; } $erpUsers = (int)($company['erp_active_users'] ?? 0); $limitT1 = max(1, $erpUsers * 10); $limitT23 = max(1, $erpUsers); // Contar números actuales $counts = db()->prepare("SELECT permission_type, COUNT(*) as cnt FROM company_phones WHERE company_id=? AND is_active=1 GROUP BY permission_type"); $counts->execute([$companyId]); $cntMap = []; foreach ($counts->fetchAll(\PDO::FETCH_ASSOC) as $r) $cntMap[(int)$r['permission_type']] = (int)$r['cnt']; $cntT1 = $cntMap[1] ?? 0; $cntT23 = ($cntMap[2] ?? 0) + ($cntMap[3] ?? 0); if ($permissionType === 1 && $cntT1 >= $limitT1) { echo json_encode(['ok' => false, 'error' => "Límite alcanzado: máximo {$limitT1} números de tipo 1 (usuarios ERP × 10)"]); exit; } if ($permissionType !== 1 && $cntT23 >= $limitT23) { echo json_encode(['ok' => false, 'error' => "Límite alcanzado: máximo {$limitT23} números de tipo 2/3 (igual a usuarios ERP)"]); exit; } try { db()->prepare("INSERT INTO company_phones (company_id, wa_number, label, permission_type) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE label=VALUES(label), permission_type=VALUES(permission_type), is_active=1") ->execute([$companyId, $waNumber, $label, $permissionType]); echo json_encode(['ok' => true, 'message' => "Número +{$waNumber} agregado"]); } catch (\PDOException $e) { echo json_encode(['ok' => false, 'error' => 'Error DB: ' . $e->getMessage()]); } exit; } // ─── POST /admin/company/phone/delete ──────────────────────────────────── public static function companyPhoneDelete(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { echo json_encode(['ok' => false, 'error' => 'ID inválido']); exit; } db()->prepare("DELETE FROM company_phones WHERE id=?")->execute([$id]); echo json_encode(['ok' => true]); exit; } // ─── POST /admin/company/endpoint/save ─────────────────────────────────── public static function companyEndpointSave(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $companyId = (int)($_POST['company_id'] ?? 0); $key = preg_replace('/[^a-z0-9_]/', '', $_POST['endpoint_key'] ?? ''); $direction = in_array($_POST['direction'] ?? '', ['upload','download']) ? $_POST['direction'] : null; $url = trim($_POST['url'] ?? ''); $method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET'; if ($companyId <= 0 || $key === '' || $direction === null) { echo json_encode(['ok' => false, 'error' => 'Datos inválidos']); exit; } try { db()->prepare("INSERT INTO company_endpoints (company_id, endpoint_key, direction, url, method) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE url=VALUES(url), method=VALUES(method)") ->execute([$companyId, $key, $direction, $url, $method]); echo json_encode(['ok' => true]); } catch (\PDOException $e) { echo json_encode(['ok' => false, 'error' => $e->getMessage()]); } exit; } // ─── POST /admin/company/endpoint/test ─────────────────────────────────── public static function companyEndpointTest(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $companyId = (int)($_POST['company_id'] ?? 0); $key = preg_replace('/[^a-z0-9_]/', '', $_POST['endpoint_key'] ?? ''); $url = trim($_POST['url'] ?? ''); $method = in_array(strtoupper($_POST['method'] ?? 'GET'), ['GET','POST']) ? strtoupper($_POST['method']) : 'GET'; if ($url === '') { echo json_encode(['ok' => false, 'error' => 'URL vacía']); exit; } $company = CompanyRepository::findById($companyId); $apiKey = $company['api_key'] ?? ''; $ch = curl_init($url); $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/json'], ]; if ($apiKey !== '') $opts[CURLOPT_HTTPHEADER][] = 'Authorization: Bearer ' . $apiKey; if ($method === 'POST') { $opts[CURLOPT_POST] = true; $opts[CURLOPT_POSTFIELDS] = '{}'; } curl_setopt_array($ch, $opts); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($err) { echo json_encode(['ok' => false, 'error' => 'cURL: ' . $err]); exit; } $decoded = json_decode($resp, true); $preview = is_array($decoded) ? $decoded : (string)$resp; // Guardar última respuesta if ($key !== '') { db()->prepare("UPDATE company_endpoints SET last_response=?, last_called_at=NOW() WHERE company_id=? AND endpoint_key=?") ->execute([is_string($resp) ? substr($resp, 0, 2000) : '', $companyId, $key]); } if ($httpCode >= 200 && $httpCode < 300) { echo json_encode(['ok' => true, 'http_code' => $httpCode, 'response' => $preview]); } else { echo json_encode(['ok' => false, 'error' => "HTTP {$httpCode}", 'response' => $preview]); } exit; } // ─── GET /admin/sync-companies ────────────────────────────────────────── public static function syncCompanies(): void { SessionAuth::require(); $user = SessionAuth::user(); $userName = self::h($user['name'] ?? 'Admin'); $result = ErpSync::sync(); $hasError = isset($result['error']); $icon = $hasError ? '❌' : '✅'; $title = $hasError ? 'Error en Sincronización' : 'Sincronización Exitosa'; $titleClass = $hasError ? 'error' : 'success'; $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo Layout::open('Sincronizar Empresas', 'companies', $user['name'] ?? 'Admin'); echo << .wrap{padding:24px;max-width:800px;margin:0 auto} .btn-row{text-align:center;margin-top:24px}
{$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 Layout::open('Procesar Cola de Mensajes', 'dashboard', $user['name'] ?? 'Admin'); echo << .wrap{padding:24px;max-width:800px;margin:0 auto} .btn-row{text-align:center;margin-top:24px}
{$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', 'gemini' => 'Google Gemini']], ['key' => 'openai_api_key', 'label' => 'OpenAI API Key', 'type' => 'password', 'placeholder' => 'sk-...'], ['key' => 'openai_model', 'label' => 'Modelo OpenAI', 'type' => 'select', 'options' => ['gpt-4o-mini' => 'GPT-4o Mini', 'gpt-4o' => 'GPT-4o', 'gpt-3.5-turbo' => 'GPT-3.5 Turbo']], ['key' => 'gemini_api_key', 'label' => 'Google Gemini API Key', 'type' => 'password', 'placeholder' => 'AIza...'], ['key' => 'gemini_model', 'label' => 'Modelo Gemini', 'type' => 'select', 'options' => ['gemini-2.0-flash' => 'Gemini 2.0 Flash', 'gemini-1.5-flash' => 'Gemini 1.5 Flash', 'gemini-1.5-pro' => 'Gemini 1.5 Pro']], ['key' => 'ai_max_tokens', 'label' => 'Máximo de tokens', 'type' => 'number', 'placeholder' => '500'], ['key' => 'ai_default_prompt', 'label' => 'System Prompt por defecto', 'type' => 'textarea', 'placeholder' => 'Eres un asistente...'], ], ]; $versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 8); http_response_code(200); header('Content-Type: text/html; charset=utf-8'); echo Layout::open('Configuración', 'settings', $user['name'] ?? 'Admin'); echo << .wrap{padding:24px;max-width:900px;margin:0 auto} .form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px} .fw{grid-column:1/-1} .lbl{display:block;font-size:12px;font-weight:600;color:#555;margin-bottom:4px} .inp{width:100%;padding:9px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;outline:none;transition:border .2s} .inp:focus{border-color:#1565c0} .inp-sel{padding:9px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;outline:none;background:#fff;cursor:pointer} textarea.inp{min-height:90px;resize:vertical;font-family:inherit} .btn-row{display:flex;justify-content:flex-end;gap:10px;margin-top:18px} .chk-lbl{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500;color:#333;cursor:pointer;padding:8px 0} .chk-lbl input[type=checkbox]{width:18px;height:18px;accent-color:#0b3d91} .version-info{margin-top:16px;padding:12px;background:#f8f9fb;border-radius:8px;font-size:12px;color:#888;text-align:center} small{color:#888;font-size:11px;display:block;margin-top:2px} @media(max-width:640px){.form-grid{grid-template-columns:1fr}.wrap{padding:12px}}
{$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
Probar conexión IA
Guarda primero la configuración, luego prueba la conexión. Se enviará un mensaje de prueba al proveedor activo.
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 Layout::open('Chat', 'chat', $user['name'] ?? 'Admin'); echo << body{overflow:hidden;height:100vh} .chat-layout{display:flex;height:calc(100vh - 56px);overflow:hidden} .conv-sidebar{width:340px;min-width:340px;background:#fff;border-right:1px solid #eef1f5;display:flex;flex-direction:column;overflow:hidden} .conv-search{padding:14px 16px;border-bottom:1px solid #eef1f5} .conv-search input{width:100%;padding:9px 14px 9px 36px;border:1px solid #e2e5ea;border-radius:10px;font-size:13px;outline:none;background:#f5f7fb url('data:image/svg+xml,') 12px 50% no-repeat;background-size:16px;transition:all .15s} .conv-search input:focus{border-color:#1565c0;background-color:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)} .conv-list{flex:1;overflow-y:auto;overflow-x:hidden} .conv-item{display:flex;align-items:center;gap:12px;padding:14px 16px;cursor:pointer;border-bottom:1px solid #f5f7fb;transition:all .15s} .conv-item:hover{background:#f8faff} .conv-item.active{background:#f0f4ff;border-left:3px solid #0b3d91;padding-left:13px} .conv-avatar{width:42px;height:42px;border-radius:50%;background:linear-gradient(135deg,#e8eaf6,#d6e4ff);display:flex;align-items:center;justify-content:center;font-size:16px;color:#0b3d91;font-weight:700;flex-shrink:0} .conv-info{flex:1;min-width:0} .conv-name{font-size:13px;font-weight:600;color:#1a1d23;display:flex;align-items:center;gap:6px} .conv-phone{font-size:11px;color:#7a8291;font-family:monospace;margin-top:1px} .conv-last{font-size:12px;color:#7a8291;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .conv-meta{text-align:right;flex-shrink:0} .conv-time{font-size:11px;color:#a0a8b8} .conv-unread{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:9px;background:#0b3d91;color:#fff;font-size:10px;font-weight:700;padding:0 5px;margin-top:4px} .conv-company{font-size:10px;color:#0b3d91;background:#f0f4ff;padding:1px 6px;border-radius:4px;font-weight:600} .chat-main{flex:1;display:flex;flex-direction:column;background:#f5f7fb} .chat-header{display:none;padding:14px 22px;background:#fff;border-bottom:1px solid #eef1f5;align-items:center;gap:12px} .chat-header.show{display:flex} .chat-info{flex:1} .chat-info .name{font-size:14px;font-weight:600} .chat-info .phone{font-size:12px;color:#7a8291;font-family:monospace} .chat-company-tag{font-size:10px;color:#0b3d91;background:#f0f4ff;padding:2px 8px;border-radius:4px;font-weight:600} .chat-messages{flex:1;overflow-y:auto;padding:20px 22px;display:none;flex-direction:column;gap:6px} .chat-messages.show{display:flex} .msg-row{display:flex;margin-bottom:4px;animation:msgIn .2s ease} @keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} .msg-row.inbound{justify-content:flex-start} .msg-row.outbound{justify-content:flex-end} .msg-bubble{max-width:75%;padding:10px 14px;border-radius:16px;font-size:13px;line-height:1.5;position:relative;word-wrap:break-word} .msg-row.inbound .msg-bubble{background:#fff;color:#1a1d23;border-bottom-left-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.06)} .msg-row.outbound .msg-bubble{background:#0b3d91;color:#fff;border-bottom-right-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.08)} .msg-meta{display:flex;align-items:center;gap:6px;margin-top:4px;font-size:10px} .msg-row.inbound .msg-meta{color:#a0a8b8} .msg-row.outbound .msg-meta{color:rgba(255,255,255,.65);justify-content:flex-end} .msg-status{font-size:11px} .msg-status .sent{color:rgba(255,255,255,.5)} .msg-status .delivered{color:#8fcbff} .msg-status .read{color:#5cb3ff} .msg-reactions{display:flex;gap:2px;margin-top:2px;font-size:16px} .msg-media{max-width:240px;border-radius:10px;overflow:hidden;margin-bottom:4px;cursor:pointer} .msg-media img{width:100%;display:block;border-radius:10px;border:1px solid rgba(0,0,0,.06)} .msg-media.doc{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#f8f9fd;border:1px solid #e2e5ea;border-radius:8px;color:#0b3d91;font-size:12px;font-weight:600} .msg-media.doc .ico{font-size:20px} .chat-empty{flex:1;display:none;align-items:center;justify-content:center;color:#a0a8b8;flex-direction:column;gap:12px} .chat-empty.show{display:flex} .chat-empty .big{font-size:48px} .chat-input{display:none;padding:14px 22px 18px;background:#fff;border-top:1px solid #eef1f5;gap:10px;align-items:flex-end} .chat-input.show{display:flex} .chat-input textarea{flex:1;padding:10px 14px;border:1px solid #e2e5ea;border-radius:12px;font-size:13px;outline:none;resize:none;font-family:inherit;max-height:120px;min-height:42px;line-height:1.5;transition:border .15s} .chat-input textarea:focus{border-color:#1565c0;box-shadow:0 0 0 3px rgba(21,101,192,.08)} .chat-input .send-btn{width:42px;height:42px;border-radius:50%;background:#0b3d91;color:#fff;border:none;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:all .15s;flex-shrink:0} .chat-input .send-btn:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.25);transform:scale(1.05)} .chat-input .send-btn:disabled{opacity:.4;cursor:not-allowed;transform:none} .msg-date-sep{text-align:center;font-size:11px;color:#a0a8b8;padding:12px 0 8px;font-weight:500} .typing{display:flex;gap:4px;padding:6px 0} .typing span{width:6px;height:6px;border-radius:50%;background:#a0a8b8;animation:typing 1.2s infinite} .typing span:nth-child(2){animation-delay:.2s} .typing span:nth-child(3){animation-delay:.4s} @keyframes typing{0%,60%,100%{opacity:.3}30%{opacity:1}} @media(max-width:768px){ .conv-sidebar{width:100%;min-width:auto} .chat-main{display:none} .chat-main.mobile-open{display:flex;position:fixed;inset:0;z-index:200;top:0} .conv-sidebar.mobile-hidden{display:none} .back-btn{display:flex} } .back-btn{display:none;background:none;border:none;color:#0b3d91;font-size:20px;cursor:pointer;padding:4px 8px;border-radius:6px;line-height:1} .back-btn:hover{background:#f0f4ff}
💬
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, MAX(c.contact_name) as contact_name, MAX(c.company_id) as company_id, MAX(c.created_at) as last_time, (SELECT content FROM conversations c2 WHERE c2.phone_number = c.phone_number ORDER BY c2.id DESC LIMIT 1) as last_message, (SELECT COUNT(*) FROM conversations c3 WHERE c3.phone_number = c.phone_number AND c3.direction = 'inbound' AND c3.id > COALESCE((SELECT MAX(c4.id) FROM conversations c4 WHERE c4.phone_number = c.phone_number AND c4.direction = 'outbound'), 0)) as unread_count FROM conversations c GROUP BY c.phone_number ORDER BY last_time DESC ")->fetchAll(); $companies = []; foreach (CompanyRepository::findAll() as $co) { $companies[(int)$co['id']] = $co['name']; } $conversations = []; foreach ($rows as $r) { $cid = (int)$r['company_id']; $conversations[] = [ 'phone' => $r['phone_number'], 'contact_name' => $r['contact_name'] ?? '', 'company_name' => $companies[$cid] ?? '', 'last_message' => mb_substr($r['last_message'] ?? '', 0, 80), 'last_time' => $r['last_time'], 'unread_count' => (int)$r['unread_count'], ]; } jsonResponse(200, ['conversations' => $conversations]); } // ─── API: mensajes de una conversación ─────────────────────────────────── public static function chatMessages(): void { SessionAuth::require(); $phone = trim($_GET['phone'] ?? ''); if ($phone === '') jsonResponse(400, ['error' => 'phone requerido']); $stmt = db()->prepare(" SELECT direction, message_type, content, media_id, status, created_at FROM conversations WHERE phone_number = ? ORDER BY id ASC "); $stmt->execute([$phone]); $messages = $stmt->fetchAll(); // Also include outbound_queue items for this phone $stmt2 = db()->prepare(" SELECT 'outbound' as direction, message_type, payload as content, NULL as media_id, status, created_at FROM outbound_queue WHERE to_number = ? AND status IN ('sent','delivered','failed') AND id > COALESCE((SELECT MAX(c.id) FROM conversations c WHERE c.phone_number = ?), 0) ORDER BY id ASC "); $stmt2->execute([$phone, $phone]); $queueMsgs = $stmt2->fetchAll(); foreach ($queueMsgs as &$qm) { $pl = json_decode($qm['content'], true); $qm['content'] = $pl['text'] ?? $pl['caption'] ?? $qm['content']; } $all = array_merge($messages, $queueMsgs); usort($all, fn($a, $b) => strcmp($a['created_at'] ?? '', $b['created_at'] ?? '')); jsonResponse(200, ['messages' => $all]); } // ─── API: enviar mensaje desde el chat ─────────────────────────────────── public static function chatSend(): void { SessionAuth::require(); $input = json_decode(file_get_contents('php://input'), true); $phone = trim($input['phone'] ?? ''); $text = trim($input['text'] ?? ''); if ($phone === '' || $text === '') { jsonResponse(400, ['error' => 'phone y text requeridos']); } // Find company for this phone (lookup by latest conversation) $stmt = db()->prepare("SELECT company_id FROM conversations WHERE phone_number = ? ORDER BY id DESC LIMIT 1"); $stmt->execute([$phone]); $conv = $stmt->fetch(); $companyId = $conv ? (int)$conv['company_id'] : 1; // Enqueue the message $db = db(); $payload = json_encode(['text' => $text]); $qStmt = $db->prepare("INSERT INTO outbound_queue (company_id, to_number, message_type, payload, status, created_at) VALUES (?, ?, 'text', ?, 'queued', NOW())"); $qStmt->execute([$companyId, $phone, $payload]); // Add to conversations $msgId = 'admin_' . time() . '_' . $phone; $cStmt = $db->prepare("INSERT INTO conversations (company_id, message_id, phone_number, direction, message_type, content, created_at) VALUES (?, ?, ?, 'outbound', 'text', ?, NOW())"); $cStmt->execute([$companyId, $msgId, $phone, $text]); jsonResponse(200, ['status' => 'sent']); } // ─── GET /admin/bot-config ────────────────────────────────────────────── public static function botConfig(): void { SessionAuth::require(); $user = SessionAuth::user(); $userName = self::h($user['name'] ?? 'Admin'); $companyId = (int)($_GET['id'] ?? 0); $companies = CompanyRepository::findAll(); $company = null; $config = ['commands' => [], 'menus' => [], 'flows' => [], 'ai_prompt' => '', 'fallback' => '', 'greeting' => '']; $msg = $_GET['msg'] ?? ''; $toastHtml = $msg === 'saved' ? '
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 Layout::open('Configurar Bot', 'bot-config', $user['name'] ?? 'Admin'); echo << .wrap{padding:24px;max-width:1100px;margin:0 auto} .tabs{display:flex;gap:2px;margin-bottom:0;flex-wrap:wrap;position:sticky;top:56px;z-index:90;background:#f9fafb;padding:10px 0 0} .tab-btn{padding:10px 18px;border:none;border-radius:10px 10px 0 0;font-size:13px;font-weight:600;cursor:pointer;background:#e8ecf2;color:#5f6b7a;transition:all .15s} .tab-btn:hover{background:#dce1e8} .tab-btn.active{background:#fff;color:#0b3d91;box-shadow:0 -2px 4px rgba(0,0,0,.04)} .tab-content{display:none;background:#fff;border-radius:0 12px 12px 12px;box-shadow:0 1px 4px rgba(0,0,0,.04),0 4px 16px rgba(0,0,0,.03);padding:24px;border:1px solid #eef1f5;margin-bottom:24px} .tab-content.active{display:block} .item-card{background:#f8f9fd;border:1px solid #eef1f5;border-radius:10px;padding:16px;margin-bottom:12px} .item-card .item-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;font-weight:600;font-size:13px;color:#0b3d91} .item-card .item-head .del{cursor:pointer;color:#991b1b;font-size:12px;background:none;border:none;font-weight:600} .add-btn{background:none;border:2px dashed #d6e4ff;border-radius:8px;padding:10px;text-align:center;color:#0b3d91;font-size:13px;font-weight:600;cursor:pointer;width:100%;transition:all .15s} .add-btn:hover{background:#f0f4ff;border-color:#0b3d91} .inline-group{display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap} .inline-group input,.inline-group select{flex:1;min-width:100px} .inline-group .del{margin-left:auto} table.simple{width:100%;border-collapse:collapse;margin-bottom:12px} table.simple th{text-align:left;padding:8px 10px;font-size:11px;font-weight:700;color:#0b3d91;text-transform:uppercase;letter-spacing:.3px;border-bottom:2px solid #e8eaf6;background:#f8f9fd} table.simple td{padding:8px 10px;border-bottom:1px solid #f0f2f5;font-size:13px;vertical-align:middle} .company-select{display:flex;gap:12px;align-items:center;margin-bottom:20px;flex-wrap:wrap} .company-select select{padding:9px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;background:#fff;min-width:250px} .company-select select:focus{border-color:#1565c0} @media(max-width:768px){.wrap{padding:12px}.tabs{top:auto}.tab-content{padding:16px}}
{$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
Vista previa
{$cName[0]}
{$cName}
en línea
{$greetingVal}
12:00
{$fallbackVal}
fallback
Vista previa aproximada
↩ 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 Layout::open('Hilo Conductor', 'conv-flow', $user['name'] ?? 'Admin'); echo << body{background:#f0f2f6} .wrap{padding:16px 24px 40px;max-width:1300px;margin:0 auto} .company-select{display:flex;gap:12px;align-items:center;margin-bottom:16px;flex-wrap:wrap} .company-select select{padding:9px 14px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;background:#fff;min-width:250px} .company-select select:focus{border-color:#1565c0} .company-select label{font-weight:600;font-size:13px} .toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;align-items:center} .toolbar .btn-pri{background:#0b3d91;color:#fff;border:none;border-radius:8px;padding:9px 20px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:6px} .toolbar .btn-pri:hover{background:#1565c0;box-shadow:0 2px 8px rgba(11,61,145,.2)} .toolbar .btn-sec{background:#fff;color:#0b3d91;border:1px solid #d6e4ff;border-radius:8px;padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:6px} .toolbar .btn-sec:hover{background:#f0f4ff} .legend{display:flex;gap:14px;flex-wrap:wrap;padding:10px 14px;background:#fff;border-radius:10px;border:1px solid #eef1f5;margin-bottom:16px;font-size:12px;align-items:center} .legend-item{display:flex;align-items:center;gap:6px} .legend-dot{width:12px;height:12px;border-radius:50%;display:inline-block} .legend-dot.cmd{background:#6366f1} .legend-dot.menu{background:#0ea5e9} .legend-dot.row-dot{background:#f59e0b} .legend-dot.flow{background:#8b5cf6} .legend-dot.sub{background:#10b981} .legend-dot.action{background:#ef4444} /* ─── TREE ─── */ .tree-canvas{background:#fff;border-radius:12px;border:1px solid #eef1f5;padding:24px;overflow-x:auto;min-height:300px;box-shadow:0 1px 4px rgba(0,0,0,.04)} .no-data{text-align:center;padding:60px 20px;color:#a0a8b8;font-size:15px} .root-node{display:flex;flex-direction:column;align-items:center} .entry-node{display:flex;flex-direction:column;align-items:center;position:relative;width:100%} .node-group{display:flex;flex-direction:column;align-items:center;width:100%;position:relative} .node-card{background:#fff;border-radius:10px;padding:12px 16px;min-width:220px;max-width:340px;position:relative;box-shadow:0 1px 3px rgba(0,0,0,.06),0 4px 12px rgba(0,0,0,.04);transition:all .2s;cursor:pointer} .node-card:hover{box-shadow:0 4px 12px rgba(0,0,0,.1),0 8px 24px rgba(0,0,0,.06);transform:translateY(-2px)} .node-card .tag{font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;margin-bottom:4px;display:flex;align-items:center;gap:4px} .node-card .n-title{font-size:14px;font-weight:700;margin-bottom:2px;word-break:break-word} .node-card .n-sub{font-size:11px;color:#6b7280;margin-bottom:4px;word-break:break-word} .node-card .n-info{font-size:11px;color:#6b7280;display:flex;gap:4px;flex-wrap:wrap;margin-bottom:6px} .node-card .n-info span{background:#f3f4f6;padding:1px 6px;border-radius:4px} .node-card .actions{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap} .node-card .actions button{font-size:10px;padding:2px 8px;border-radius:4px;border:none;cursor:pointer;font-weight:600;transition:all .12s} .node-card .actions .edit-btn{background:#e8f4fd;color:#0369a1} .node-card .actions .edit-btn:hover{background:#bae6fd} .node-card .actions .del-btn{background:#fef2f2;color:#991b1b} .node-card .actions .del-btn:hover{background:#fee2e2} .node-card .actions .add-btn-sm{background:#f0fdf4;color:#166534} .node-card .actions .add-btn-sm:hover{background:#dcfce7;} .cmd-card{border-left:4px solid #6366f1} .menu-card{border-left:4px solid #0ea5e9} .row-card{border-left:4px solid #f59e0b;min-width:200px} .flow-card{border-left:4px solid #8b5cf6} .sub-card{border-left:4px solid #10b981} .root-card{border-left:4px solid #374151} .row-list{display:flex;flex-direction:column;gap:3px;margin-top:6px;width:100%} .row-item{display:flex;align-items:center;gap:6px;background:#f9fafb;padding:5px 8px;border-radius:6px;font-size:12px;border:1px solid #f0f1f3;transition:all .12s;cursor:pointer} .row-item:hover{background:#f0f4ff;border-color:#d6e4ff} .row-item .r-icon{font-size:14px;flex-shrink:0} .row-item .r-title{font-weight:600;flex:1;color:#1f2937} .row-item .r-id{font-size:10px;color:#6b7280;font-family:monospace;background:#eef1f5;padding:1px 5px;border-radius:3px} .row-item .r-arrow{color:#9ca3af;font-size:12px} .row-item .r-target{font-size:11px;color:#6366f1;font-weight:500;font-family:monospace} .connector-v{width:2px;height:20px;background:#d1d5db;flex-shrink:0} .connector-v.thick{height:28px} .connector-branch{display:flex;gap:20px;position:relative;padding-top:4px;flex-wrap:wrap;justify-content:center} .connector-branch::before{content:'';position:absolute;top:0;left:50%;right:50%;height:2px;background:#d1d5db} .connector-branch::after{content:none} .branch-item{display:flex;flex-direction:column;align-items:center;position:relative;min-width:0} .branch-item::before{content:'';position:absolute;top:0;left:50%;width:2px;height:10px;background:#d1d5db} .branch-item:first-child::before{left:50%} .branch-item:last-child::before{left:50%} .empty-hint{padding:20px;text-align:center;color:#9ca3af;font-size:12px} .overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:1000;backdrop-filter:blur(2px)} .overlay.show{display:flex;align-items:center;justify-content:center} .modal{background:#fff;border-radius:14px;max-width:520px;width:90%;max-height:85vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.15);padding:24px} .modal h2{font-size:16px;font-weight:700;color:#0b3d91;margin-bottom:16px;padding-bottom:10px;border-bottom:2px solid #eef1f5} .modal .form-group{margin-bottom:12px} .modal .form-group label{display:block;font-size:12px;font-weight:600;color:#3d4552;margin-bottom:4px} .modal .form-group input,.modal .form-group select,.modal .form-group textarea{width:100%;padding:8px 12px;border:1px solid #e2e5ea;border-radius:8px;font-size:13px;outline:none;font-family:inherit;background:#fafbfc;transition:all .15s;color:#1a1d23} .modal .form-group input:focus,.modal .form-group select:focus,.modal .form-group textarea:focus{border-color:#1565c0;background:#fff;box-shadow:0 0 0 3px rgba(21,101,192,.08)} .modal .form-group textarea{min-height:50px;resize:vertical} .modal .btn-row{display:flex;gap:8px;justify-content:flex-end;margin-top:16px} .modal .btn-row button{padding:8px 20px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;border:none;transition:all .12s} .modal .btn-row .btn-save{background:#0b3d91;color:#fff} .modal .btn-row .btn-save:hover{background:#1565c0} .modal .btn-row .btn-cancel{background:#f3f4f6;color:#4b5563} .modal .btn-row .btn-cancel:hover{background:#e5e7eb} .modal .btn-row .btn-del-modal{background:#fef2f2;color:#991b1b} .modal .btn-row .btn-del-modal:hover{background:#fee2e2} .badge-list{background:#e0f2fe;color:#0369a1} .badge-button{background:#fef3c7;color:#92400e} .badge-text{background:#f3e8ff;color:#6b21a8} .badge-func{background:#d1fae5;color:#065f46} .badge-menu{background:#dbeafe;color:#1e40af} .badge-ai{background:#fce7f3;color:#9d174d} .badge-agent{background:#fff7ed;color:#9a3412} .badge-webhook{background:#e0e7ff;color:#3730a3} .highlight-path .node-card{opacity:.4} .highlight-path .highlighted .node-card{opacity:1;box-shadow:0 0 0 2px #6366f1,0 4px 12px rgba(99,102,241,.15)} .row-item.highlighted{background:#eef2ff;border-color:#a5b4fc} @media(max-width:768px){.wrap{padding:12px}.tree-canvas{padding:12px}.connector-branch{gap:10px}.node-card{min-width:160px;max-width:260px}}
{$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 { $isButton = ($menu['type'] ?? 'list') === 'button'; $typeLabel = $isButton ? 'Botones' : 'Lista'; $typeBadge = $isButton ? 'badge-button' : 'badge-list'; // Unifica items: botones directos o filas de secciones $items = []; if ($isButton) { foreach ($menu['buttons'] ?? [] as $btn) { $items[] = ['id' => $btn['id'], 'title' => $btn['title'], 'description' => '']; } } else { foreach ($menu['sections'] ?? [] as $section) { foreach ($section['rows'] ?? [] as $row) { $items[] = ['id' => $row['id'], 'title' => $row['title'], 'description' => $row['description'] ?? '']; } } } echo '
'; echo ''; // node-card // Mostrar primer submenú en árbol foreach ($items as $item) { $flow = $flows[$item['id']] ?? null; if ($flow && $flow['type'] === 'menu' && isset($flow['menu'])) { echo '
'; echo '
'; echo '
🔲 ' . self::h($item['title']) . ' →
'; self::renderFlowNode($item['id'], $flow, $allMenus, $companyId, true); echo '
'; 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 '
'; 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']) . '
'; // Mostrar items del sub-menú (buttons o sections[].rows) $subIsBtn = ($subMenu['type'] ?? 'list') === 'button'; $subItems = $subIsBtn ? ($subMenu['buttons'] ?? []) : array_merge(...array_map(fn($s) => $s['rows'] ?? [], $subMenu['sections'] ?? [])); if (!empty($subItems)) { echo '
'; foreach ($subItems as $item) { echo '
'; echo '' . ($subIsBtn ? '🔲' : '🔘') . ''; echo '' . self::h($item['title']) . ''; echo '' . self::h($item['id']) . ''; echo '
'; } echo '
'; } else { echo '
Sin botones
'; } echo '
'; echo ''; echo '
'; echo '
'; } } // ─── GET /admin/test-message ────────────────────────────────────────────── public static function testMessage(): void { SessionAuth::require(); $user = SessionAuth::user(); $companies = CompanyRepository::findAll(); $opts = ''; foreach ($companies as $c) { $opts .= ''; } echo Layout::open('Mensaje de prueba', 'test', $user['username'] ?? 'Admin'); echo <<

Mensaje de prueba

Simula un mensaje entrante de WhatsApp al webhook. El bot responde igual que en producción.

HTML; echo Layout::close(); } // ─── POST /admin/test-message/send ─────────────────────────────────────── public static function testMessageSend(): void { SessionAuth::require(); header('Content-Type: application/json; charset=utf-8'); $phoneNumberId = trim($_POST['phone_number_id'] ?? ''); $fromNumber = preg_replace('/\D/', '', $_POST['from_number'] ?? ''); $fromName = trim($_POST['from_name'] ?? 'Test'); $message = trim($_POST['message'] ?? ''); if ($phoneNumberId === '' || $fromNumber === '' || $message === '') { echo json_encode(['ok' => false, 'error' => 'Faltan campos requeridos']); exit; } $payload = [ 'object' => 'whatsapp_business_account', 'entry' => [[ 'id' => 'TEST_WABA', 'changes' => [[ 'field' => 'messages', 'value' => [ 'messaging_product' => 'whatsapp', 'metadata' => [ 'display_phone_number' => '0000000000', 'phone_number_id' => $phoneNumberId, ], 'contacts' => [[ 'profile' => ['name' => $fromName], 'wa_id' => $fromNumber, ]], 'messages' => [[ 'id' => 'wamid.TEST_' . time(), 'from' => $fromNumber, 'timestamp' => (string)time(), 'type' => 'text', 'text' => ['body' => $message], ]], ], ]], ]], ]; $json = json_encode($payload, JSON_UNESCAPED_UNICODE); $appSecret = env('WHATSAPP_APP_SECRET', ''); $sig = $appSecret !== '' ? 'sha256=' . hash_hmac('sha256', $json, $appSecret) : 'sha256=test'; // POST al webhook propio vía cURL $webhookUrl = 'https://admin.palmas360.com/admin/v1/wp-webhook'; $ch = curl_init($webhookUrl); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $json, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Hub-Signature-256: ' . $sig, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_SSL_VERIFYPEER => false, ]); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); if ($curlErr) { echo json_encode(['ok' => false, 'error' => 'cURL: ' . $curlErr]); exit; } if ($httpCode === 200) { echo json_encode(['ok' => true, 'message' => 'Mensaje enviado al webhook. Revisa Live Feed y logs.']); } else { $body = is_string($resp) ? substr($resp, 0, 200) : ''; echo json_encode(['ok' => false, 'error' => "Webhook respondió HTTP $httpCode. $body"]); } exit; } }