- Reemplazo de comillas simples por backticks (literales de plantilla) en strings multilínea dentro de `views/stripe.php` para evitar errores fatales de JavaScript que bloqueaban la navegación del menú y la interacción del usuario. - Mejora de la lógica en la interfaz para manejar el estado de pago "processing" (común en transferencias SPEI), mostrando el mensaje "Pago en proceso" con instrucciones claras en lugar del mensaje genérico de error. - Corrección del objeto global `store` en `public.php` eliminando propiedades duplicadas que podían afectar la persistencia de la sesión. - Actualización de versión a 4.6.1 en CHANGELOG, README y manifest.json.
2971 lines
110 KiB
PHP
Executable File
2971 lines
110 KiB
PHP
Executable File
<?php
|
|
|
|
require_once __DIR__ . '/vendor/autoload.php';
|
|
|
|
chdir(__DIR__);
|
|
|
|
use Ubnt\UcrmPluginSdk\Service\PluginConfigManager;
|
|
use Ubnt\UcrmPluginSdk\Service\UcrmApi;
|
|
use Ubnt\UcrmPluginSdk\Service\UcrmSecurity;
|
|
use SmsNotifier\Service\PaymentIntentService;
|
|
|
|
if (!file_exists(__DIR__ . '/data/config.json')) {
|
|
die('Acceso denegado o configuración no encontrada.');
|
|
}
|
|
|
|
$configManager = PluginConfigManager::create();
|
|
$config = $configManager->loadConfig();
|
|
|
|
// LOG DE EMERGENCIA
|
|
$debugLogPath = __DIR__ . '/data/debug_public.log';
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$input = file_get_contents('php://input');
|
|
$jsonData = json_decode((string)$input, true);
|
|
|
|
if ($jsonData && (isset($jsonData['uuid']) || isset($jsonData['eventName']) || isset($jsonData['type']))) {
|
|
file_put_contents($debugLogPath, "[" . date('Y-m-d H:i:s') . "] Webhook in public.php. Delegating..." . PHP_EOL, FILE_APPEND);
|
|
|
|
$builder = new \DI\ContainerBuilder();
|
|
$container = $builder->build();
|
|
$plugin = $container->get(\SmsNotifier\Plugin::class);
|
|
$plugin->run();
|
|
exit;
|
|
}
|
|
}
|
|
|
|
$ipServer = $config['ipserver'] ?? 'localhost';
|
|
$ucrmApiUrl = 'https://' . $ipServer . '/crm/api/v1.0/';
|
|
$ucrmPublicUrl = 'https://' . $ipServer . '/crm';
|
|
|
|
$httpClient = new \GuzzleHttp\Client([
|
|
'base_uri' => $ucrmApiUrl,
|
|
'verify' => false,
|
|
'timeout' => 15
|
|
]);
|
|
|
|
$ucrmApi = new UcrmApi($httpClient, $config['apitoken'] ?? '');
|
|
$paymentIntentService = new PaymentIntentService($ucrmApi, $config['tokenstripe'] ?? '');
|
|
|
|
// Admins Logic
|
|
$admins = [];
|
|
$defaultStripeAdminId = null;
|
|
try {
|
|
$adminsRaw = $ucrmApi->get('users/admins');
|
|
foreach ($adminsRaw as $admin) {
|
|
$nombre = trim(($admin['firstName'] ?? '') . ' ' . ($admin['lastName'] ?? ''));
|
|
$admins[] = ['id' => $admin['id'], 'nombre' => $nombre];
|
|
if (strtolower($nombre) === 'stripe' || strtolower($admin['username'] ?? '') === 'stripe') {
|
|
$defaultStripeAdminId = $admin['id'];
|
|
}
|
|
}
|
|
if ($defaultStripeAdminId === null && !empty($admins)) $defaultStripeAdminId = $admins[0]['id'];
|
|
} catch (\Exception $e) {
|
|
}
|
|
|
|
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// 0. LOGIN SCREEN SUPPORT & AUTHENTICATION
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
// Determinar si el usuario necesita login (si no tiene sesión UCRM activa)
|
|
$security = UcrmSecurity::create();
|
|
$currentUser = $security->getUser();
|
|
$needsLogin = !$currentUser;
|
|
|
|
$nmsBaseUrl = "https://{$ipServer}/nms/api/v2.1";
|
|
|
|
// 0a. NMS Login (POST username + password)
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_GET['action']) && $_GET['action'] === 'nms_login') {
|
|
header('Content-Type: application/json');
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
$username = $input['username'] ?? '';
|
|
$password = $input['password'] ?? '';
|
|
|
|
if (!$username || !$password) {
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Se requieren usuario y contraseña.']);
|
|
exit;
|
|
}
|
|
|
|
try {
|
|
$nmsClient = new \GuzzleHttp\Client(['verify' => false, 'http_errors' => false]);
|
|
$resp = $nmsClient->post("{$nmsBaseUrl}/user/login", [
|
|
'json' => ['username' => $username, 'password' => $password],
|
|
'headers' => ['Content-Type' => 'application/json'],
|
|
]);
|
|
|
|
$statusCode = $resp->getStatusCode();
|
|
$body = json_decode($resp->getBody()->getContents(), true);
|
|
$authToken = $resp->getHeaderLine('x-auth-token');
|
|
|
|
if ($statusCode === 200 && $authToken) {
|
|
echo json_encode(['success' => true, 'token' => $authToken, 'user' => $body]);
|
|
} elseif ($statusCode === 201) {
|
|
http_response_code(201);
|
|
echo json_encode(['requires2FA' => true, 'twoFactorToken' => $body]);
|
|
} else {
|
|
http_response_code($statusCode ?: 401);
|
|
echo json_encode(['error' => $body['message'] ?? 'Credenciales inválidas.', 'statusCode' => $statusCode]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Error de conexión con el servidor UISP.']);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// 0b. NMS TOTP Login
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_GET['action']) && $_GET['action'] === 'nms_login_totp') {
|
|
header('Content-Type: application/json');
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
|
|
try {
|
|
$nmsClient = new \GuzzleHttp\Client(['verify' => false, 'http_errors' => false]);
|
|
$resp = $nmsClient->post("{$nmsBaseUrl}/user/login/totpauth", [
|
|
'json' => $input,
|
|
'headers' => ['Content-Type' => 'application/json'],
|
|
]);
|
|
|
|
$statusCode = $resp->getStatusCode();
|
|
$body = json_decode($resp->getBody()->getContents(), true);
|
|
$authToken = $resp->getHeaderLine('x-auth-token');
|
|
|
|
if ($statusCode === 200 && $authToken) {
|
|
echo json_encode(['success' => true, 'token' => $authToken, 'user' => $body]);
|
|
} else {
|
|
http_response_code($statusCode ?: 401);
|
|
echo json_encode(['error' => $body['message'] ?? 'Código TOTP inválido.']);
|
|
}
|
|
} catch (\Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Error de conexión con el servidor UISP.']);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// 0c. NMS Verify Session
|
|
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['action']) && $_GET['action'] === 'nms_verify_session') {
|
|
header('Content-Type: application/json');
|
|
$token = $_SERVER['HTTP_X_AUTH_TOKEN'] ?? '';
|
|
if (!$token) {
|
|
http_response_code(401);
|
|
echo json_encode(['error' => 'No token provided.']);
|
|
exit;
|
|
}
|
|
try {
|
|
$nmsClient = new \GuzzleHttp\Client(['verify' => false, 'http_errors' => false]);
|
|
$resp = $nmsClient->get("{$nmsBaseUrl}/user", [
|
|
'headers' => ['x-auth-token' => $token],
|
|
]);
|
|
if ($resp->getStatusCode() === 200) {
|
|
echo json_encode(['success' => true, 'user' => json_decode($resp->getBody()->getContents(), true)]);
|
|
} else {
|
|
http_response_code(401);
|
|
echo json_encode(['error' => 'Sesión inválida o expirada.']);
|
|
}
|
|
} catch (\Exception $e) {
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Error verificando sesión.']);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// API HANDLERS
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
|
if ($_POST['action'] === 'save_installers') {
|
|
$installersJson = $_POST['installers_data'] ?? '';
|
|
if (json_decode($installersJson) !== null) {
|
|
$configPath = __DIR__ . '/data/config.json';
|
|
$currentConfig = json_decode(file_get_contents($configPath), true);
|
|
$currentConfig['installersDataWhatsApp'] = $installersJson;
|
|
file_put_contents($configPath, json_encode($currentConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
|
echo json_encode(['success' => true]);
|
|
exit;
|
|
}
|
|
echo json_encode(['success' => false]);
|
|
exit;
|
|
}
|
|
|
|
if ($_POST['action'] === 'resend_payment') {
|
|
$paymentId = $_POST['paymentId'] ?? null;
|
|
try {
|
|
$payment = $ucrmApi->get("payments/$paymentId");
|
|
$client = $ucrmApi->get("clients/{$payment['clientId']}");
|
|
|
|
$payload = [
|
|
'uuid' => 'manual-trigger',
|
|
'changeType' => 'insert',
|
|
'entity' => 'payment',
|
|
'entityId' => (int)$paymentId,
|
|
'eventName' => 'payment.add',
|
|
'clientData' => $client,
|
|
'paymentData' => $payment
|
|
];
|
|
|
|
// Internal Loopback
|
|
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
|
|
$selfUrl = $protocol . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
|
|
|
|
$debugLogPath = __DIR__ . '/data/debug_public.log';
|
|
file_put_contents($debugLogPath, "[" . date('Y-m-d H:i:s') . "] RESEND PAYMENT. URL: $selfUrl" . PHP_EOL, FILE_APPEND);
|
|
|
|
$ch = curl_init($selfUrl);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
|
curl_setopt($ch, CURLOPT_POSTREDIR, 3); // Mantener POST y payload en redirects 301/302
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
|
$res = curl_exec($ch);
|
|
$err = curl_error($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
file_put_contents($debugLogPath, "[" . date('Y-m-d H:i:s') . "] RESEND CURL RESULT - Code: $code - Error: $err - Body: $res" . PHP_EOL, FILE_APPEND);
|
|
|
|
echo json_encode(['success' => true, 'message' => 'Notificación disparada.']);
|
|
} catch (\Exception $e) {
|
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
if ($_POST['action'] === 'resend_job_notification') {
|
|
$jobId = $_POST['jobId'] ?? null;
|
|
if (!$jobId) {
|
|
echo json_encode(['success' => false, 'message' => 'jobId requerido']);
|
|
exit;
|
|
}
|
|
try {
|
|
$job = $ucrmApi->get("scheduling/jobs/$jobId");
|
|
$client = $ucrmApi->get("clients/{$job['clientId']}");
|
|
|
|
// Simular webhook job.edit con activación (status 0→1)
|
|
$entityBeforeEdit = $job;
|
|
$entityBeforeEdit['status'] = 0; // Simular que estaba en "Abierto"
|
|
$entity = $job;
|
|
$entity['status'] = 1; // Estado actual: "En curso"
|
|
// Agregar prefijo para que verifyJobActionToDo reconozca que debe notificar
|
|
$entity['title'] = '[NOTIFICACION-PENDIENTE]' . ($entity['title'] ?? '');
|
|
|
|
$payload = [
|
|
'uuid' => 'manual-trigger',
|
|
'changeType' => 'update',
|
|
'entity' => 'job',
|
|
'entityId' => (int)$jobId,
|
|
'eventName' => 'job.edit',
|
|
'extraData' => [
|
|
'entity' => $entity,
|
|
'entityBeforeEdit' => $entityBeforeEdit
|
|
]
|
|
];
|
|
|
|
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
|
|
$selfUrl = $protocol . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
|
|
|
|
$ch = curl_init($selfUrl);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
|
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
|
curl_setopt($ch, CURLOPT_POSTREDIR, 3);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
|
$res = curl_exec($ch);
|
|
$err = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
echo json_encode(['success' => true, 'message' => 'Notificación reenviada para job #' . $jobId]);
|
|
} catch (\Exception $e) {
|
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
}
|
|
}
|
|
|
|
if (isset($_GET['action'])) {
|
|
header('Content-Type: application/json');
|
|
|
|
if ($_GET['action'] === 'search_clients') {
|
|
$q = $_GET['q'] ?? '';
|
|
try {
|
|
$clients = $ucrmApi->get('clients', ['query' => $q, 'limit' => 6]);
|
|
echo json_encode($clients);
|
|
} catch (\Exception $e) {
|
|
echo json_encode([]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
if ($_GET['action'] === 'get_payments') {
|
|
$clientId = $_GET['clientId'] ?? null;
|
|
try {
|
|
$payments = $ucrmApi->get('payments', ['clientId' => $clientId, 'limit' => 20, 'order' => 'createdDate', 'direction' => 'DESC']);
|
|
$methods = $ucrmApi->get('payment-methods');
|
|
$methodMap = [];
|
|
foreach ($methods as $m) $methodMap[$m['id']] = $m['name'];
|
|
$formatted = array_slice($payments, 0, 10);
|
|
foreach ($formatted as &$p) $p['methodName'] = $methodMap[$p['methodId']] ?? 'N/A';
|
|
echo json_encode($formatted);
|
|
} catch (\Exception $e) {
|
|
echo json_encode([]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
if ($_GET['action'] === 'search_stripe') {
|
|
$q = $_GET['q'] ?? '';
|
|
echo json_encode($paymentIntentService->searchClients($q));
|
|
exit;
|
|
}
|
|
|
|
if ($_GET['action'] === 'get_stripe_details') {
|
|
$id = $_GET['id'] ?? null;
|
|
if ($id) {
|
|
echo json_encode($paymentIntentService->getClientDetails($id));
|
|
} else {
|
|
echo json_encode(['error' => 'Missing ID']);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
if ($_GET['action'] === 'get_stripe_history') {
|
|
$stripeCustomerId = $_GET['stripeCustomerId'] ?? $_GET['customerId'] ?? null;
|
|
if ($stripeCustomerId) {
|
|
// getCustomerCashBalance returns MXN (already /100), multiply by 100 so JS can do /100
|
|
$cashBalanceMxn = $paymentIntentService->getCustomerCashBalance($stripeCustomerId);
|
|
echo json_encode([
|
|
'success' => true,
|
|
'payments' => $paymentIntentService->getLastPayments($stripeCustomerId, 10),
|
|
'cashBalance' => $cashBalanceMxn * 100 // centavos, JS divides by 100
|
|
]);
|
|
} else {
|
|
echo json_encode(['success' => false, 'error' => 'Missing customer id']);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
if ($_GET['action'] === 'get_oxxo_history') {
|
|
$stripeCustomerId = $_GET['stripeCustomerId'] ?? null;
|
|
if ($stripeCustomerId) {
|
|
echo json_encode(['history' => $paymentIntentService->getLastOxxoPayments($stripeCustomerId, 5)]);
|
|
} else {
|
|
echo json_encode(['error' => 'Missing customer id']);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
if ($_GET['action'] === 'get_installer_jobs') {
|
|
$installerId = $_GET['installerId'] ?? null;
|
|
if (!$installerId) {
|
|
echo json_encode([]);
|
|
exit;
|
|
}
|
|
try {
|
|
$jobs = $ucrmApi->get('scheduling/jobs', [
|
|
'assignedUserId' => $installerId,
|
|
'statuses[]' => 1,
|
|
'limit' => 50
|
|
]);
|
|
$result = [];
|
|
foreach ($jobs as $job) {
|
|
$clientName = 'Sin cliente';
|
|
if (!empty($job['clientId'])) {
|
|
try {
|
|
$client = $ucrmApi->get("clients/{$job['clientId']}");
|
|
$clientName = trim(($client['firstName'] ?? '') . ' ' . ($client['lastName'] ?? ''));
|
|
} catch (\Exception $e) {
|
|
$clientName = 'Cliente #' . $job['clientId'];
|
|
}
|
|
}
|
|
$result[] = [
|
|
'id' => $job['id'],
|
|
'title' => $job['title'] ?? 'Sin título',
|
|
'date' => $job['date'] ?? '',
|
|
'clientName' => $clientName,
|
|
'clientId' => $job['clientId'] ?? null,
|
|
'description' => mb_substr($job['description'] ?? '', 0, 80)
|
|
];
|
|
}
|
|
echo json_encode($result);
|
|
} catch (\Exception $e) {
|
|
echo json_encode([]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
if ($_GET['action'] === 'image' || $_GET['action'] === 'get_image') {
|
|
// Image Handler
|
|
if (ob_get_level()) ob_end_clean();
|
|
$filename = basename($_GET['file'] ?? $_GET['name'] ?? '');
|
|
$paths = [__DIR__ . '/img/' . $filename, __DIR__ . '/img/webp/' . $filename, __DIR__ . '/vouchers_oxxo/' . $filename];
|
|
$finalPath = null;
|
|
foreach ($paths as $p) {
|
|
if (file_exists($p)) {
|
|
$finalPath = $p;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($finalPath) {
|
|
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
|
$type = ($ext === 'png') ? 'image/png' : (($ext === 'webp') ? 'image/webp' : 'image/jpeg');
|
|
header("Content-Type: $type");
|
|
header("Content-Length: " . filesize($finalPath));
|
|
readfile($finalPath);
|
|
} else {
|
|
http_response_code(404);
|
|
echo "Not Found";
|
|
}
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// POST Actions for Intents
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
|
header('Content-Type: application/json');
|
|
if ($_POST['action'] === 'create_intent') {
|
|
try {
|
|
$clientId = $_POST['clientId'] ?? null;
|
|
$amount = (float)($_POST['amount'] ?? 0);
|
|
$stripeCustomerId = $_POST['stripeCustomerId'] ?? null;
|
|
$adminId = $_POST['adminId'] ?? null;
|
|
|
|
if (!$clientId || !$amount || !$stripeCustomerId) {
|
|
throw new \Exception("Datos incompletos para crear intención de pago.");
|
|
}
|
|
|
|
if (ob_get_length()) ob_clean();
|
|
$result = $paymentIntentService->createPaymentIntent($clientId, $amount, $stripeCustomerId, $adminId);
|
|
echo json_encode($result);
|
|
} catch (\Exception $e) {
|
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
if ($_POST['action'] === 'create_oxxo_intent') {
|
|
try {
|
|
$builder = new \DI\ContainerBuilder();
|
|
$container = $builder->build();
|
|
$oxxoService = $container->get(\SmsNotifier\Facade\PluginOxxoNotifierFacade::class);
|
|
if (ob_get_length()) ob_clean();
|
|
$result = $oxxoService->createOxxoPaymentIntent(['client_id' => $_POST['clientId']], $_POST['amount'], false);
|
|
if (ob_get_length()) ob_clean();
|
|
echo json_encode(['success' => true, 'data' => $result]);
|
|
} catch (\Exception $e) {
|
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
}
|
|
}
|
|
|
|
$installersData = json_decode($config['installersDataWhatsApp'] ?? '{"instaladores":[]}', true);
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="es" data-theme="light">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SIIP - Notificaciones y Pagos</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--primary: #2563eb;
|
|
--primary-hover: #1d4ed8;
|
|
--bg-body: #f8fafc;
|
|
--bg-card: #ffffff;
|
|
--text-main: #1e293b;
|
|
--text-muted: #64748b;
|
|
--border: #e2e8f0;
|
|
--danger: #ef4444;
|
|
--success: #22c55e;
|
|
--transition: all 0.3s ease;
|
|
--primary-surface: rgba(37, 99, 235, 0.08);
|
|
/* Re-added for standard consistency */
|
|
}
|
|
|
|
[data-theme="dark"] {
|
|
--primary: #3b82f6;
|
|
--primary-hover: #93c5fd;
|
|
--bg-body: #0f172a;
|
|
--bg-card: #1e293b;
|
|
--text-main: #f1f5f9;
|
|
--text-muted: #94a3b8;
|
|
--border: #334155;
|
|
--primary-surface: rgba(96, 165, 250, 0.15);
|
|
}
|
|
|
|
/* RESET BOX-SIZING */
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Outfit', sans-serif;
|
|
background-color: var(--bg-body);
|
|
color: var(--text-main);
|
|
padding: 2rem;
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
transition: var(--transition);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
flex: 1;
|
|
}
|
|
|
|
/* HEADER - Restored Layout */
|
|
.header {
|
|
background: var(--bg-card);
|
|
padding: 2rem;
|
|
border-radius: 16px;
|
|
margin-bottom: 2rem;
|
|
border: 1px solid var(--border);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 1.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
flex: 1;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.header-logo {
|
|
width: 80px;
|
|
height: 80px;
|
|
object-fit: contain;
|
|
border-radius: 12px;
|
|
background: white;
|
|
padding: 5px;
|
|
border: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.header-title {
|
|
margin: 0 0 5px 0;
|
|
font-size: 1.5rem;
|
|
color: var(--text-main);
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.header-subtitle {
|
|
margin: 0;
|
|
color: var(--text-muted);
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
/* MENU DASHBOARD */
|
|
.menu-container {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
gap: 25px;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.menu-item {
|
|
background-color: var(--bg-card);
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
|
padding: 25px;
|
|
text-align: center;
|
|
transition: var(--transition);
|
|
cursor: pointer;
|
|
border: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
min-height: 250px;
|
|
}
|
|
|
|
.menu-item:hover {
|
|
transform: translateY(-5px);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.menu-item img {
|
|
width: 120px;
|
|
height: 120px;
|
|
object-fit: contain;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.menu-item h3 {
|
|
margin: 0 0 10px 0;
|
|
font-size: 1.6em;
|
|
}
|
|
|
|
.menu-item p {
|
|
margin: 0;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* MODULES & NEW SEARCH UI */
|
|
.section-view {
|
|
display: none;
|
|
}
|
|
|
|
.section-view.active {
|
|
display: block;
|
|
}
|
|
|
|
/* TAB NAVIGATION SYSTEM */
|
|
.tabs {
|
|
display: flex;
|
|
gap: 10px;
|
|
padding: 1rem 0;
|
|
border-bottom: 2px solid var(--border);
|
|
margin-bottom: 2rem;
|
|
flex-wrap: wrap;
|
|
position: sticky;
|
|
top: 0;
|
|
background: var(--bg-body);
|
|
z-index: 100;
|
|
}
|
|
|
|
.tab {
|
|
padding: 12px 24px;
|
|
border-radius: 8px 8px 0 0;
|
|
text-decoration: none;
|
|
color: var(--text-main);
|
|
font-weight: 500;
|
|
transition: all 0.3s ease;
|
|
border: 2px solid transparent;
|
|
white-space: nowrap;
|
|
display: flex;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.tab:hover {
|
|
background: var(--bg-card);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.tab.active {
|
|
background: var(--primary);
|
|
color: white;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
.card {
|
|
background: var(--bg-card);
|
|
border-radius: 16px;
|
|
padding: 2rem;
|
|
border: 1px solid var(--border);
|
|
margin-bottom: 2rem;
|
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
/* SEARCH STYLES */
|
|
.search-wrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 15px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--text-muted);
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
.search-input-padded {
|
|
padding-left: 45px !important;
|
|
}
|
|
|
|
.form-control {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
border-radius: 10px;
|
|
border: 1px solid var(--border);
|
|
background: var(--bg-body);
|
|
color: var(--text-main);
|
|
font-size: 1rem;
|
|
}
|
|
|
|
/* RICH SEARCH RESULTS */
|
|
.search-results {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
margin-top: 5px;
|
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
|
z-index: 2000;
|
|
display: none;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.search-item {
|
|
padding: 15px;
|
|
border-bottom: 1px solid var(--border);
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
}
|
|
|
|
.search-item:hover {
|
|
background: var(--bg-body);
|
|
}
|
|
|
|
.search-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
margin-top: 6px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.status-dot.active {
|
|
background-color: var(--success);
|
|
}
|
|
|
|
.status-dot.suspended {
|
|
background-color: var(--danger);
|
|
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
|
|
}
|
|
|
|
.client-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.client-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.client-name {
|
|
font-weight: 700;
|
|
font-size: 1.05rem;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.highlight {
|
|
background-color: #fde047;
|
|
color: black;
|
|
padding: 0 2px;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.badge-suspended {
|
|
background-color: #fffbeb;
|
|
color: #d97706;
|
|
border: 1px solid #fcd34d;
|
|
font-size: 0.7rem;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
text-transform: uppercase;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.client-details {
|
|
font-size: 0.85rem;
|
|
color: var(--text-muted);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* BUTTONS & BADGES */
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 10px 20px;
|
|
border-radius: 10px;
|
|
border: none;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: var(--bg-body);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.btn-whatsapp {
|
|
background-color: #25D366;
|
|
color: white !important;
|
|
}
|
|
|
|
.badge {
|
|
padding: 4px 12px;
|
|
border-radius: 100px;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* UNIFIED INPUT GROUP (Premium Focus) */
|
|
.input-group-unified {
|
|
display: flex;
|
|
align-items: stretch;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--bg-body);
|
|
transition: all 0.2s ease;
|
|
overflow: hidden;
|
|
/* Ensures children don't overflow corners */
|
|
}
|
|
|
|
.input-group-unified:focus-within {
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 2px rgba(37, 211, 102, 0.2);
|
|
/* Green glow for OXXO theme consistency or use primary */
|
|
}
|
|
|
|
.input-group-unified .input-prefix {
|
|
position: relative;
|
|
padding: 10px 15px;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
display: flex;
|
|
align-items: center;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.input-group-unified .form-control {
|
|
flex: 1;
|
|
padding: 12px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-main);
|
|
font-size: 1rem;
|
|
outline: none;
|
|
/* Remove default focus outline */
|
|
box-shadow: none;
|
|
/* Remove default shadow */
|
|
}
|
|
|
|
.input-group-unified .form-control:focus {
|
|
outline: none;
|
|
box-shadow: none;
|
|
}
|
|
|
|
/* ROUNDED CONFIG CONTAINER (Standardized Layout) */
|
|
.config-container {
|
|
background: var(--bg-body);
|
|
padding: 1.5rem;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border);
|
|
margin-bottom: 2rem;
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: flex-end;
|
|
/* Align inputs and buttons */
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.config-container .form-group {
|
|
margin-bottom: 0;
|
|
/* Remove default margin inside container */
|
|
flex: 1;
|
|
min-width: 250px;
|
|
}
|
|
|
|
/* PLACEHOLDER STATES */
|
|
.placeholder-state {
|
|
text-align: center;
|
|
padding: 4rem;
|
|
color: var(--text-muted);
|
|
border: 1px dashed var(--border);
|
|
border-radius: 12px;
|
|
margin-top: 2rem;
|
|
}
|
|
|
|
.placeholder-icon {
|
|
font-size: 3rem;
|
|
margin-bottom: 1rem;
|
|
opacity: 0.5;
|
|
display: block;
|
|
}
|
|
|
|
/* STRIPE CASH BALANCE BADGE */
|
|
.balance-badge {
|
|
align-items: center;
|
|
background-color: #f1f5f9;
|
|
color: var(--text-main);
|
|
padding: 6px 14px;
|
|
border-radius: 8px;
|
|
font-size: 0.9em;
|
|
font-weight: 700;
|
|
border: 1px solid #e2e8f0;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
[data-theme="dark"] .balance-badge {
|
|
background-color: #bfdbfe;
|
|
/* Light blue background for contrast */
|
|
color: #1e3a8a;
|
|
/* Navy blue text */
|
|
border-color: #93c5fd;
|
|
}
|
|
|
|
/* ENHANCED DARK MODE CONTRAST FOR SEARCH BOXES */
|
|
[data-theme="dark"] .config-container {
|
|
background: #1e293b;
|
|
border: 1px solid #475569;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
[data-theme="dark"] .search-wrapper .form-control {
|
|
background: #0f172a;
|
|
border: 1px solid #475569;
|
|
color: #f1f5f9;
|
|
}
|
|
|
|
[data-theme="dark"] .search-wrapper .form-control:focus {
|
|
border-color: var(--primary-hover);
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
|
|
}
|
|
|
|
/* THEME TOGGLE (Premium) */
|
|
.theme-toggle {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
padding: 4px 12px 4px 8px;
|
|
/* Slightly more padding on right for balance */
|
|
border-radius: 50px;
|
|
cursor: pointer;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.theme-toggle:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.theme-toggle:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.theme-icon-box {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
background: var(--bg-body);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
color: var(--text-main);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.theme-toggle:hover .theme-icon-box {
|
|
transform: rotate(15deg) scale(1.1);
|
|
}
|
|
|
|
.theme-text {
|
|
font-weight: 600;
|
|
font-size: 0.85rem;
|
|
color: var(--text-main);
|
|
letter-spacing: 0.5px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
/* Modal & Tables */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 3000;
|
|
}
|
|
|
|
.modal {
|
|
background: var(--bg-card);
|
|
padding: 2rem;
|
|
border-radius: 16px;
|
|
width: 90%;
|
|
max-width: 500px;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: separate;
|
|
border-spacing: 0;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--border);
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
}
|
|
|
|
thead {
|
|
background-color: var(--primary-surface);
|
|
}
|
|
|
|
th {
|
|
padding: 16px 12px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
color: var(--text-main);
|
|
text-transform: uppercase;
|
|
font-size: 0.85rem;
|
|
letter-spacing: 0.05em;
|
|
border-bottom: 2px solid var(--border);
|
|
}
|
|
|
|
td {
|
|
padding: 14px 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
color: var(--text-muted);
|
|
vertical-align: middle;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
tbody tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
tbody tr:hover td {
|
|
background-color: rgba(37, 99, 235, 0.04);
|
|
color: var(--text-main);
|
|
}
|
|
|
|
[data-theme="dark"] tbody tr:hover td {
|
|
background-color: rgba(96, 165, 250, 0.08);
|
|
}
|
|
|
|
[data-theme="dark"] thead {
|
|
background-color: rgba(30, 41, 59, 0.8);
|
|
}
|
|
|
|
[data-theme="dark"] th {
|
|
color: #e2e8f0;
|
|
}
|
|
|
|
/* Toast */
|
|
#toast {
|
|
position: fixed;
|
|
bottom: 2rem;
|
|
left: 50%;
|
|
transform: translate(-50%, 150%);
|
|
padding: 1rem 2rem;
|
|
border-radius: 12px;
|
|
background: #333;
|
|
color: #fff;
|
|
transition: transform 0.3s;
|
|
z-index: 4000;
|
|
}
|
|
|
|
/* UNIFORM BUTTONS */
|
|
.btn-uniform {
|
|
background-color: var(--primary) !important;
|
|
color: white !important;
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 8px 16px;
|
|
font-weight: 600;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
transition: all 0.2s;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.btn-uniform:hover {
|
|
opacity: 0.9;
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
/* ICONS */
|
|
.icon-btn {
|
|
width: 20px;
|
|
height: 20px;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.icon-crm {
|
|
width: 18px;
|
|
height: 18px;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.icon-action {
|
|
width: 20px;
|
|
height: 20px;
|
|
cursor: pointer;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.icon-action:hover {
|
|
transform: scale(1.2);
|
|
}
|
|
|
|
.client-header-icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
object-fit: contain;
|
|
margin-right: 10px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
#toast.show {
|
|
transform: translate(-50%, 0);
|
|
}
|
|
|
|
/* STRIPE MODULE REDESIGN */
|
|
.client-header-grid {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 1rem;
|
|
padding-bottom: 1.5rem;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.client-info h3 {
|
|
margin: 0 0 4px 0;
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.client-info p {
|
|
margin: 0;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.client-balance {
|
|
text-align: right;
|
|
}
|
|
|
|
.client-balance .label {
|
|
display: block;
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.client-balance .badge {
|
|
font-size: 1.1rem;
|
|
padding: 6px 16px;
|
|
}
|
|
|
|
.details-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
gap: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
background: var(--bg-body);
|
|
padding: 1.5rem;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.detail-item label {
|
|
display: block;
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
margin-bottom: 6px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.detail-item .value-box {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 1rem;
|
|
color: var(--text-main);
|
|
word-break: break-all;
|
|
background: var(--bg-card);
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1.5rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
body {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.form-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.header {
|
|
padding: 1.5rem;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.header-left {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
min-width: 100%;
|
|
}
|
|
|
|
.header-logo {
|
|
width: 60px;
|
|
height: 60px;
|
|
}
|
|
|
|
.header-title {
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.tabs {
|
|
flex-wrap: nowrap;
|
|
overflow-x: auto;
|
|
padding-bottom: 10px;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
.tab {
|
|
padding: 10px 16px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.config-container {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.actions-row {
|
|
flex-direction: column;
|
|
width: 100%;
|
|
}
|
|
|
|
.actions-row .btn {
|
|
width: 100%;
|
|
}
|
|
|
|
.menu-container {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.menu-item {
|
|
min-height: 200px;
|
|
padding: 1.5rem;
|
|
}
|
|
}
|
|
|
|
.table-responsive {
|
|
width: 100%;
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-weight: 600;
|
|
color: var(--text-main);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.section-title {
|
|
margin: 0 0 1.5rem 0;
|
|
font-size: 1.1rem;
|
|
color: var(--text-main);
|
|
border-left: 4px solid var(--primary);
|
|
padding-left: 12px;
|
|
}
|
|
|
|
.input-group {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.input-prefix {
|
|
position: absolute;
|
|
left: 12px;
|
|
color: var(--text-muted);
|
|
font-weight: 500;
|
|
z-index: 10;
|
|
}
|
|
|
|
.input-with-prefix {
|
|
padding-left: 35px !important;
|
|
}
|
|
|
|
.actions-row {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-top: 2rem;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.btn-lg {
|
|
padding: 12px 24px;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.btn-outline {
|
|
background: transparent;
|
|
border: 2px solid var(--border);
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.btn-outline:hover {
|
|
border-color: var(--primary);
|
|
color: var(--primary);
|
|
}
|
|
|
|
/* ── Login Overlay ─────────────────────────────────────── */
|
|
#loginOverlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 99999;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
|
font-family: 'Outfit', sans-serif;
|
|
transition: opacity 0.5s ease, visibility 0.5s ease;
|
|
}
|
|
|
|
#loginOverlay.hidden {
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.login-card {
|
|
width: 400px;
|
|
max-width: 92vw;
|
|
background: rgba(30, 41, 59, 0.85);
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
border-radius: 24px;
|
|
padding: 48px 36px 36px;
|
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
|
text-align: center;
|
|
animation: loginSlideIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
}
|
|
|
|
@keyframes loginSlideIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(30px) scale(0.96);
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
}
|
|
|
|
.login-logo {
|
|
width: 90px;
|
|
height: 90px;
|
|
object-fit: contain;
|
|
margin-bottom: 12px;
|
|
filter: drop-shadow(0 4px 20px rgba(99, 102, 241, 0.3));
|
|
}
|
|
|
|
.login-title {
|
|
color: #f8fafc;
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
margin: 0 0 4px;
|
|
}
|
|
|
|
.login-subtitle {
|
|
color: #94a3b8;
|
|
font-size: 0.85rem;
|
|
margin: 0 0 28px;
|
|
}
|
|
|
|
.login-field {
|
|
position: relative;
|
|
margin-bottom: 16px;
|
|
text-align: left;
|
|
}
|
|
|
|
.login-field label {
|
|
display: block;
|
|
color: #94a3b8;
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.login-field input {
|
|
width: 100%;
|
|
padding: 12px 14px;
|
|
background: rgba(15, 23, 42, 0.7);
|
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
|
border-radius: 12px;
|
|
color: #f1f5f9;
|
|
font-size: 0.95rem;
|
|
font-family: 'Outfit', sans-serif;
|
|
outline: none;
|
|
transition: border-color 0.25s, box-shadow 0.25s;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.login-field input:focus {
|
|
border-color: #6366f1;
|
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
|
}
|
|
|
|
.login-field input::placeholder {
|
|
color: #475569;
|
|
}
|
|
|
|
.login-btn {
|
|
width: 100%;
|
|
padding: 13px;
|
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 12px;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
font-family: 'Outfit', sans-serif;
|
|
cursor: pointer;
|
|
transition: transform 0.15s, box-shadow 0.25s;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.login-btn:hover:not(:disabled) {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 8px 30px rgba(99, 102, 241, 0.35);
|
|
}
|
|
|
|
.login-btn:active:not(:disabled) {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.login-btn:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.login-error {
|
|
background: rgba(239, 68, 68, 0.12);
|
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
|
color: #fca5a5;
|
|
padding: 10px 14px;
|
|
border-radius: 10px;
|
|
font-size: 0.85rem;
|
|
margin-bottom: 16px;
|
|
display: none;
|
|
}
|
|
|
|
.login-error.visible {
|
|
display: block;
|
|
}
|
|
|
|
.login-spinner {
|
|
display: inline-block;
|
|
width: 18px;
|
|
height: 18px;
|
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
border-top-color: white;
|
|
border-radius: 50%;
|
|
animation: loginSpin 0.6s linear infinite;
|
|
vertical-align: middle;
|
|
margin-right: 6px;
|
|
}
|
|
|
|
@keyframes loginSpin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
#loginTotpSection {
|
|
display: none;
|
|
}
|
|
|
|
#loginTotpSection.visible {
|
|
display: block;
|
|
}
|
|
|
|
.login-footer {
|
|
margin-top: 24px;
|
|
color: #475569;
|
|
font-size: 0.72rem;
|
|
}
|
|
|
|
.login-footer a {
|
|
color: #6366f1;
|
|
text-decoration: none;
|
|
}
|
|
|
|
/* Particles background */
|
|
.login-particles {
|
|
position: absolute;
|
|
inset: 0;
|
|
overflow: hidden;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.login-particles span {
|
|
position: absolute;
|
|
width: 4px;
|
|
height: 4px;
|
|
background: rgba(99, 102, 241, 0.3);
|
|
border-radius: 50%;
|
|
animation: loginFloat 12s infinite ease-in-out;
|
|
}
|
|
|
|
@keyframes loginFloat {
|
|
|
|
0%,
|
|
100% {
|
|
transform: translateY(0) translateX(0);
|
|
opacity: 0;
|
|
}
|
|
|
|
10% {
|
|
opacity: 1;
|
|
}
|
|
|
|
90% {
|
|
opacity: 1;
|
|
}
|
|
|
|
50% {
|
|
transform: translateY(-400px) translateX(100px);
|
|
}
|
|
}
|
|
|
|
.login-theme-btn {
|
|
position: absolute;
|
|
top: 24px;
|
|
right: 24px;
|
|
z-index: 10000;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
padding: 10px 20px;
|
|
border-radius: 30px;
|
|
font-family: inherit;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
background: rgba(15, 23, 42, 0.6);
|
|
color: #f8fafc;
|
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
backdrop-filter: blur(16px);
|
|
-webkit-backdrop-filter: blur(16px);
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
|
cursor: pointer;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.login-theme-btn:hover {
|
|
background: rgba(15, 23, 42, 0.8);
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
|
border-color: rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
[data-theme="light"] #loginOverlay {
|
|
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #f8fafc 100%);
|
|
}
|
|
|
|
[data-theme="light"] .login-card {
|
|
background: rgba(255, 255, 255, 0.85);
|
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
[data-theme="light"] .login-title {
|
|
color: #0f172a;
|
|
}
|
|
|
|
[data-theme="light"] .login-theme-btn {
|
|
background: rgba(255, 255, 255, 0.7);
|
|
color: #1e293b;
|
|
border: 1px solid rgba(255, 255, 255, 1);
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
[data-theme="light"] .login-theme-btn:hover {
|
|
background: rgba(255, 255, 255, 0.95);
|
|
border-color: rgba(209, 213, 219, 0.8);
|
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.login-theme-btn .theme-icon,
|
|
.login-theme-btn .theme-icon-box {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
[data-theme="light"] .login-subtitle,
|
|
[data-theme="light"] .login-field label,
|
|
[data-theme="light"] .login-footer {
|
|
color: #475569;
|
|
}
|
|
|
|
[data-theme="light"] .login-field input {
|
|
background: #ffffff;
|
|
border: 1px solid #cbd5e1;
|
|
color: #0f172a;
|
|
}
|
|
|
|
[data-theme="light"] .login-field input:focus {
|
|
border-color: #6366f1;
|
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
|
background: #fff;
|
|
}
|
|
|
|
[data-theme="light"] .login-field input::placeholder {
|
|
color: #94a3b8;
|
|
}
|
|
|
|
[data-theme="light"] .login-particles span {
|
|
background: rgba(99, 102, 241, 0.4);
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<!-- ━━━ Login Overlay ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
|
|
<div id="loginOverlay">
|
|
<button class="theme-toggle login-theme-btn" onclick="toggleTheme()">
|
|
<div class="theme-icon-box" style="display: flex; align-items: center; justify-content: center;"></div>
|
|
<span class="theme-text">Modo Claro</span>
|
|
</button>
|
|
<div class="login-particles" id="loginParticles"></div>
|
|
<div class="login-card">
|
|
<img src="?action=image&file=whatsapp-logo.png" class="login-logo" alt="SIIP">
|
|
<h2 class="login-title">SIIP Notifications</h2>
|
|
<p class="login-subtitle">Inicia sesión con tu cuenta de UISP</p>
|
|
|
|
<div class="login-error" id="loginError"></div>
|
|
|
|
<!-- Formulario principal -->
|
|
<div id="loginFormSection">
|
|
<div class="login-field">
|
|
<label for="loginUsername">Usuario</label>
|
|
<input type="text" id="loginUsername" placeholder="Ingresa tu usuario" autocomplete="username" autofocus>
|
|
</div>
|
|
<div class="login-field">
|
|
<label for="loginPassword">Contraseña</label>
|
|
<input type="password" id="loginPassword" placeholder="Ingresa tu contraseña" autocomplete="current-password">
|
|
</div>
|
|
<button class="login-btn" id="loginBtn" onclick="handleLogin()">Iniciar Sesión</button>
|
|
</div>
|
|
|
|
<!-- Sección 2FA (oculta por defecto) -->
|
|
<div id="loginTotpSection">
|
|
<div class="login-field">
|
|
<label for="loginTotpCode">Código de autenticación (2FA)</label>
|
|
<input type="text" id="loginTotpCode" placeholder="Código de 6 dígitos" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
|
|
</div>
|
|
<button class="login-btn" id="loginTotpBtn" onclick="handleTotpLogin()">Verificar Código</button>
|
|
</div>
|
|
|
|
<div class="login-footer">
|
|
Acceso restringido a administradores UISP · SIIP © <?php echo date('Y'); ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<!-- HEADER -->
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<img src="?action=image&file=logo-empresa.png" class="header-logo">
|
|
<div>
|
|
<!-- RESTORED HEADER TEXT -->
|
|
<h1 class="header-title">Portal Administrativo de Pagos de STRIPE y Notificaciones WhatsApp</h1>
|
|
<p class="header-subtitle">Administración de Notificaciones vía WhatsApp, Intenciones de pago con Stripe y Fichas de OXXO Pay</p>
|
|
</div>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button class="btn btn-secondary hidden" id="btnBackToMenu" onclick="showDashboard()">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2-2H5a2 2 0 0 1-2-2z" />
|
|
<polyline points="9 22 9 12 15 12 15 22" />
|
|
</svg>
|
|
Menú Principal
|
|
</button>
|
|
<?php if (strpos($_SERVER['REQUEST_URI'], '_plugins') !== false): ?>
|
|
<button id="logoutBtn" class="btn btn-secondary" style="color: #ef4444; border-color: rgba(239, 68, 68, 0.3); background: rgba(239, 68, 68, 0.05);" onclick="handleLogout()">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle; margin-right:6px;">
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
|
<polyline points="16 17 21 12 16 7"></polyline>
|
|
<line x1="21" y1="12" x2="9" y2="12"></line>
|
|
</svg>
|
|
Cerrar Sesión
|
|
</button>
|
|
<?php endif; ?>
|
|
<!-- RESTORED THEME BUTTON -->
|
|
<!-- PREMIUM THEME BUTTON -->
|
|
<button class="theme-toggle" id="themeBtn" onclick="toggleTheme()">
|
|
<div class="theme-icon-box">
|
|
<!-- Icon injected by JS -->
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="theme-icon-svg">
|
|
<circle cx="12" cy="12" r="5"></circle>
|
|
<line x1="12" y1="1" x2="12" y2="3"></line>
|
|
<line x1="12" y1="21" x2="12" y2="23"></line>
|
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
|
<line x1="1" y1="12" x2="3" y2="12"></line>
|
|
<line x1="21" y1="12" x2="23" y2="12"></line>
|
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
|
</svg>
|
|
</div>
|
|
<span class="theme-text" id="themeText">Modo Claro</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MAIN MENU -->
|
|
<div class="menu-container" id="mainDashboard">
|
|
<div class="menu-item" onclick="showModule('installers')">
|
|
<img src="?action=image&file=instalador.png">
|
|
<h3>Administrar Instaladores</h3>
|
|
<p>Administra los técnicos e instaladores registrados en el sistema.</p>
|
|
</div>
|
|
<div class="menu-item" onclick="showModule('notifications')">
|
|
<img src="?action=image&file=whatsapp-logo.png">
|
|
<h3>Reenviar Notificaciones de pago vía WhatsApp</h3>
|
|
<p>Re-envía manualmente notificaciones de pago vía WhatsApp.</p>
|
|
</div>
|
|
<div class="menu-item" onclick="showModule('stripe')">
|
|
<img src="?action=image&file=stripe-logo.png">
|
|
<h3>Generador de Intenciones de Pago Stripe</h3>
|
|
<p>Genera intenciones de pago vía Stripe personalizadas.</p>
|
|
</div>
|
|
<div class="menu-item" onclick="showModule('oxxo')">
|
|
<img src="?action=image&file=oxxo-logo.png">
|
|
<h3>Generador de fichas OXXO</h3>
|
|
<p>Genera fichas y códigos de barras para pago en tiendas OXXO.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MODULES -->
|
|
<div id="moduleView" class="hidden">
|
|
<!-- Tabs Navigation -->
|
|
<nav class="tabs">
|
|
<a href="#" class="tab active" onclick="switchTab('instaladores'); return false;">
|
|
<img src="?action=get_image&name=installers-management.png" style="width:24px;height:24px;vertical-align:middle;margin-right:8px;"> Administrar Instaladores
|
|
</a>
|
|
<a href="#" class="tab" onclick="switchTab('notificaciones'); return false;">
|
|
<img src="?action=get_image&name=whatsapp-notification.png" style="width:24px;height:24px;vertical-align:middle;margin-right:8px;"> Reenviar Notificaciones de pago vía WhatsApp
|
|
</a>
|
|
<a href="#" class="tab" onclick="switchTab('pagos-spei'); return false;">
|
|
<img src="?action=get_image&name=online-payments-stripe.png" style="width:24px;height:24px;vertical-align:middle;margin-right:8px;"> Generador de Intenciones de Pago Stripe
|
|
</a>
|
|
<a href="#" class="tab" onclick="switchTab('pagos-oxxo'); return false;">
|
|
<img src="?action=get_image&name=oxxo-payments.png" style="width:24px;height:24px;vertical-align:middle;margin-right:8px;"> Generador de fichas OXXO
|
|
</a>
|
|
</nav>
|
|
|
|
<!-- MODULE 1: INSTALLERS -->
|
|
<section id="section-instaladores" class="section-view active">
|
|
<div class="card">
|
|
<div style="margin-bottom: 2rem;">
|
|
<h2 style="margin: 0; display: flex; align-items: center; gap: 10px;">
|
|
<img src="?action=get_image&name=installers-management.png" style="width:64px;height:64px;"> Gestión de Instaladores
|
|
</h2>
|
|
<p style="color: var(--text-muted); margin: 5px 0 0 0;">👷 Administra tu equipo de técnicos y asigna instalaciones de manera eficiente</p>
|
|
</div>
|
|
|
|
<!-- CONFIG CONTAINER -->
|
|
<!-- CONFIG CONTAINER -->
|
|
<div style="display: flex; justify-content: flex-end; margin-bottom: 2rem;">
|
|
<button class="btn btn-primary" onclick="openInstallerModal()" style="height: 42px;">
|
|
+ Nuevo Instalador
|
|
</button>
|
|
</div>
|
|
|
|
<div style="overflow-x: auto;">
|
|
<table id="installersTable">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Nombre</th>
|
|
<th>WhatsApp</th>
|
|
<th>Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div id="installersEmptyState" class="placeholder-state" style="display: none;">
|
|
<span class="placeholder-icon">🔍</span>
|
|
<p>No se encontraron instaladores</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- JOBS ACTIVOS DEL INSTALADOR -->
|
|
<div class="card" style="margin-top: 2rem;">
|
|
<div style="margin-bottom: 1.5rem;">
|
|
<h2 id="jobsSectionTitle" style="margin: 0; display: flex; align-items: center; gap: 10px;">
|
|
📋 Tareas Activas del Instalador
|
|
</h2>
|
|
<p style="color: var(--text-muted); margin: 5px 0 0 0;">Selecciona un instalador con el botón 📋 para ver sus tareas "En curso" y reenviar notificaciones</p>
|
|
</div>
|
|
|
|
<div id="jobsEmptyState" class="placeholder-state">
|
|
<span class="placeholder-icon">👷</span>
|
|
<p>Selecciona un instalador para ver sus tareas activas</p>
|
|
</div>
|
|
|
|
<div id="jobsLoading" style="display: none; text-align: center; padding: 2rem; color: var(--text-muted);">
|
|
<div class="loader" style="margin: 0 auto 1rem;"></div>
|
|
<p>Cargando tareas...</p>
|
|
</div>
|
|
|
|
<div id="jobsTableWrapper" style="display: none; overflow-x: auto;">
|
|
<table id="installerJobsTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Folio</th>
|
|
<th>Cliente</th>
|
|
<th>Fecha</th>
|
|
<th>Descripción</th>
|
|
<th>Acción</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div id="jobsNoResults" class="placeholder-state" style="display: none;">
|
|
<span class="placeholder-icon">✅</span>
|
|
<p>Este instalador no tiene tareas "En curso"</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- MODULE 2: NOTIFICATIONS -->
|
|
<section id="section-notificaciones" class="section-view">
|
|
<div class="card">
|
|
<div style="margin-bottom: 2rem;">
|
|
<h2 style="margin: 0; display: flex; align-items: center; gap: 10px;">
|
|
<img src="?action=get_image&name=whatsapp-notification.png" style="width:64px;height:64px;"> Notificaciones WhatsApp
|
|
</h2>
|
|
<p style="color: var(--text-muted); margin: 5px 0 0 0;">📱 Busca clientes y envía comprobantes de pago directamente a su WhatsApp</p>
|
|
</div>
|
|
|
|
<!-- CONFIG CONTAINER -->
|
|
<div class="config-container">
|
|
<div class="form-group" style="position: relative;">
|
|
<label>Buscar Cliente (Nombre, Email o ID)</label>
|
|
<div class="search-wrapper">
|
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="11" cy="11" r="8" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
<input type="text" id="clientSearch" class="form-control search-input-padded" placeholder="Escribe para buscar (ej. Juan Perez)..." autocomplete="off">
|
|
</div>
|
|
<div id="searchResults" class="search-results"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PLACEHOLDER STATE (Initially Visible) -->
|
|
<div id="notificationsPlaceholder" class="placeholder-state">
|
|
<span class="placeholder-icon">👋</span>
|
|
<p>Busca un cliente arriba para ver sus pagos y enviar notificaciones</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="paymentsContainer" class="card" style="display: none;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 10px;">
|
|
<h3 style="margin: 0; display: flex; align-items: center;">
|
|
<img src="?action=image&file=client.webp" class="client-header-icon"> <span id="selectedClientName">Pagos del Cliente</span>
|
|
</h3>
|
|
<div style="display: flex; gap: 8px;">
|
|
<a id="btnNotifCrm" href="#" target="_blank" class="btn btn-uniform" style="height: 38px;">
|
|
<img src="?action=image&file=crm.webp" class="icon-crm"> Ver en CRM
|
|
</a>
|
|
<button class="btn btn-secondary" onclick="refreshClientPayments()" style="height: 38px;">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M23 4v6h-6" />
|
|
<path d="M1 20v-6h6" />
|
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
|
</svg>
|
|
Refrescar Pagos
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x: auto;">
|
|
<table id="paymentsTable">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Fecha</th>
|
|
<th>Monto</th>
|
|
<th>Método</th>
|
|
<th>Acción</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<?php include __DIR__ . '/views/stripe.php'; ?>
|
|
<?php include __DIR__ . '/views/oxxo.php'; ?>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- FOOTER -->
|
|
<footer style="
|
|
margin-top: 80px;
|
|
padding: 20px;
|
|
text-align: center;
|
|
border-top: 2px solid var(--primary);
|
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
|
|
background: var(--bg-card);
|
|
border-radius: 12px 12px 0 0;
|
|
">
|
|
<?php
|
|
$manifestPath = __DIR__ . '/manifest.json';
|
|
$pluginVersion = 'Desconocida';
|
|
if (file_exists($manifestPath)) {
|
|
$manifestData = json_decode(file_get_contents($manifestPath), true);
|
|
if (isset($manifestData['information']['version'])) {
|
|
$pluginVersion = $manifestData['information']['version'];
|
|
}
|
|
}
|
|
?>
|
|
<p style="margin: 0; font-size: 0.9rem; color: var(--text-main);">
|
|
Plugin Desarrollado por <strong>SIIP INTERNET</strong> - Todos los derechos reservados.
|
|
</p>
|
|
<p style="margin: 5px 0 0 0; font-size: 0.85rem; color: var(--text-muted);">
|
|
© <?php echo date('Y'); ?> SIIP Internet. Versión <?php echo htmlspecialchars($pluginVersion); ?>
|
|
</p>
|
|
</footer>
|
|
|
|
<!-- MODAL INSTALLER -->
|
|
<div class="modal-overlay" id="modalOverlay">
|
|
<div class="modal">
|
|
<h2 id="modalTitle">Registro de Instalador</h2>
|
|
<form id="installerForm">
|
|
<input type="hidden" id="editIndex">
|
|
<div class="form-group" id="adminSelectGroup">
|
|
<label>Seleccionar Administrador UCRM</label>
|
|
<select id="adminSelect" class="form-control" onchange="fillInstallerData(this)">
|
|
<option value="">-- Seleccionar --</option>
|
|
<?php foreach ($admins as $admin): echo "<option value='{$admin['id']}' data-name='{$admin['nombre']}'>{$admin['nombre']}</option>";
|
|
endforeach; ?>
|
|
</select>
|
|
</div>
|
|
<input type="number" id="installerId" class="form-control" readonly style="margin: 10px 0" placeholder="ID Usuario">
|
|
<input type="text" id="installerName" class="form-control" readonly style="margin: 10px 0" placeholder="Nombre">
|
|
<input type="text" id="installerWhatsApp" class="form-control" required style="margin: 10px 0" placeholder="WhatsApp (52...)">
|
|
<div style="display:flex; justify-content:flex-end; gap:10px; margin-top:20px;">
|
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('modalOverlay').style.display='none'">Cancelar</button>
|
|
<button type="submit" class="btn btn-primary">Guardar</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STRIPE RESULT MODAL -->
|
|
<div id="stripeResultModal" class="modal-overlay" style="z-index: 3500;">
|
|
<div class="modal">
|
|
<h2>Referencia Generada</h2>
|
|
<div id="stripeResultContent"></div>
|
|
<div style="text-align: right; margin-top: 20px;">
|
|
<button class="btn btn-primary" onclick="document.getElementById('stripeResultModal').style.display='none'">Cerrar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
const NEEDS_LOGIN = <?php echo $needsLogin ? 'true' : 'false'; ?>;
|
|
let SYSTEM_USER_ID = <?php echo $currentUser ? $currentUser->userId : 'null'; ?>;
|
|
|
|
const store = {
|
|
installers: <?php echo json_encode($installersData['instaladores']); ?>,
|
|
theme: localStorage.getItem('theme') || 'light',
|
|
crmUrl: '<?php echo $ucrmApiUrl; ?>',
|
|
publicUrl: '<?php echo $ucrmPublicUrl; ?>',
|
|
defaultStripeAdminId: '<?php echo $defaultStripeAdminId; ?>'
|
|
};
|
|
let currentClientId = null;
|
|
|
|
// --- THEME LOGIC (Restored) ---
|
|
function toggleTheme() {
|
|
const next = store.theme === 'light' ? 'dark' : 'light';
|
|
store.theme = next;
|
|
document.documentElement.setAttribute('data-theme', next);
|
|
localStorage.setItem('theme', next);
|
|
updateThemeBtn();
|
|
}
|
|
|
|
window.handleLogout = () => {
|
|
sessionStorage.removeItem('nms_auth_token');
|
|
sessionStorage.removeItem('nms_user');
|
|
window.location.reload();
|
|
};
|
|
|
|
|
|
function updateThemeBtn() {
|
|
const iconSun = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>`;
|
|
const iconMoon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>`;
|
|
|
|
document.querySelectorAll('.theme-toggle').forEach(btn => {
|
|
const listBox = btn.querySelector('.theme-icon-box');
|
|
const text = btn.querySelector('.theme-text');
|
|
|
|
if (store.theme === 'dark') {
|
|
if (listBox) listBox.innerHTML = iconMoon;
|
|
if (text) text.textContent = 'Modo Oscuro';
|
|
if (!btn.classList.contains('login-theme-btn')) {
|
|
btn.style.borderColor = '#6366f1';
|
|
btn.style.background = 'rgba(30, 41, 59, 0.8)';
|
|
}
|
|
} else {
|
|
if (listBox) listBox.innerHTML = iconSun;
|
|
if (text) text.textContent = 'Modo Claro';
|
|
if (!btn.classList.contains('login-theme-btn')) {
|
|
btn.style.borderColor = '#f59e0b';
|
|
btn.style.background = '#fff';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
document.documentElement.setAttribute('data-theme', store.theme);
|
|
updateThemeBtn();
|
|
|
|
function showDashboard() {
|
|
document.getElementById('mainDashboard').style.display = 'grid';
|
|
document.getElementById('moduleView').classList.add('hidden');
|
|
document.getElementById('btnBackToMenu').classList.add('hidden');
|
|
}
|
|
|
|
function showModule(id) {
|
|
document.getElementById('mainDashboard').style.display = 'none';
|
|
document.getElementById('moduleView').classList.remove('hidden');
|
|
document.getElementById('btnBackToMenu').classList.remove('hidden');
|
|
|
|
// Map old module IDs to new tab names (support both English and Spanish IDs)
|
|
const moduleMap = {
|
|
// Spanish IDs
|
|
'instaladores': 'instaladores',
|
|
'notificaciones': 'notificaciones',
|
|
'stripe': 'pagos-spei',
|
|
'oxxo': 'pagos-oxxo',
|
|
// English IDs (from dashboard menu)
|
|
'installers': 'instaladores',
|
|
'notifications': 'notificaciones'
|
|
};
|
|
|
|
const tabName = moduleMap[id] || 'instaladores';
|
|
switchTab(tabName);
|
|
}
|
|
|
|
function switchTab(tabName) {
|
|
// Update tabs visual state
|
|
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
|
|
|
|
// Find and activate the clicked tab
|
|
const targetTab = Array.from(document.querySelectorAll('.tab')).find(tab => {
|
|
return tab.getAttribute('onclick').includes(`'${tabName}'`);
|
|
});
|
|
if (targetTab) {
|
|
targetTab.classList.add('active');
|
|
}
|
|
|
|
// Update sections visibility
|
|
document.querySelectorAll('.section-view').forEach(section => section.classList.remove('active'));
|
|
const targetSection = document.getElementById(`section-${tabName}`);
|
|
if (targetSection) {
|
|
targetSection.classList.add('active');
|
|
}
|
|
|
|
// Initialize module if needed
|
|
if (tabName === 'notificaciones' && !window.notificacionesInitialized) {
|
|
// Load clients for notifications module
|
|
loadClients();
|
|
window.notificacionesInitialized = true;
|
|
}
|
|
}
|
|
|
|
function showToast(msg, err = false) {
|
|
const t = document.getElementById('toast');
|
|
t.textContent = msg;
|
|
t.style.background = err ? '#ef4444' : '#333';
|
|
t.classList.add('show');
|
|
setTimeout(() => t.classList.remove('show'), 3000);
|
|
}
|
|
|
|
// --- SEARCH LOGIC (UNIFIED) ---
|
|
function highlightMatch(text, query) {
|
|
if (!query) return text;
|
|
const regex = new RegExp(`(${query})`, 'gi');
|
|
return text.replace(regex, '<span class="highlight">$1</span>');
|
|
}
|
|
|
|
function renderRichSearchResults(container, data, query, callback) {
|
|
container.innerHTML = '';
|
|
|
|
// Error Handling Display
|
|
if (data.error) {
|
|
container.innerHTML = `<div class="search-item" style="color:red">Error: ${data.error}</div>`;
|
|
container.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
if (!Array.isArray(data) || data.length === 0) {
|
|
container.innerHTML = '<div class="search-item">No se encontraron resultados</div>';
|
|
container.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
// Limit results to 8 for performance
|
|
data.slice(0, 8).forEach(client => {
|
|
const isSuspended = client.isSuspended || false;
|
|
const name = client.clientType === 2 ? client.companyName : `${client.firstName} ${client.lastName}`;
|
|
const address = client.fullAddress || client.street1 || (client.city ? `(${client.city})` : '');
|
|
|
|
let email = client.email || '';
|
|
let phone = '';
|
|
if (client.contacts && Array.isArray(client.contacts) && client.contacts.length > 0) {
|
|
email = client.contacts[0].email || email;
|
|
phone = client.contacts[0].phone || '';
|
|
}
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'search-item';
|
|
item.innerHTML = `
|
|
<div class="status-dot ${isSuspended ? 'suspended' : 'active'}"></div>
|
|
<div class="client-info">
|
|
<div class="client-header">
|
|
<div class="client-header">
|
|
<img src="?action=image&file=client.webp" style="width:24px;height:24px;margin-right:5px;vertical-align:middle;">
|
|
<span class="client-name">${highlightMatch(name, query)}</span>
|
|
${isSuspended ? '<span class="badge-suspended">SUSPENDIDO</span>' : ''}
|
|
</div>
|
|
<div class="client-details">
|
|
ID: ${client.id} · ${email} ${phone ? '· '+phone : ''} <br>
|
|
${address}
|
|
</div>
|
|
</div>
|
|
`;
|
|
item.onclick = () => {
|
|
container.style.display = 'none';
|
|
callback(client);
|
|
};
|
|
container.appendChild(item);
|
|
});
|
|
container.style.display = 'block';
|
|
}
|
|
|
|
function setupSearch(inputId, resultId, action, onSelect) {
|
|
console.log(`[DEBUG] setupSearch initialized for ${inputId}`);
|
|
const input = document.getElementById(inputId);
|
|
const results = document.getElementById(resultId);
|
|
let timeout;
|
|
|
|
if (!input) console.error(`[DEBUG] Input ${inputId} NOT FOUND`);
|
|
if (!results) console.error(`[DEBUG] Results ${resultId} NOT FOUND`);
|
|
|
|
input.oninput = (e) => {
|
|
const q = e.target.value.trim();
|
|
console.log(`[DEBUG] oninput: '${q}'`);
|
|
clearTimeout(timeout);
|
|
if (q.length < 2) {
|
|
results.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
timeout = setTimeout(async () => {
|
|
console.log(`[DEBUG] Fetching ${action} with q=${q}`);
|
|
try {
|
|
const res = await fetch(`?action=${action}&q=${encodeURIComponent(q)}`);
|
|
console.log(`[DEBUG] Response Status: ${res.status}`);
|
|
if (!res.ok) throw new Error(`Server Error: ${res.status}`);
|
|
const data = await res.json();
|
|
console.log(`[DEBUG] Data Received:`, data);
|
|
renderRichSearchResults(results, data, q, (client) => {
|
|
input.value = '';
|
|
onSelect(client);
|
|
});
|
|
} catch (e) {
|
|
console.error('[DEBUG] Fetch Error:', e);
|
|
renderRichSearchResults(results, {
|
|
error: e.message
|
|
}, q, null);
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
|
|
document.addEventListener('click', e => {
|
|
if (!input.contains(e.target) && !results.contains(e.target)) results.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
// 1. NOTIFICATIONS SEARCH
|
|
setupSearch('clientSearch', 'searchResults', 'search_clients', (client) => {
|
|
currentClientId = client.id;
|
|
const name = client.clientType === 2 ? client.companyName : `${client.firstName} ${client.lastName}`;
|
|
document.getElementById('selectedClientName').textContent = `Pagos de ${name}`;
|
|
document.getElementById('btnNotifCrm').href = `${store.publicUrl}/client/${client.id}`;
|
|
document.getElementById('paymentsContainer').style.display = 'block';
|
|
document.getElementById('notificationsPlaceholder').style.display = 'none'; // Hide placeholder
|
|
loadPayments(client.id);
|
|
});
|
|
|
|
async function loadPayments(id) {
|
|
const tbody = document.querySelector('#paymentsTable tbody');
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">Cargando...</td></tr>';
|
|
try {
|
|
const res = await fetch(`?action=get_payments&clientId=${id}`);
|
|
const payments = await res.json();
|
|
if (payments.length === 0) tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">Sin pagos recientes</td></tr>';
|
|
else {
|
|
tbody.innerHTML = payments.map(p => `
|
|
<tr>
|
|
<td>#${p.id}</td>
|
|
<td>${new Date(p.createdDate).toLocaleString()}</td>
|
|
<td>$${p.amount} ${p.currencyCode}</td>
|
|
<td>${p.methodName}</td>
|
|
<td>
|
|
<button class="btn btn-whatsapp" onclick="resendPayment(${p.id})">
|
|
<img src="?action=image&file=whatsapp-logo-button.png" class="icon-btn" style="margin-right:5px;filter: brightness(0) invert(1);"> Re-enviar Notificación
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
} catch (e) {
|
|
tbody.innerHTML = '<tr><td colspan="5">Error</td></tr>';
|
|
}
|
|
}
|
|
|
|
function refreshClientPayments() {
|
|
if (currentClientId) loadPayments(currentClientId);
|
|
else showToast("Busca un cliente primero", true);
|
|
}
|
|
|
|
async function resendPayment(id) {
|
|
if (!confirm('¿Re-enviar notificación?')) return;
|
|
const fd = new FormData();
|
|
fd.append('action', 'resend_payment');
|
|
fd.append('paymentId', id);
|
|
const res = await fetch('?', {
|
|
method: 'POST',
|
|
body: fd
|
|
});
|
|
const d = await res.json();
|
|
showToast(d.message, !d.success);
|
|
}
|
|
|
|
async function loadOxxoHistory(stripeCustomerId) {
|
|
const tbody = document.querySelector('#oxxoHistoryTable tbody');
|
|
if (!tbody) return;
|
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center">Cargando historial...</td></tr>';
|
|
try {
|
|
const res = await fetch(`?action=get_oxxo_history&stripeCustomerId=${stripeCustomerId}`);
|
|
const data = await res.json();
|
|
|
|
if (data.error) {
|
|
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;color:var(--danger)">Error: ${data.error}</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
if (!data.history || data.history.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center">No hay fichas recientes</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.history.map(p => {
|
|
let statusBadge = '';
|
|
switch (p.status) {
|
|
case 'succeeded':
|
|
statusBadge = '<span class="badge badge-success">Pagado</span>';
|
|
break;
|
|
case 'requires_action':
|
|
statusBadge = '<span class="badge badge-warning">Pendiente</span>';
|
|
break;
|
|
case 'canceled':
|
|
statusBadge = '<span class="badge badge-danger">Cancelado</span>';
|
|
break;
|
|
default:
|
|
statusBadge = `<span class="badge badge-secondary">${p.status}</span>`;
|
|
}
|
|
|
|
const date = new Date(p.created * 1000).toLocaleString();
|
|
const voucherBtn = p.voucherUrl ?
|
|
`<a href="${p.voucherUrl}" target="_blank" class="btn btn-uniform" style="padding: 4px 12px; font-size: 0.8rem;">Ver Ficha</a>` :
|
|
'-';
|
|
|
|
return `<tr>
|
|
<td><small style="font-family:monospace">${p.id.slice(-8)}</small></td>
|
|
<td>${date}</td>
|
|
<td>$${p.amount.toFixed(2)} ${p.currency}</td>
|
|
<td style="font-family:monospace">${p.oxxoNumber}</td>
|
|
<td>${statusBadge}</td>
|
|
<td>${voucherBtn}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
if (tbody) tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--danger)">Error de conexión</td></tr>';
|
|
}
|
|
}
|
|
|
|
<?php $isModuleJs = true;
|
|
include __DIR__ . '/views/stripe.php'; ?>
|
|
|
|
<?php $isModuleJs = true;
|
|
include __DIR__ . '/views/oxxo.php'; ?>
|
|
|
|
// --- INPUT VALIDATION & UX ---
|
|
function setupNumericInput(inputId) {
|
|
const input = document.getElementById(inputId);
|
|
if (!input) return;
|
|
|
|
// 1. Focus input when clicking prefix (if exists)
|
|
const group = input.closest('.input-group');
|
|
if (group) {
|
|
const prefix = group.querySelector('.input-prefix');
|
|
if (prefix) {
|
|
prefix.onclick = () => input.focus();
|
|
prefix.style.cursor = 'text';
|
|
}
|
|
}
|
|
|
|
// 2. Strict Numeric Validation (Robust)
|
|
input.addEventListener('keydown', (e) => {
|
|
// Allow control keys: Backspace, Delete, Tab, Escape, Enter, Arrows, Home, End
|
|
const allowedKeys = ['Backspace', 'Delete', 'Tab', 'Escape', 'Enter', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
|
|
if (allowedKeys.includes(e.key) || e.ctrlKey || e.metaKey || e.altKey) {
|
|
return;
|
|
}
|
|
|
|
// Block invalid chars for number input: e, E, +, -
|
|
// Note: We allow '.' if not already present
|
|
if (['e', 'E', '+', '-'].includes(e.key)) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
// Allow numbers and dot
|
|
const isDigit = /^[0-9]$/.test(e.key);
|
|
const isDot = e.key === '.';
|
|
|
|
if (!isDigit && !isDot) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
if (isDot && input.value.includes('.')) {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
// 3. Fallback Sanitization (on input)
|
|
input.addEventListener('input', (e) => {
|
|
let val = input.value;
|
|
// If browser allowed something invalid, clean it
|
|
// Keep only numbers and dots
|
|
// Also ensure only one dot
|
|
const clean = val.replace(/[^0-9.]/g, '');
|
|
|
|
// Handle multiple dots
|
|
const parts = clean.split('.');
|
|
if (parts.length > 2) {
|
|
input.value = parts[0] + '.' + parts.slice(1).join('');
|
|
} else if (val !== clean) {
|
|
input.value = clean;
|
|
}
|
|
});
|
|
|
|
// Prevent paste of invalid text
|
|
input.addEventListener('paste', (e) => {
|
|
const paste = (e.clipboardData || window.clipboardData).getData('text');
|
|
if (!/^\d*\.?\d*$/.test(paste)) {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
// Prevent scrolling changing value
|
|
input.addEventListener('wheel', (e) => e.preventDefault());
|
|
}
|
|
|
|
// Initialize Validation for Amounts
|
|
setupNumericInput('oxxoAmount');
|
|
setupNumericInput('stripeAmount');
|
|
|
|
// --- INSTALLER MODAL LOGIC ---
|
|
function renderTable() {
|
|
const tbody = document.querySelector('#installersTable tbody');
|
|
tbody.innerHTML = store.installers.map((inst, i) => `<tr>
|
|
<td>#${inst.id}</td>
|
|
<td>${inst.nombre}</td>
|
|
<td>${inst.whatsapp}</td>
|
|
<td>
|
|
<span style="cursor:pointer; font-size:1.3em; margin-right:8px;" onclick="loadInstallerJobs('${inst.id}', '${inst.nombre}')" title="Ver tareas activas">📋</span>
|
|
<img src="?action=image&file=edit.webp" class="icon-action" onclick="editInstaller(${i})" title="Editar">
|
|
<img src="?action=image&file=delete.webp" class="icon-action" style="margin-left:10px" onclick="deleteInstaller(${i})" title="Borrar">
|
|
</td>
|
|
</tr>`).join('');
|
|
}
|
|
renderTable();
|
|
|
|
|
|
window.editInstaller = function(index) {
|
|
const installer = store.installers[index];
|
|
if (!installer) return;
|
|
|
|
document.getElementById('editIndex').value = index;
|
|
document.getElementById('installerId').value = installer.id;
|
|
document.getElementById('installerName').value = installer.nombre;
|
|
document.getElementById('installerWhatsApp').value = installer.whatsapp;
|
|
|
|
document.getElementById('modalOverlay').style.display = 'flex';
|
|
}
|
|
|
|
window.deleteInstaller = async function(index) {
|
|
if (!confirm('¿Estás seguro de que deseas eliminar este instalador?')) return;
|
|
|
|
store.installers.splice(index, 1);
|
|
renderTable();
|
|
|
|
const fd = new FormData();
|
|
fd.append('action', 'save_installers');
|
|
fd.append('installers_data', JSON.stringify({
|
|
instaladores: store.installers
|
|
}));
|
|
|
|
try {
|
|
await fetch('?action=save_installers', {
|
|
method: 'POST',
|
|
body: fd
|
|
});
|
|
showToast('Instalador eliminado correctamente');
|
|
} catch (e) {
|
|
console.error(e);
|
|
showToast('Error al eliminar', true);
|
|
}
|
|
}
|
|
|
|
// --- JOBS ACTIVOS DEL INSTALADOR ---
|
|
async function loadInstallerJobs(installerId, installerName) {
|
|
const title = document.getElementById('jobsSectionTitle');
|
|
const emptyState = document.getElementById('jobsEmptyState');
|
|
const loading = document.getElementById('jobsLoading');
|
|
const tableWrapper = document.getElementById('jobsTableWrapper');
|
|
const noResults = document.getElementById('jobsNoResults');
|
|
|
|
title.innerHTML = `📋 Tareas Activas: <strong>${installerName}</strong>`;
|
|
emptyState.style.display = 'none';
|
|
loading.style.display = 'block';
|
|
tableWrapper.style.display = 'none';
|
|
noResults.style.display = 'none';
|
|
|
|
// Scroll suave a la sección de jobs
|
|
title.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'start'
|
|
});
|
|
|
|
try {
|
|
const resp = await fetch(`?action=get_installer_jobs&installerId=${installerId}`);
|
|
const jobs = await resp.json();
|
|
loading.style.display = 'none';
|
|
|
|
if (!jobs.length) {
|
|
noResults.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
const tbody = document.querySelector('#installerJobsTable tbody');
|
|
tbody.innerHTML = jobs.map(job => {
|
|
const dateFormatted = job.date ? new Date(job.date).toLocaleDateString('es-MX', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric'
|
|
}) : 'S/F';
|
|
const titleClean = job.title.replace('[NOTIFICACION-PENDIENTE]', '').replace('[CLIENTE-SIN-WHATSAPP]', '').trim();
|
|
return `<tr>
|
|
<td><strong>#${job.id}</strong></td>
|
|
<td>[${job.clientId}] ${job.clientName}</td>
|
|
<td>${dateFormatted}</td>
|
|
<td style="max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${job.description}">${titleClean || job.description || 'Sin descripción'}</td>
|
|
<td>
|
|
<button class="btn btn-primary" style="padding:6px 14px; font-size:0.85em;" onclick="resendJobNotification(${job.id}, this)">
|
|
📨 Reenviar
|
|
</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
tableWrapper.style.display = 'block';
|
|
} catch (e) {
|
|
loading.style.display = 'none';
|
|
noResults.style.display = 'block';
|
|
console.error('Error loading installer jobs:', e);
|
|
}
|
|
}
|
|
|
|
async function resendJobNotification(jobId, btn) {
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = '⏳ Enviando...';
|
|
btn.disabled = true;
|
|
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('action', 'resend_job_notification');
|
|
fd.append('jobId', jobId);
|
|
|
|
const resp = await fetch('', {
|
|
method: 'POST',
|
|
body: fd
|
|
});
|
|
const result = await resp.json();
|
|
|
|
if (result.success) {
|
|
btn.innerHTML = '✅ Enviado';
|
|
btn.style.backgroundColor = '#22c55e';
|
|
showToast('Notificación reenviada para tarea #' + jobId);
|
|
setTimeout(() => {
|
|
btn.innerHTML = originalText;
|
|
btn.style.backgroundColor = '';
|
|
btn.disabled = false;
|
|
}, 3000);
|
|
} else {
|
|
throw new Error(result.message || 'Error desconocido');
|
|
}
|
|
} catch (e) {
|
|
btn.innerHTML = '❌ Error';
|
|
btn.style.backgroundColor = '#ef4444';
|
|
showToast('Error: ' + e.message, true);
|
|
setTimeout(() => {
|
|
btn.innerHTML = originalText;
|
|
btn.style.backgroundColor = '';
|
|
btn.disabled = false;
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
function openInstallerModal() {
|
|
document.getElementById('modalOverlay').style.display = 'flex';
|
|
document.getElementById('installerForm').reset();
|
|
document.getElementById('editIndex').value = '';
|
|
}
|
|
|
|
function fillInstallerData(sel) {
|
|
document.getElementById('installerId').value = sel.value;
|
|
document.getElementById('installerName').value = sel.options[sel.selectedIndex].getAttribute('data-name');
|
|
}
|
|
document.getElementById('installerForm').onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const id = document.getElementById('installerId').value;
|
|
const name = document.getElementById('installerName').value;
|
|
const wa = document.getElementById('installerWhatsApp').value;
|
|
const idx = document.getElementById('editIndex').value;
|
|
if (idx !== '') store.installers[idx] = {
|
|
id,
|
|
nombre: name,
|
|
whatsapp: wa
|
|
};
|
|
else store.installers.push({
|
|
id,
|
|
nombre: name,
|
|
whatsapp: wa
|
|
});
|
|
|
|
const fd = new FormData();
|
|
fd.append('action', 'save_installers');
|
|
fd.append('installers_data', JSON.stringify({
|
|
instaladores: store.installers
|
|
}));
|
|
await fetch('?action=save_installers', {
|
|
method: 'POST',
|
|
body: fd
|
|
});
|
|
renderTable();
|
|
document.getElementById('modalOverlay').style.display = 'none';
|
|
};
|
|
|
|
async function loadStripeHistory(customerId) {
|
|
const container = document.getElementById('stripeHistoryContainer');
|
|
const tbody = document.querySelector('#stripeHistoryTable tbody');
|
|
const balanceBadge = document.getElementById('stripeCashBalanceBadge');
|
|
|
|
container.style.display = 'block';
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; padding: 20px; color: var(--text-muted);">Cargando historial...</td></tr>';
|
|
balanceBadge.style.display = 'none';
|
|
|
|
try {
|
|
const response = await fetch(`?action=get_stripe_history&stripeCustomerId=${customerId}`);
|
|
const dataRaw = await response.json();
|
|
|
|
// Compatibility handling (array vs object)
|
|
const data = Array.isArray(dataRaw) ? dataRaw : (dataRaw.history || []);
|
|
const cashBalance = (dataRaw.cashBalance !== undefined && dataRaw.cashBalance !== null) ? parseFloat(dataRaw.cashBalance) : null;
|
|
|
|
// Display Cash Balance
|
|
if (cashBalance !== null) {
|
|
balanceBadge.style.display = 'inline-flex';
|
|
const balanceText = document.getElementById('stripeCashBalanceText');
|
|
if (balanceText) {
|
|
balanceText.textContent = 'Saldo Stripe: $' + cashBalance.toFixed(2) + ' MXN';
|
|
}
|
|
|
|
if (cashBalance > 0) {
|
|
balanceBadge.style.backgroundColor = '#dcfce7'; // Green
|
|
balanceBadge.style.color = '#15803d';
|
|
balanceBadge.style.border = '1px solid #bbf7d0';
|
|
} else {
|
|
balanceBadge.style.backgroundColor = '';
|
|
balanceBadge.style.color = '';
|
|
balanceBadge.style.border = '';
|
|
}
|
|
}
|
|
|
|
if (dataRaw.error) {
|
|
tbody.innerHTML = `<tr><td colspan="5" style="color:var(--danger); text-align:center; padding: 10px;">Error: ${dataRaw.error}</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
if (data.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; padding: 20px; color: var(--text-muted);">No hay pagos recientes.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.map(payment => {
|
|
const date = new Date(payment.created * 1000).toLocaleString();
|
|
const statusBadge = getStatusBadge(payment.status);
|
|
return `
|
|
<tr style="border-bottom: 1px solid var(--border);">
|
|
<td style="padding: 10px;">${statusBadge}</td>
|
|
<td style="padding: 10px; font-weight: 500;">$${payment.amount.toFixed(2)} ${payment.currency}</td>
|
|
<td style="padding: 10px; color: var(--text-muted);">${payment.description}</td>
|
|
<td style="padding: 10px; color: var(--text-muted); font-size: 0.9em;">${date}</td>
|
|
<td style="padding: 10px;"><small style="color: var(--text-muted); font-family: monospace;">${payment.id}</small></td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
} catch (e) {
|
|
tbody.innerHTML = `<tr><td colspan="5" style="color:var(--danger); text-align:center; padding: 10px;">Error de conexión</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function getStatusBadge(status) {
|
|
let color = 'var(--text-muted)';
|
|
let bg = '#f1f5f9';
|
|
let label = status;
|
|
|
|
if (status === 'succeeded') {
|
|
color = '#15803d';
|
|
bg = '#dcfce7';
|
|
label = 'Exitoso';
|
|
} else if (status === 'requires_payment_method' || status === 'requires_action') {
|
|
color = '#b45309';
|
|
bg = '#fef3c7';
|
|
label = 'Pendiente';
|
|
} else if (status === 'canceled') {
|
|
color = '#b91c1c';
|
|
bg = '#fee2e2';
|
|
label = 'Cancelado';
|
|
}
|
|
|
|
return `<span style="background-color: ${bg}; color: ${color}; padding: 4px 10px; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; text-transform: capitalize;">${label}</span>`;
|
|
}
|
|
</script>
|
|
<script>
|
|
// ━━━ Login Logic ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
(function() {
|
|
const ALLOWED_ROLES = ['admin', 'superadmin'];
|
|
let _twoFactorData = null;
|
|
|
|
// Generar partículas decorativas
|
|
const particlesEl = document.getElementById('loginParticles');
|
|
if (particlesEl) {
|
|
for (let i = 0; i < 20; i++) {
|
|
const s = document.createElement('span');
|
|
s.style.left = Math.random() * 100 + '%';
|
|
s.style.top = (60 + Math.random() * 40) + '%';
|
|
s.style.animationDelay = (Math.random() * 12) + 's';
|
|
s.style.animationDuration = (8 + Math.random() * 8) + 's';
|
|
particlesEl.appendChild(s);
|
|
}
|
|
}
|
|
|
|
function showLoginError(msg) {
|
|
const el = document.getElementById('loginError');
|
|
el.textContent = msg;
|
|
el.classList.add('visible');
|
|
}
|
|
|
|
function hideLoginError() {
|
|
document.getElementById('loginError').classList.remove('visible');
|
|
}
|
|
|
|
function setLoginLoading(loading) {
|
|
const btn = document.getElementById('loginBtn');
|
|
const totpBtn = document.getElementById('loginTotpBtn');
|
|
if (loading) {
|
|
btn.disabled = true;
|
|
totpBtn.disabled = true;
|
|
btn.innerHTML = '<span class="login-spinner"></span>Autenticando...';
|
|
totpBtn.innerHTML = '<span class="login-spinner"></span>Verificando...';
|
|
} else {
|
|
btn.disabled = false;
|
|
totpBtn.disabled = false;
|
|
btn.textContent = 'Iniciar Sesión';
|
|
totpBtn.textContent = 'Verificar Código';
|
|
}
|
|
}
|
|
|
|
function onLoginSuccess(token, user) {
|
|
sessionStorage.setItem('nms_auth_token', token);
|
|
sessionStorage.setItem('nms_user', JSON.stringify(user));
|
|
// Actualizar SYSTEM_USER_ID global
|
|
SYSTEM_USER_ID = user.id || user.userId || null;
|
|
console.log('Login OK — User:', user.username, 'Role:', user.role, 'ID:', SYSTEM_USER_ID);
|
|
|
|
// Ocultar overlay con animación
|
|
const overlay = document.getElementById('loginOverlay');
|
|
overlay.classList.add('hidden');
|
|
setTimeout(() => {
|
|
overlay.style.display = 'none';
|
|
}, 500);
|
|
}
|
|
|
|
window.handleLogin = async function() {
|
|
hideLoginError();
|
|
const username = document.getElementById('loginUsername').value.trim();
|
|
const password = document.getElementById('loginPassword').value;
|
|
|
|
if (!username || !password) {
|
|
showLoginError('Ingresa usuario y contraseña.');
|
|
return;
|
|
}
|
|
|
|
setLoginLoading(true);
|
|
try {
|
|
const resp = await fetch('?action=nms_login', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
username,
|
|
password
|
|
}),
|
|
});
|
|
|
|
const data = await resp.json();
|
|
|
|
if (data.success && data.token) {
|
|
// Verificar rol
|
|
const role = data.user?.role || '';
|
|
if (!ALLOWED_ROLES.includes(role)) {
|
|
showLoginError(`Acceso denegado. Tu rol (${role}) no tiene permisos para este portal.`);
|
|
setLoginLoading(false);
|
|
return;
|
|
}
|
|
onLoginSuccess(data.token, data.user);
|
|
} else if (data.requires2FA) {
|
|
_twoFactorData = data.twoFactorToken;
|
|
document.getElementById('loginFormSection').style.display = 'none';
|
|
document.getElementById('loginTotpSection').classList.add('visible');
|
|
document.getElementById('loginTotpCode').focus();
|
|
setLoginLoading(false);
|
|
} else {
|
|
showLoginError(data.error || 'Credenciales inválidas.');
|
|
setLoginLoading(false);
|
|
}
|
|
} catch (e) {
|
|
showLoginError('Error de conexión. Intenta de nuevo.');
|
|
setLoginLoading(false);
|
|
}
|
|
};
|
|
|
|
window.handleTotpLogin = async function() {
|
|
hideLoginError();
|
|
const code = document.getElementById('loginTotpCode').value.trim();
|
|
if (!code || code.length < 6) {
|
|
showLoginError('Ingresa el código de 6 dígitos.');
|
|
return;
|
|
}
|
|
|
|
setLoginLoading(true);
|
|
try {
|
|
const payload = {
|
|
..._twoFactorData,
|
|
totpCode: code
|
|
};
|
|
const resp = await fetch('?action=nms_login_totp', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
const data = await resp.json();
|
|
if (data.success && data.token) {
|
|
const role = data.user?.role || '';
|
|
if (!ALLOWED_ROLES.includes(role)) {
|
|
showLoginError(`Acceso denegado. Tu rol (${role}) no tiene permisos.`);
|
|
setLoginLoading(false);
|
|
return;
|
|
}
|
|
onLoginSuccess(data.token, data.user);
|
|
} else {
|
|
showLoginError(data.error || 'Código TOTP inválido.');
|
|
setLoginLoading(false);
|
|
}
|
|
} catch (e) {
|
|
showLoginError('Error de conexión. Intenta de nuevo.');
|
|
setLoginLoading(false);
|
|
}
|
|
};
|
|
|
|
// Verificar sesión almacenada al cargar
|
|
async function verifyStoredSession() {
|
|
const token = sessionStorage.getItem('nms_auth_token');
|
|
if (!token) return false;
|
|
|
|
try {
|
|
const resp = await fetch('?action=nms_verify_session', {
|
|
headers: {
|
|
'x-auth-token': token
|
|
},
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success && data.user) {
|
|
const role = data.user?.role || '';
|
|
if (ALLOWED_ROLES.includes(role)) {
|
|
onLoginSuccess(token, data.user);
|
|
return true;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
/* token inválido, mostrar login */
|
|
}
|
|
|
|
sessionStorage.removeItem('nms_auth_token');
|
|
sessionStorage.removeItem('nms_user');
|
|
return false;
|
|
}
|
|
|
|
// Enter key handlers
|
|
document.getElementById('loginPassword')?.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') handleLogin();
|
|
});
|
|
document.getElementById('loginUsername')?.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') document.getElementById('loginPassword').focus();
|
|
});
|
|
document.getElementById('loginTotpCode')?.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') handleTotpLogin();
|
|
});
|
|
|
|
// Inicialización: decidir si mostrar login o portal
|
|
sessionStorage.removeItem('nms_force_local_login'); // Cleanup from previous versions
|
|
|
|
// Ocultar botón de Cerrar Sesión si estamos dentro del iframe del CRM
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
if (window.top !== window.self) {
|
|
const logoutBtn = document.getElementById('logoutBtn');
|
|
if (logoutBtn) logoutBtn.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
if (NEEDS_LOGIN) {
|
|
// No hay sesión UCRM, intentar con token almacenado
|
|
verifyStoredSession().then(valid => {
|
|
if (!valid) {
|
|
document.getElementById('loginOverlay').style.display = 'flex';
|
|
}
|
|
});
|
|
} else {
|
|
// Sesión UCRM activa, ocultar login overlay
|
|
const overlay = document.getElementById('loginOverlay');
|
|
overlay.classList.add('hidden');
|
|
overlay.style.display = 'none';
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|