diff --git a/admin/DashboardController.php b/admin/DashboardController.php index 5916b42..97bbe90 100644 --- a/admin/DashboardController.php +++ b/admin/DashboardController.php @@ -945,11 +945,32 @@ HTML; // ─── GET /admin/company/edit ───────────────────────────────────────────── + // ── Catálogo de endpoints (fijo para todas las empresas) ───────────────── + private static function endpointCatalog(): array + { + return [ + 'upload' => [ + 'cosecha_up' => 'Ingreso ciclos cosecha, sanidad, polinización', + 'bascula_up' => 'Ingreso tiquetes de báscula', + 'ausentismo_up' => 'Reporte de ausentismos', + 'pluviometria_up' => 'Reporte de Pluviometría', + 'subproductos_up' => 'Reporte de Salida subproductos', + ], + 'download' => [ + 'cosecha_dn' => 'Informe ciclos cosecha, sanidad, polinización', + 'produccion_dn' => 'Informe de producción', + 'ausentismo_dn' => 'Informe de ausentismos', + 'pluviometria_dn' => 'Informe de Pluviometría', + 'subproductos_dn' => 'Informe de Salida subproductos de extractora', + 'numeros_dn' => 'Informe de números activos', + ], + ]; + } + public static function companyEdit(): void { SessionAuth::require(); $user = SessionAuth::user(); - $userName = self::h($user['name'] ?? 'Admin'); $id = (int)($_GET['id'] ?? 0); $isEdit = $id > 0; $company = $isEdit ? CompanyRepository::findById($id) : null; @@ -965,104 +986,465 @@ HTML; $bot_type = $company['bot_type'] ?? 'normal'; $requires_approval = !empty($company['requires_approval']); $is_active = !empty($company['is_active']); + $erp_active_users = (int)($company['erp_active_users'] ?? 0); $reqAprChecked = $requires_approval ? 'checked' : ''; $isActChecked = $is_active ? 'checked' : ''; $botOptions = ''; - foreach (['normal' => 'Normal', 'ai' => 'AI', 'hybrid' => 'Híbrido'] as $val => $label) { + foreach (['normal' => 'Normal', 'ai' => 'AI', 'hybrid' => 'Híbrido'] as $val => $lbl) { $sel = $bot_type === $val ? 'selected' : ''; - $botOptions .= "\n"; + $botOptions .= "\n"; } $pageTitle = $isEdit ? 'Editar Empresa' : 'Nueva Empresa'; - - http_response_code(200); - header('Content-Type: text/html; charset=utf-8'); echo Layout::open($pageTitle, 'companies', $user['name'] ?? 'Admin'); - echo << - .wrap{padding:24px;max-width:800px;margin:0 auto} - .card h2{font-size:18px;margin-bottom:22px;color:#0b3d91;font-weight:700} - .form-group textarea{min-height:120px;resize:vertical;font-family:'SF Mono',Monaco,monospace;font-size:12px;line-height:1.6} - .form-group .hint{font-size:11px;color:#7a8291;margin-top:4px} - .btn-row{display:flex;gap:12px;margin-top:28px;padding-top:20px;border-top:1px solid #eef1f5} - @media(max-width:768px){.wrap{padding:12px}.card{padding:16px}.form-row .form-group{min-width:100%}} - -
-
-

{$pageTitle}

-
-HTML; + + // ── Tab: Números WhatsApp ───────────────────────────────────────────── + $phonesHtml = ''; + $epHtml = ''; if ($isEdit) { - echo ''; - } - 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)
-
-
- - -
-
- - -
+ // Phones + $phones = db()->prepare("SELECT * FROM company_phones WHERE company_id=? ORDER BY permission_type, wa_number"); + $phones->execute([$id]); + $phones = $phones->fetchAll(\PDO::FETCH_ASSOC); + + $limitType1 = max(1, $erp_active_users * 10); + $limitType23 = max(1, $erp_active_users); + $countT1 = count(array_filter($phones, fn($p) => (int)$p['permission_type'] === 1)); + $countT23 = count(array_filter($phones, fn($p) => (int)$p['permission_type'] !== 1)); + + $pTypeName = ['1' => 'Solo reporta', '2' => 'Solo recibe', '3' => 'Reporta y recibe']; + $pTypeCls = ['1' => 'badge-blue', '2' => 'badge-green', '3' => 'badge-teal']; + $rows = ''; + foreach ($phones as $ph) { + $t = (string)$ph['permission_type']; + $tc = $pTypeCls[$t] ?? 'badge-gray'; + $tn = $pTypeName[$t] ?? '?'; + $lbl = self::h($ph['label'] ?? ''); + $num = self::h($ph['wa_number']); + $act = $ph['is_active'] ? 'Activo' : 'Inactivo'; + $rows .= " + +{$num} + {$lbl} + {$tn} + {$act} + + "; + } + if (!$rows) $rows = 'Sin números registrados'; + + $warnERP = $erp_active_users === 0 + ? '
⚠ Usuarios activos ERP = 0. Configura el campo en la pestaña General o ejecuta sincronización.
' + : ''; + + $pTypeOpts = ''; + foreach ($pTypeName as $v => $n) $pTypeOpts .= ""; + + $phonesHtml = << + Tipo 1 (Solo reporta): {$countT1} / {$limitType1} + Tipo 2+3 (Reciben): {$countT23} / {$limitType23} +
+
+ + + {$rows} +
Número WAEtiquetaPermisoEstado
+
+
+
Agregar número
+
+
+
+ +
- - -
Configuración adicional del bot en formato JSON
+ +
-
- - Cancelar +
+ +
- +
+
+
- - 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 << +
+ + +
+
+ + +
+
+
+
+ + +
ID del número WA Business API (para enviar mensajes)
+
+
+ + +
+
+
+
+ + +
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/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; } diff --git a/admin/Layout.php b/admin/Layout.php index 6a22f02..3acbaa2 100644 --- a/admin/Layout.php +++ b/admin/Layout.php @@ -26,6 +26,11 @@ class Layout return $icons[$name] ?? ''; } + public static function close(): string + { + return ''; + } + public static function open( string $title, string $active, diff --git a/public/index.php b/public/index.php index afeaf81..cd6137d 100644 --- a/public/index.php +++ b/public/index.php @@ -265,6 +265,14 @@ $routes = [ ['GET', '/admin/test-message', fn() => DashboardController::testMessage()], ['POST', '/admin/test-message/send', fn() => DashboardController::testMessageSend()], + // ─── Números WhatsApp por empresa ────────────────────────────────────── + ['POST', '/admin/company/phone/save', fn() => DashboardController::companyPhoneSave()], + ['POST', '/admin/company/phone/delete', fn() => DashboardController::companyPhoneDelete()], + + // ─── Endpoints API por empresa ───────────────────────────────────────── + ['POST', '/admin/company/endpoint/save', fn() => DashboardController::companyEndpointSave()], + ['POST', '/admin/company/endpoint/test', fn() => DashboardController::companyEndpointTest()], + // ─── Admin: listar pendientes de aprobación ───────────────────────────── ['GET', '/admin/pending-list', fn() => DashboardController::pendingList()], ['GET', '/admin/pending', fn() => DashboardController::pending()], diff --git a/setup/migrate.php b/setup/migrate.php index 7c8c5e9..3c1cb1c 100644 --- a/setup/migrate.php +++ b/setup/migrate.php @@ -207,6 +207,46 @@ foreach ($seedSettings as $k => $v) { $insertStmt->execute([$k, $v]); } +// ─── Números WhatsApp por empresa ───────────────────────────────────────────── + +$db->exec(" + CREATE TABLE IF NOT EXISTS company_phones ( + id INT AUTO_INCREMENT PRIMARY KEY, + company_id INT NOT NULL, + wa_number VARCHAR(25) NOT NULL, + label VARCHAR(100) DEFAULT '', + permission_type TINYINT NOT NULL DEFAULT 1 COMMENT '1=solo reporta, 2=solo recibe, 3=ambos', + is_active TINYINT NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_cp (company_id, wa_number), + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"); + +// ─── Endpoints API configurables por empresa ─────────────────────────────────── + +$db->exec(" + CREATE TABLE IF NOT EXISTS company_endpoints ( + id INT AUTO_INCREMENT PRIMARY KEY, + company_id INT NOT NULL, + endpoint_key VARCHAR(50) NOT NULL, + direction ENUM('upload','download') NOT NULL, + url VARCHAR(500) DEFAULT '', + method VARCHAR(10) DEFAULT 'GET', + last_response TEXT, + last_called_at DATETIME, + is_active TINYINT DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_ce (company_id, endpoint_key), + FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"); + +// ─── Campo erp_active_users en companies ────────────────────────────────────── +try { + $db->exec("ALTER TABLE companies ADD COLUMN erp_active_users INT DEFAULT 0 AFTER is_active"); +} catch (\PDOException $e) {} + // ─── Usuario admin por defecto ──────────────────────────────────────────────── $email = 'admin@palmas360.com';