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'], ], '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 ""; } 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']); } }