Nuevas Características: • Visualizador de pagos mensuales con gráfica de dona (Chart.js) • Tarjetas estadísticas: clientes activos, pagados y pendientes • Tabla de clientes pendientes con saldos en tiempo real • Microservicio Node.js para metadata de Stripe (acceso directo a BD) Mejoras: • Fix crítico: Sincronización automática de saldo en CallBell al agregar facturas • Categorización mejorada de pagos OXXO y Transferencias Stripe • Normalización de valores: "OXXO" → "OXXO Pay" para evitar errores 422 • Configuración .env para credenciales de base de datos Correcciones: • Saldo y estado ahora se actualizan correctamente en CallBell • Fix networking Docker (ECONNREFUSED resuelto) • Fix validación de atributos en API de UCRM • Actualización automática de userId en pagos Stripe Archivos principales: public.php (visualizador de pagos) AbstractMessageNotifierFacade.php (logging sync) ClientCallBellAPI.php (comparación de campos) AbstractStripeOperationsFacade.php (normalización) manifest.json, README.md, CHANGELOG.md (docs)
1869 lines
83 KiB
PHP
Executable File
1869 lines
83 KiB
PHP
Executable File
<?php
|
|
|
|
require_once __DIR__ . '/vendor/autoload.php';
|
|
|
|
chdir(__DIR__);
|
|
|
|
use Ubnt\UcrmPluginSdk\Service\PluginLogManager;
|
|
use Ubnt\UcrmPluginSdk\Service\PluginConfigManager;
|
|
use Ubnt\UcrmPluginSdk\Service\UcrmApi;
|
|
use SmsNotifier\Service\PaymentIntentService;
|
|
|
|
// Carga manual del servicio para asegurar compatibilidad si el autoloader falla
|
|
require_once __DIR__ . '/src/Service/PaymentIntentService.php';
|
|
|
|
// Solo permitir acceso si estamos en un entorno UCRM válido
|
|
if (!file_exists(__DIR__ . '/data/config.json')) {
|
|
die('Acceso denegado o configuración no encontrada.');
|
|
}
|
|
|
|
$configManager = PluginConfigManager::create();
|
|
$config = $configManager->loadConfig();
|
|
$logger = new \SmsNotifier\Service\Logger();
|
|
|
|
// LOG DE EMERGENCIA
|
|
$debugLogPath = __DIR__ . '/data/debug_public.log';
|
|
file_put_contents($debugLogPath, "[" . date('Y-m-d H:i:s') . "] Hit public.php - Method: " . $_SERVER['REQUEST_METHOD'] . PHP_EOL, FILE_APPEND);
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$input = file_get_contents('php://input');
|
|
$jsonData = json_decode((string)$input, true);
|
|
|
|
// Si detectamos que es un webhook de UCRM o una solicitud externa (Stripe, OXXO, etc.)
|
|
// Stripe usa 'type', UCRM usa 'uuid'/'eventName'
|
|
if ($jsonData && (isset($jsonData['uuid']) || isset($jsonData['eventName']) || isset($jsonData['type']))) {
|
|
file_put_contents($debugLogPath, "[" . date('Y-m-d H:i:s') . "] Webhook detectado en public.php (Type: " . ($jsonData['type'] ?? 'UCRM') . "). Delegando a Plugin->run()" . PHP_EOL, FILE_APPEND);
|
|
|
|
$builder = new \DI\ContainerBuilder();
|
|
$container = $builder->build();
|
|
$plugin = $container->get(\SmsNotifier\Plugin::class);
|
|
$plugin->run();
|
|
exit;
|
|
}
|
|
|
|
$logger->debug('public.php POST input: ' . substr((string)$input, 0, 100) . '...');
|
|
}
|
|
$ucrmApi = UcrmApi::create();
|
|
|
|
// Inicializar servicio de Stripe
|
|
$ipServer = $config['ipserver'] ?? '';
|
|
$ucrmApiUrl = 'https://' . $ipServer . '/crm';
|
|
$stripeService = new PaymentIntentService(
|
|
$ucrmApiUrl,
|
|
$config['apitoken'] ?? '',
|
|
$config['tokenstripe'] ?? '',
|
|
$logger
|
|
);
|
|
|
|
// Obtener administradores de UCRM para el selector
|
|
$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
|
|
];
|
|
|
|
// Identificar al usuario "stripe" para el modo automático
|
|
if (strtolower($nombre) === 'stripe' || strtolower($admin['username'] ?? '') === 'stripe') {
|
|
$defaultStripeAdminId = $admin['id'];
|
|
}
|
|
}
|
|
|
|
// Si no se encontró el usuario "stripe", usar el primero de la lista como fallback
|
|
if ($defaultStripeAdminId === null && !empty($admins)) {
|
|
$defaultStripeAdminId = $admins[0]['id'];
|
|
}
|
|
} catch (\Exception $e) {
|
|
$logger->error('Error al obtener administradores de UCRM: ' . $e->getMessage());
|
|
}
|
|
|
|
// Manejar actualizaciones del JSON de instaladores
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
|
if ($_POST['action'] === 'save_installers') {
|
|
$installersJson = $_POST['installers_data'] ?? '';
|
|
|
|
// Validar que sea un JSON válido
|
|
if (json_decode($installersJson) !== null) {
|
|
$configPath = __DIR__ . '/data/config.json';
|
|
$currentConfig = json_decode(file_get_contents($configPath), true);
|
|
$currentConfig['installersDataWhatsApp'] = $installersJson;
|
|
|
|
if (file_put_contents($configPath, json_encode($currentConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['success' => true]);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['success' => false, 'message' => 'Error al guardar los datos.']);
|
|
exit;
|
|
}
|
|
|
|
if ($_POST['action'] === 'resend_payment') {
|
|
$paymentId = $_POST['paymentId'] ?? null;
|
|
if (!$paymentId) {
|
|
echo json_encode(['success' => false, 'message' => 'Falta el ID del pago.']);
|
|
exit;
|
|
}
|
|
|
|
try {
|
|
// Obtener datos del pago
|
|
$payment = $ucrmApi->get("payments/$paymentId");
|
|
// Obtener datos del cliente
|
|
$client = $ucrmApi->get("clients/{$payment['clientId']}");
|
|
|
|
// Simular PayLoad de Webhook
|
|
$payload = [
|
|
'uuid' => 'manual-trigger',
|
|
'changeType' => 'insert',
|
|
'entity' => 'payment',
|
|
'entityId' => (int)$paymentId,
|
|
'eventName' => 'payment.add',
|
|
'clientData' => $client,
|
|
'paymentData' => $payment
|
|
];
|
|
|
|
// Realizar una petición interna a este mismo script para disparar el flujo de notificación
|
|
// Usamos curl local o simplemente instanciamos el plugin si lo preparamos
|
|
$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_TIMEOUT, 5);
|
|
$res = curl_exec($ch);
|
|
curl_close($ch);
|
|
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['success' => true, 'message' => 'Notificación disparada correctamente.']);
|
|
exit;
|
|
|
|
} catch (\Exception $e) {
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
|
exit;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Búsqueda de clientes (GET)
|
|
if (isset($_GET['action']) && $_GET['action'] === 'search_clients') {
|
|
$q = $_GET['q'] ?? '';
|
|
try {
|
|
$clients = $ucrmApi->get('clients', ['query' => $q, 'limit' => 10]);
|
|
header('Content-Type: application/json');
|
|
echo json_encode($clients);
|
|
} catch (\Exception $e) {
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// Obtener pagos de un cliente (GET)
|
|
if (isset($_GET['action']) && $_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'];
|
|
}
|
|
|
|
$formattedPayments = array_slice($payments, 0, 10);
|
|
foreach ($formattedPayments as &$p) {
|
|
$p['methodName'] = $methodMap[$p['methodId']] ?? 'N/A';
|
|
}
|
|
|
|
header('Content-Type: application/json');
|
|
echo json_encode($formattedPayments); // Top 10
|
|
} catch (\Exception $e) {
|
|
echo json_encode(['error' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// Búsqueda de clientes para Stripe (AJAX)
|
|
if (isset($_GET['action']) && $_GET['action'] === 'search_stripe') {
|
|
$q = $_GET['q'] ?? '';
|
|
header('Content-Type: application/json');
|
|
echo json_encode($stripeService->searchClients($q));
|
|
exit;
|
|
}
|
|
|
|
// Detalles de cliente para Stripe (AJAX)
|
|
if (isset($_GET['action']) && $_GET['action'] === 'get_stripe_details') {
|
|
$clientId = $_GET['id'] ?? null;
|
|
header('Content-Type: application/json');
|
|
echo json_encode($stripeService->getClientDetails($clientId));
|
|
exit;
|
|
}
|
|
|
|
// Crear intención de pago Stripe (POST AJAX)
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_intent') {
|
|
$clientId = $_POST['clientId'] ?? null;
|
|
$amount = $_POST['amount'] ?? 0;
|
|
$stripeCustomerId = $_POST['stripeCustomerId'] ?? null;
|
|
$adminId = $_POST['adminId'] ?? null;
|
|
|
|
try {
|
|
$result = $stripeService->createPaymentIntent($clientId, $amount, $stripeCustomerId, $adminId);
|
|
header('Content-Type: application/json');
|
|
echo json_encode($result);
|
|
} catch (\Exception $e) {
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// Crear intención de pago OXXO (POST AJAX)
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'create_oxxo_intent') {
|
|
$clientId = $_POST['clientId'] ?? null;
|
|
$amount = $_POST['amount'] ?? 0;
|
|
try {
|
|
$builder = new \DI\ContainerBuilder();
|
|
$container = $builder->build();
|
|
$oxxoService = $container->get(\SmsNotifier\Facade\PluginOxxoNotifierFacade::class);
|
|
// Aumentar tiempo de ejecución y limpiar buffer de salida
|
|
set_time_limit(300);
|
|
if (ob_get_length()) ob_clean();
|
|
|
|
$eventData = ['client_id' => $clientId];
|
|
$result = $oxxoService->createOxxoPaymentIntent($eventData, $amount, false); // false = No subir FTP para UI más rápida
|
|
|
|
// Verificar si hay output previo y limpiar de nuevo
|
|
if (ob_get_length()) ob_clean();
|
|
|
|
// Retornar el resultado directamente para que coincida con el formato de Plugin.php si es necesario,
|
|
// pero para el dashboard mantenemos el success wrapper para consistencia JS.
|
|
header('Content-Type: application/json');
|
|
$jsonResponse = json_encode(['success' => true, 'data' => $result]);
|
|
|
|
if ($jsonResponse === false) {
|
|
echo json_encode(['success' => false, 'error' => 'JSON Error: ' . json_last_error_msg()]);
|
|
} else {
|
|
echo $jsonResponse;
|
|
}
|
|
} catch (\Exception $e) {
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// Cargar imágenes (GET)
|
|
if (isset($_GET['action']) && $_GET['action'] === 'image') {
|
|
$filename = basename($_GET['file'] ?? '');
|
|
$path = __DIR__ . '/img/' . $filename;
|
|
$voucherPath = __DIR__ . '/vouchers_oxxo/' . $filename;
|
|
|
|
$finalPath = null;
|
|
if (file_exists($path)) {
|
|
$finalPath = $path;
|
|
} elseif (file_exists($voucherPath)) {
|
|
$finalPath = $voucherPath;
|
|
}
|
|
|
|
// DEBUG: Log image request
|
|
$debugLog = __DIR__ . '/data/plugin.log';
|
|
$logMsg = "[PROPAGATED_DEBUG] Image Request: File=$filename | PathTry=$path | VoucherPathTry=$voucherPath | FinalPath=" . ($finalPath ?? 'NULL') . " | Exists=" . (file_exists($voucherPath) ? 'YES' : 'NO') . PHP_EOL;
|
|
file_put_contents($debugLog, $logMsg, FILE_APPEND);
|
|
|
|
if ($finalPath) {
|
|
// Limpiar cualquier output previo (espacios, warnings, etc) para evitar corromper la imagen
|
|
if (ob_get_level()) ob_end_clean();
|
|
|
|
$size = filesize($finalPath);
|
|
file_put_contents($debugLog, "[DEBUG_IMG] Serving $finalPath. Size: $size bytes" . PHP_EOL, FILE_APPEND);
|
|
|
|
header("Content-Type: image/jpeg");
|
|
header("Content-Length: " . $size);
|
|
readfile($finalPath);
|
|
exit;
|
|
} else {
|
|
http_response_code(404);
|
|
echo "Image not found";
|
|
}
|
|
exit;
|
|
}
|
|
|
|
// Cargar instaladores actuales
|
|
$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 - Dashboard Admin</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<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;
|
|
--sidebar-width: 300px;
|
|
--danger: #ef4444;
|
|
--success: #22c55e;
|
|
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
--primary-surface: rgba(37, 99, 235, 0.08);
|
|
}
|
|
|
|
[data-theme="dark"] {
|
|
--primary: #60a5fa;
|
|
--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);
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
font-family: 'Outfit', sans-serif;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg-body);
|
|
color: var(--text-main);
|
|
transition: var(--transition);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.sidebar {
|
|
width: var(--sidebar-width);
|
|
background: var(--bg-card);
|
|
border-right: 1px solid var(--border);
|
|
padding: 2rem 1.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: fixed;
|
|
height: 100vh;
|
|
z-index: 100;
|
|
}
|
|
|
|
.logo {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
margin-bottom: 3rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.nav-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
padding: 14px 20px;
|
|
border-radius: 12px;
|
|
color: var(--text-muted);
|
|
text-decoration: none;
|
|
margin-bottom: 10px;
|
|
transition: var(--transition);
|
|
font-weight: 600;
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
.nav-link.active, .nav-link:hover {
|
|
background: var(--primary-surface);
|
|
color: var(--primary);
|
|
}
|
|
|
|
/* Main Content */
|
|
.main-content {
|
|
margin-left: var(--sidebar-width);
|
|
flex: 1;
|
|
padding: 0; /* Changed to 0 to allow full-width header */
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.global-header {
|
|
background: linear-gradient(90deg, #1e3a8a 0%, #2563eb 100%);
|
|
border-bottom: 4px solid rgba(255, 255, 255, 0.2);
|
|
color: #ffffff;
|
|
padding: 1.8rem 2rem;
|
|
text-align: center;
|
|
margin-bottom: 2.5rem;
|
|
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.2);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.global-header h2 {
|
|
font-size: 1.15rem;
|
|
font-weight: 700;
|
|
letter-spacing: 1.5px;
|
|
text-transform: uppercase;
|
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.main-container {
|
|
padding: 0 3rem 2rem 3rem;
|
|
flex: 1;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 2.5rem;
|
|
}
|
|
|
|
.title-section h1 {
|
|
font-size: 1.875rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.title-section p {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Actions */
|
|
.actions {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.btn {
|
|
padding: 10px 20px;
|
|
border-radius: 10px;
|
|
border: none;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: var(--transition);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: var(--primary-hover);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: var(--bg-card);
|
|
color: var(--text-main);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: rgba(37, 99, 235, 0.05);
|
|
border-color: var(--primary);
|
|
color: var(--primary);
|
|
}
|
|
|
|
.btn-whatsapp {
|
|
background-color: #25D366;
|
|
color: white !important;
|
|
}
|
|
|
|
.btn-whatsapp:hover {
|
|
background-color: #128C7E;
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 15px -3px rgba(37, 211, 102, 0.2);
|
|
}
|
|
|
|
.theme-toggle {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-main);
|
|
width: 44px;
|
|
height: 44px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.theme-toggle svg {
|
|
position: absolute;
|
|
transition: transform 0.4s ease;
|
|
}
|
|
|
|
[data-theme="light"] .theme-toggle .moon-icon { transform: translateY(40px); }
|
|
[data-theme="dark"] .theme-toggle .sun-icon { transform: translateY(-40px); }
|
|
|
|
/* Card / Table */
|
|
.card {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 20px;
|
|
padding: 1.5rem;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.table-container {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
th {
|
|
text-align: left;
|
|
padding: 1rem;
|
|
color: var(--text-muted);
|
|
font-weight: 600;
|
|
border-bottom: 2px solid var(--border);
|
|
}
|
|
|
|
td {
|
|
padding: 1.25rem 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.badge {
|
|
padding: 4px 12px;
|
|
border-radius: 100px;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
background: var(--primary-surface);
|
|
color: var(--primary);
|
|
}
|
|
|
|
.action-btns {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.action-btn {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 8px;
|
|
border: none;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.edit-btn { background: rgba(34, 197, 94, 0.1); color: var(--success); }
|
|
.delete-btn { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
|
|
|
|
.edit-btn:hover { background: var(--success); color: white; }
|
|
.delete-btn:hover { background: var(--danger); color: white; }
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
backdrop-filter: blur(4px);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.modal {
|
|
background: var(--bg-card);
|
|
width: 90%;
|
|
max-width: 480px;
|
|
padding: 2rem;
|
|
border-radius: 24px;
|
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
font-weight: 600;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.form-control {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
border-radius: 10px;
|
|
border: 1px solid var(--border);
|
|
background: var(--bg-body);
|
|
color: var(--text-main);
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.form-control:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.modal-footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 12px;
|
|
margin-top: 2rem;
|
|
}
|
|
|
|
/* Toast */
|
|
#toast {
|
|
position: fixed;
|
|
bottom: 2rem;
|
|
left: 50%;
|
|
transform: translate(-50%, 150%);
|
|
padding: 1rem 2rem;
|
|
border-radius: 12px;
|
|
background: var(--text-main);
|
|
color: var(--bg-body);
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
|
|
transition: var(--transition);
|
|
z-index: 2000;
|
|
}
|
|
|
|
#toast.show { transform: translate(-50%, 0); }
|
|
|
|
/* Search Results in Notifications */
|
|
.search-results {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
margin-top: 5px;
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
|
z-index: 2000; /* Always above header */
|
|
display: none;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.search-item {
|
|
padding: 12px 16px;
|
|
cursor: pointer;
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.search-item:hover {
|
|
background: rgba(37, 99, 235, 0.05);
|
|
}
|
|
|
|
.search-item .name {
|
|
display: block;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.search-item .details {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.section-view { display: none; }
|
|
.section-view.active { display: block; }
|
|
|
|
/* Payment Visualizer Styles */
|
|
.stat-card {
|
|
background: var(--bg-card);
|
|
padding: 1.5rem;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.stat-card h3 {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.stat-card p {
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
color: var(--text-main);
|
|
margin: 0;
|
|
}
|
|
|
|
.stat-card.success p { color: var(--success); }
|
|
.stat-card.danger p { color: var(--danger); }
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<aside class="sidebar">
|
|
<div class="logo" style="flex-direction: column; align-items: center; gap: 15px; margin-bottom: 3.5rem;">
|
|
<img src="?action=image&file=logo-empresa.png" alt="Logo Empresa" style="width: 180px; height: 180px; object-fit: contain; border-radius: 16px; background: #fff; padding: 10px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
|
|
</div>
|
|
<nav>
|
|
<a href="#" class="nav-link active" onclick="switchSection('installers', this)">
|
|
<img src="?action=image&file=instalador.png" alt="" style="width: 28px; height: 28px; object-fit: contain; flex-shrink: 0;">
|
|
<span>Instaladores</span>
|
|
</a>
|
|
<a href="#" class="nav-link" onclick="switchSection('notifications', this)">
|
|
<img src="?action=image&file=whatsapp-logo.png" alt="" style="width: 28px; height: 28px; object-fit: contain; flex-shrink: 0;">
|
|
<span>Notificaciones</span>
|
|
</a>
|
|
<a href="#" class="nav-link" onclick="switchSection('stripe', this)">
|
|
<img src="?action=image&file=stripe-logo.png" alt="" style="width: 28px; height: 28px; object-fit: contain; flex-shrink: 0;">
|
|
<span>Pagos SPEI</span>
|
|
</a>
|
|
<a href="#" class="nav-link" onclick="switchSection('oxxo', this)">
|
|
<img src="?action=image&file=oxxo-logo.png" alt="" style="width: 28px; height: 28px; object-fit: contain; flex-shrink: 0;">
|
|
<span>Pagos OXXO</span>
|
|
</a>
|
|
<a href="#" class="nav-link" onclick="switchSection('payments-viz', this)">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="12" y1="20" x2="12" y2="10"></line>
|
|
<line x1="18" y1="20" x2="18" y2="4"></line>
|
|
<line x1="6" y1="20" x2="6" y2="16"></line>
|
|
</svg>
|
|
<span>Visualizador Pagos</span>
|
|
</a>
|
|
</nav>
|
|
</aside>
|
|
|
|
<main class="main-content">
|
|
<header class="global-header">
|
|
<h2>Portal Administrativo de Pagos de STRIPE y Notificaciones WhatsApp</h2>
|
|
</header>
|
|
|
|
<div class="main-container">
|
|
<header class="header">
|
|
<div class="title-section">
|
|
<h1 id="headerTitle">Gestión de Equipo</h1>
|
|
<p id="headerDesc">Administra los técnicos registrados en el sistema</p>
|
|
</div>
|
|
</header>
|
|
<div class="actions">
|
|
<button class="btn theme-toggle" id="themeBtn" title="Cambiar tema">
|
|
<svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
|
|
<svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
|
|
</button>
|
|
<button class="btn btn-primary" id="addBtn">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
|
Nuevo Instalador
|
|
</button>
|
|
</div>
|
|
|
|
<section id="section-installers" class="section-view active">
|
|
<div class="card">
|
|
<div class="table-container">
|
|
<table id="installersTable">
|
|
<thead>
|
|
<tr>
|
|
<th>ID UCRM</th>
|
|
<th>Nombre Completo</th>
|
|
<th>WhatsApp</th>
|
|
<th>Acciones</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="section-notifications" class="section-view">
|
|
<div class="card" style="margin-bottom: 2rem;">
|
|
<div class="form-group" style="position: relative;">
|
|
<label>Buscar Cliente (Nombre, Email o ID)</label>
|
|
<input type="text" id="clientSearch" class="form-control" placeholder="Escribe para buscar..." autocomplete="off">
|
|
<div id="searchResults" class="search-results"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="paymentsContainer" class="card" style="display: none;">
|
|
<h3 id="selectedClientName" style="margin-bottom: 1.5rem;">Pagos de Cliente</h3>
|
|
<div class="table-container">
|
|
<table id="paymentsTable">
|
|
<thead>
|
|
<tr>
|
|
<th>ID Pago</th>
|
|
<th>Fecha</th>
|
|
<th>Monto</th>
|
|
<th>Método</th>
|
|
<th>Acción</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="section-stripe" class="section-view">
|
|
<div class="card" style="margin-bottom: 2rem;">
|
|
<div class="form-group" style="position: relative;">
|
|
<label>Buscar Cliente para Stripe (Nombre, Email o ID)</label>
|
|
<input type="text" id="stripeSearch" class="form-control" placeholder="Escribe para buscar..." autocomplete="off">
|
|
<div id="stripeResults" class="search-results"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="stripeDetailContainer" class="card" style="display: none;">
|
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem;">
|
|
<div>
|
|
<h3 id="stripeClientName">Nombre del Cliente</h3>
|
|
<p id="stripeClientIdDisplay" style="color: var(--text-muted); font-size: 0.9rem;">ID: #0</p>
|
|
</div>
|
|
<div style="text-align: right;">
|
|
<span class="badge" id="stripeBalanceBadge">Saldo Pendiente: $0.00</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="background: rgba(37, 99, 235, 0.05); padding: 1.5rem; border-radius: 12px; margin-bottom: 2rem; border-left: 4px solid var(--primary);">
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
|
<div>
|
|
<label style="font-size: 0.8rem; color: var(--text-muted); display: block;">Stripe Customer ID</label>
|
|
<strong id="stripeCustomerIdDisplay">No disponible</strong>
|
|
</div>
|
|
<div>
|
|
<label style="font-size: 0.8rem; color: var(--text-muted); display: block;">Clabe Interbancaria (Personalizada)</label>
|
|
<strong id="stripeClabeDisplay">No disponible</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="max-width: 400px;">
|
|
<div class="form-group">
|
|
<label>Monto a Cobrar (MXN)</label>
|
|
<div style="position: relative;">
|
|
<span style="position: absolute; left: 12px; top: 50%; transform: translateY(-50%); font-weight: 600;">$</span>
|
|
<input type="number" id="stripeAmount" class="form-control" style="padding-left: 2rem;" placeholder="0.00" step="0.01">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Administrador que registra</label>
|
|
<select id="stripeAdminSelect" class="form-control">
|
|
<option value="">-- Automático (Usuario de Stripe) --</option>
|
|
<?php foreach ($admins as $admin): ?>
|
|
<option value="<?= $admin['id'] ?>"><?= $admin['nombre'] ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
|
|
<a id="btnVerEnCrm" href="#" target="_blank" class="btn btn-secondary" style="width: 100%; justify-content: center; margin-bottom: 1rem; text-decoration: none;">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" x2="21" y1="14" y2="3"/></svg>
|
|
Ver en CRM
|
|
</a>
|
|
|
|
<button id="btnCreateIntent" class="btn btn-primary" style="width: 100%; justify-content: center; height: 50px; font-weight: 700; margin-top: 1.5rem;">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 10px;"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>
|
|
Generar Referencia SPEI
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Stripe Result -->
|
|
<div id="stripeResultModal" class="modal-overlay" style="display: none; z-index: 1100;">
|
|
<div class="modal card" style="max-width: 500px;">
|
|
<h2 style="margin-bottom: 1rem;">Referencia Generada</h2>
|
|
<div id="stripeResultContent" style="margin-bottom: 1.5rem;"></div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-primary" onclick="document.getElementById('stripeResultModal').style.display='none'">Cerrar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Nueva Sección OXXO -->
|
|
<section id="section-oxxo" class="section-view">
|
|
<div class="card" style="margin-bottom: 2rem;">
|
|
<div class="form-group" style="position: relative;">
|
|
<label>Buscar Cliente para OXXO (Nombre, Email o ID)</label>
|
|
<input type="text" id="oxxoSearch" class="form-control" placeholder="Escribe para buscar..." autocomplete="off">
|
|
<div id="oxxoResults" class="search-results"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="oxxoDetailContainer" class="card" style="display: none;">
|
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem;">
|
|
<div>
|
|
<h3 id="oxxoClientName">Nombre del Cliente</h3>
|
|
<p id="oxxoClientIdDisplay" style="color: var(--text-muted); font-size: 0.9rem;">ID: #0</p>
|
|
</div>
|
|
<div style="text-align: right;">
|
|
<span class="badge" id="oxxoBalanceBadge" style="background: rgba(235,28,36,0.1); color: #eb1c24;">Saldo Pendiente: $0.00</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="max-width: 400px;">
|
|
<div class="form-group">
|
|
<label>Monto a Cobrar en OXXO (MXN)</label>
|
|
<div style="position: relative;">
|
|
<span style="position: absolute; left: 12px; top: 50%; transform: translateY(-50%); font-weight: 600;">$</span>
|
|
<input type="number" id="oxxoAmount" class="form-control" style="padding-left: 2rem;" placeholder="0.00" step="0.01">
|
|
</div>
|
|
</div>
|
|
|
|
<button id="btnCreateOxxoIntent" class="btn btn-secondary" style="width: 100%; justify-content: center; height: 50px; font-weight: 700; background: #fff; border-color: #eb1c24; color: #eb1c24; margin-top: 1.5rem;">
|
|
<img src="?action=image&file=oxxo-logo.png" alt="" style="height: 20px; margin-right: 10px;">
|
|
Generar Ficha OXXO Pay
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Contenedor de Resultado Inline: Full Width -->
|
|
<div id="oxxoInlineResult" style="margin-top: 1.5rem; display: none;"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="section-payments-viz" class="section-view">
|
|
<!-- Selector de Mes -->
|
|
<div class="card" style="margin-bottom: 2rem;">
|
|
<div style="display: flex; gap: 1rem; align-items: flex-end;">
|
|
<div class="form-group" style="flex: 1; margin-bottom: 0;">
|
|
<label>Seleccionar Mes</label>
|
|
<input type="month" id="monthSelector" class="form-control" value="<?= date('Y-m') ?>">
|
|
</div>
|
|
<button class="btn btn-primary" onclick="loadPaymentsData()" style="height: 46px;">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
</svg>
|
|
Cargar Datos
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loader -->
|
|
<div id="paymentsLoader" style="display: none; text-align: center; padding: 3rem;">
|
|
<div style="width: 50px; height: 50px; border: 4px solid var(--border); border-top-color: var(--primary); border-radius: 50%; margin: 0 auto; animation: spin 1s linear infinite;"></div>
|
|
<p style="margin-top: 1rem; color: var(--text-muted);">Cargando datos...</p>
|
|
</div>
|
|
|
|
<!-- Estadísticas Resumen -->
|
|
<div id="paymentsStats" style="display: none;">
|
|
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
|
<div class="stat-card">
|
|
<h3>Total Clientes Activos</h3>
|
|
<p id="total-clients">0</p>
|
|
</div>
|
|
<div class="stat-card success">
|
|
<h3>Clientes que Pagaron</h3>
|
|
<p id="clients-paid">0</p>
|
|
<span id="paid-percentage" style="color: var(--text-muted); font-size: 0.875rem;"></span>
|
|
</div>
|
|
<div class="stat-card danger">
|
|
<h3>Clientes Pendientes</h3>
|
|
<p id="clients-pending">0</p>
|
|
<span id="pending-percentage" style="color: var(--text-muted); font-size: 0.875rem;"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Gráfica -->
|
|
<div class="card" style="margin-bottom: 2rem;">
|
|
<canvas id="paymentsChart" style="max-height: 400px;"></canvas>
|
|
</div>
|
|
|
|
<!-- Tabla de Clientes Pendientes -->
|
|
<div class="card">
|
|
<h3 style="margin-bottom: 1.5rem;">Clientes Pendientes de Pago</h3>
|
|
<div class="table-container">
|
|
<table id="pendingTable">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Nombre</th>
|
|
<th>Email</th>
|
|
<th>Saldo</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<!-- Modal Form -->
|
|
<div class="modal-overlay" id="modalOverlay">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h2 id="modalTitle">Registro de Instalador</h2>
|
|
</div>
|
|
<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">
|
|
<option value="">-- Seleccionar de UCRM --</option>
|
|
<?php foreach ($admins as $admin): ?>
|
|
<option value="<?= $admin['id'] ?>" data-name="<?= $admin['nombre'] ?>"><?= $admin['nombre'] ?> (ID: <?= $admin['id'] ?>)</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>ID de Usuario (Automático)</label>
|
|
<input type="number" id="installerId" class="form-control" required readonly>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Nombre Completo</label>
|
|
<input type="text" id="installerName" class="form-control" required readonly>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>WhatsApp (Sin +)</label>
|
|
<input type="text" id="installerWhatsApp" class="form-control" required placeholder="Ej. 524181234567">
|
|
</div>
|
|
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn" onclick="closeModal()">Cancelar</button>
|
|
<button type="submit" class="btn btn-primary">Confirmar</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast"></div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
<script>
|
|
const store = {
|
|
installers: <?php echo json_encode($installersData['instaladores']); ?>,
|
|
theme: localStorage.getItem('theme') || 'light',
|
|
crmUrl: '<?php echo $ucrmApiUrl; ?>',
|
|
defaultStripeAdminId: '<?php echo $defaultStripeAdminId; ?>'
|
|
};
|
|
|
|
document.documentElement.setAttribute('data-theme', store.theme);
|
|
|
|
function switchSection(sectionId, element) {
|
|
// Update Sidebar
|
|
document.querySelectorAll('.nav-link').forEach(link => link.classList.remove('active'));
|
|
element.classList.add('active');
|
|
|
|
// Update Views
|
|
document.querySelectorAll('.section-view').forEach(view => view.classList.remove('active'));
|
|
document.getElementById(`section-${sectionId}`).classList.add('active');
|
|
|
|
// Update Header Buttons
|
|
document.getElementById('addBtn').style.display = sectionId === 'installers' ? 'flex' : 'none';
|
|
|
|
// Update Titles
|
|
const title = document.getElementById('headerTitle');
|
|
const desc = document.getElementById('headerDesc');
|
|
if (sectionId === 'installers') {
|
|
title.textContent = 'Gestión de Equipo';
|
|
desc.textContent = 'Administra los técnicos registrados en el sistema';
|
|
} else if (sectionId === 'notifications') {
|
|
title.textContent = 'Centro de Notificaciones';
|
|
desc.textContent = 'Re-envía manualmente notificaciones de WhatsApp a tus clientes';
|
|
} else if (sectionId === 'stripe') {
|
|
title.textContent = 'Pagos SPEI';
|
|
desc.textContent = 'Genera referencias de transferencia bancaria personalizadas para tus clientes';
|
|
} else if (sectionId === 'oxxo') {
|
|
title.textContent = 'Pagos OXXO Pay';
|
|
desc.textContent = 'Genera fichas de pago para establecimientos OXXO';
|
|
} else if (sectionId === 'payments-viz') {
|
|
title.textContent = 'Visualizador de Pagos';
|
|
desc.textContent = 'Análisis mensual de pagos de clientes activos';
|
|
}
|
|
}
|
|
|
|
// --- Notifications Search Logic ---
|
|
const clientSearch = document.getElementById('clientSearch');
|
|
const searchResults = document.getElementById('searchResults');
|
|
let searchTimeout;
|
|
|
|
clientSearch.addEventListener('input', (e) => {
|
|
const query = e.target.value.trim();
|
|
clearTimeout(searchTimeout);
|
|
if (query.length < 2) {
|
|
searchResults.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
searchTimeout = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(`?action=search_clients&q=${encodeURIComponent(query)}`);
|
|
const clients = await res.json();
|
|
renderSearchResults(clients);
|
|
} catch (e) { console.error('Error searching clients', e); }
|
|
}, 300);
|
|
});
|
|
|
|
function renderSearchResults(clients) {
|
|
searchResults.innerHTML = '';
|
|
if (clients.length === 0) {
|
|
searchResults.innerHTML = '<div class="search-item">No se encontraron clientes</div>';
|
|
} else {
|
|
clients.forEach(client => {
|
|
const div = document.createElement('div');
|
|
div.className = 'search-item';
|
|
const name = client.clientType === 2 ? client.companyName : `${client.firstName} ${client.lastName}`;
|
|
div.innerHTML = `
|
|
<span class="name">${name}</span>
|
|
<span class="details">ID: ${client.id} | ${client.userIdent || 'Sin Identificador'}</span>
|
|
`;
|
|
div.onclick = () => selectClient(client.id, name);
|
|
searchResults.appendChild(div);
|
|
});
|
|
}
|
|
searchResults.style.display = 'block';
|
|
}
|
|
|
|
async function selectClient(clientId, name) {
|
|
searchResults.style.display = 'none';
|
|
clientSearch.value = '';
|
|
document.getElementById('selectedClientName').textContent = `Pagos de ${name}`;
|
|
document.getElementById('paymentsContainer').style.display = 'block';
|
|
|
|
const tbody = document.querySelector('#paymentsTable tbody');
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;">Cargando pagos...</td></tr>';
|
|
|
|
try {
|
|
const res = await fetch(`?action=get_payments&clientId=${clientId}`);
|
|
const payments = await res.json();
|
|
renderPayments(payments);
|
|
} catch (e) {
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; color:var(--danger);">Error al cargar pagos</td></tr>';
|
|
}
|
|
}
|
|
|
|
function renderPayments(payments) {
|
|
const tbody = document.querySelector('#paymentsTable tbody');
|
|
if (payments.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;">No se encontraron pagos recientes</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = payments.map(p => `
|
|
<tr>
|
|
<td><span class="badge">#${p.id}</span></td>
|
|
<td>${new Date(p.createdDate).toLocaleString()}</td>
|
|
<td><strong>$${p.amount} ${p.currencyCode}</strong></td>
|
|
<td>${p.methodName || 'N/A'}</td>
|
|
<td>
|
|
<button class="btn btn-whatsapp" style="padding: 8px 16px; font-size: 13px; border-radius: 50px;" onclick="resendNotification(${p.id}, this)">
|
|
<img src="?action=image&file=whatsapp-logo-button.png" alt="" style="width: 18px; height: 18px; object-fit: contain; vertical-align: middle; margin-right: 6px;">
|
|
<span style="font-weight: 700;">Re-enviar</span>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
async function resendNotification(paymentId, btn) {
|
|
if (!confirm('¿Seguro que deseas re-enviar la notificación de este pago?')) return;
|
|
|
|
const originalHtml = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = 'Enviando...';
|
|
|
|
const formData = new FormData();
|
|
formData.append('action', 'resend_payment');
|
|
formData.append('paymentId', paymentId);
|
|
|
|
try {
|
|
const res = await fetch(window.location.href, { method: 'POST', body: formData });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
showToast('✅ ' + data.message);
|
|
} else {
|
|
showToast('❌ Error: ' + data.message, true);
|
|
}
|
|
} catch (e) {
|
|
showToast('❌ Fallo de conexión', true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
}
|
|
}
|
|
|
|
// Close search results on click outside
|
|
document.addEventListener('click', (e) => {
|
|
if (clientSearch && !clientSearch.contains(e.target) && !searchResults.contains(e.target)) {
|
|
searchResults.style.display = 'none';
|
|
}
|
|
if (stripeSearch && !stripeSearch.contains(e.target) && !stripeResults.contains(e.target)) {
|
|
stripeResults.style.display = 'none';
|
|
}
|
|
if (document.getElementById('oxxoSearch') && !document.getElementById('oxxoSearch').contains(e.target) && !document.getElementById('oxxoResults').contains(e.target)) {
|
|
document.getElementById('oxxoResults').style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// --- Stripe Logic ---
|
|
const stripeSearch = document.getElementById('stripeSearch');
|
|
const stripeResults = document.getElementById('stripeResults');
|
|
let selectedStripeClient = null;
|
|
|
|
stripeSearch.addEventListener('input', (e) => {
|
|
const query = e.target.value.trim();
|
|
clearTimeout(searchTimeout);
|
|
if (query.length < 2) {
|
|
stripeResults.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
searchTimeout = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(`?action=search_stripe&q=${encodeURIComponent(query)}`);
|
|
const clients = await res.json();
|
|
renderStripeResults(clients);
|
|
} catch (e) { console.error('Error searching stripe clients', e); }
|
|
}, 300);
|
|
});
|
|
|
|
function renderStripeResults(clients) {
|
|
stripeResults.innerHTML = '';
|
|
if (clients.length === 0) {
|
|
stripeResults.innerHTML = '<div class="search-item">No se encontraron clientes</div>';
|
|
} else {
|
|
clients.forEach(client => {
|
|
const div = document.createElement('div');
|
|
div.className = 'search-item';
|
|
const name = client.clientType === 2 ? client.companyName : `${client.firstName} ${client.lastName}`;
|
|
div.innerHTML = `
|
|
<span class="name">${name}</span>
|
|
<span class="details">ID: ${client.id} | ${client.username}</span>
|
|
`;
|
|
div.onclick = () => selectStripeClient(client.id, name);
|
|
stripeResults.appendChild(div);
|
|
});
|
|
}
|
|
stripeResults.style.display = 'block';
|
|
}
|
|
|
|
async function selectStripeClient(clientId, name) {
|
|
stripeResults.style.display = 'none';
|
|
stripeSearch.value = '';
|
|
|
|
try {
|
|
const res = await fetch(`?action=get_stripe_details&id=${clientId}`);
|
|
const data = await res.json();
|
|
|
|
selectedStripeClient = data;
|
|
document.getElementById('stripeClientName').textContent = data.fullName;
|
|
document.getElementById('stripeClientIdDisplay').textContent = `ID: #${data.id}`;
|
|
document.getElementById('stripeBalanceBadge').textContent = `Saldo Pendiente: $${data.accountOutstanding.toFixed(2)}`;
|
|
document.getElementById('stripeCustomerIdDisplay').textContent = data.stripeCustomerId || 'No disponible';
|
|
document.getElementById('stripeClabeDisplay').textContent = data.clabeInterbancaria || 'No disponible';
|
|
document.getElementById('stripeAmount').value = data.accountOutstanding > 0 ? data.accountOutstanding : '';
|
|
|
|
// Actualizar botón Ver en CRM
|
|
const btnVerCrm = document.getElementById('btnVerEnCrm');
|
|
btnVerCrm.href = `${store.crmUrl}/client/${data.id}`;
|
|
|
|
document.getElementById('stripeDetailContainer').style.display = 'block';
|
|
} catch (e) {
|
|
showToast('Error al cargar detalles del cliente', true);
|
|
}
|
|
}
|
|
|
|
// --- OXXO Logic ---
|
|
const oxxoSearch = document.getElementById('oxxoSearch');
|
|
const oxxoResults = document.getElementById('oxxoResults');
|
|
let selectedOxxoClient = null;
|
|
|
|
if (oxxoSearch) {
|
|
oxxoSearch.addEventListener('input', (e) => {
|
|
const query = e.target.value.trim();
|
|
clearTimeout(searchTimeout);
|
|
if (query.length < 2) {
|
|
oxxoResults.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
searchTimeout = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(`?action=search_stripe&q=${encodeURIComponent(query)}`);
|
|
const clients = await res.json();
|
|
renderOxxoResults(clients);
|
|
} catch (e) { console.error('Error searching oxxo clients', e); }
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
function renderOxxoResults(clients) {
|
|
oxxoResults.innerHTML = '';
|
|
if (clients.length === 0) {
|
|
oxxoResults.innerHTML = '<div class="search-item">No se encontraron clientes</div>';
|
|
} else {
|
|
clients.forEach(client => {
|
|
const div = document.createElement('div');
|
|
div.className = 'search-item';
|
|
const name = client.clientType === 2 ? client.companyName : `${client.firstName} ${client.lastName}`;
|
|
div.innerHTML = `
|
|
<span class="name">${name}</span>
|
|
<span class="details">ID: ${client.id} | ${client.username}</span>
|
|
`;
|
|
div.onclick = () => selectOxxoClient(client.id, name);
|
|
oxxoResults.appendChild(div);
|
|
});
|
|
}
|
|
oxxoResults.style.display = 'block';
|
|
}
|
|
|
|
async function selectOxxoClient(clientId, name) {
|
|
oxxoResults.style.display = 'none';
|
|
oxxoSearch.value = '';
|
|
|
|
try {
|
|
const res = await fetch(`?action=get_stripe_details&id=${clientId}`);
|
|
const data = await res.json();
|
|
|
|
selectedOxxoClient = data;
|
|
document.getElementById('oxxoClientName').textContent = data.fullName;
|
|
document.getElementById('oxxoClientIdDisplay').textContent = `ID: #${data.id}`;
|
|
document.getElementById('oxxoBalanceBadge').textContent = `Saldo Pendiente: $${data.accountOutstanding.toFixed(2)}`;
|
|
document.getElementById('oxxoAmount').value = data.accountOutstanding > 0 ? data.accountOutstanding : '';
|
|
|
|
document.getElementById('oxxoDetailContainer').style.display = 'block';
|
|
} catch (e) {
|
|
showToast('Error al cargar detalles del cliente', true);
|
|
}
|
|
}
|
|
|
|
document.getElementById('btnCreateIntent').onclick = async () => {
|
|
if (!selectedStripeClient || !selectedStripeClient.stripeCustomerId) {
|
|
showToast('El cliente no tiene un ID de Stripe válido', true);
|
|
return;
|
|
}
|
|
|
|
const amount = parseFloat(document.getElementById('stripeAmount').value);
|
|
if (!amount || amount < 10) {
|
|
showToast('El monto debe ser al menos 10 MXN', true);
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('btnCreateIntent');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Procesando...';
|
|
|
|
const formData = new FormData();
|
|
formData.append('action', 'create_intent');
|
|
formData.append('clientId', selectedStripeClient.id);
|
|
formData.append('amount', amount);
|
|
formData.append('stripeCustomerId', selectedStripeClient.stripeCustomerId);
|
|
|
|
// Si el selector está vacío (Automático), enviamos el ID por defecto calculado en PHP
|
|
const selectedAdmin = document.getElementById('stripeAdminSelect').value;
|
|
formData.append('adminId', selectedAdmin || store.defaultStripeAdminId);
|
|
|
|
try {
|
|
const res = await fetch(window.location.href, { method: 'POST', body: formData });
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
showStripeResult(data);
|
|
} else {
|
|
showToast('Error: ' + (data.error || 'Fallo desconocido'), true);
|
|
}
|
|
} catch (e) {
|
|
showToast('Fallo de conexión con el servidor', true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Referencia SPEI';
|
|
}
|
|
};
|
|
|
|
document.getElementById('btnCreateOxxoIntent').onclick = async () => {
|
|
if (!selectedOxxoClient) {
|
|
showToast('Selecciona un cliente primero', true);
|
|
return;
|
|
}
|
|
|
|
const amount = parseFloat(document.getElementById('oxxoAmount').value);
|
|
if (!amount || amount < 10) {
|
|
showToast('El monto debe ser al menos 10 MXN', true);
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('btnCreateOxxoIntent');
|
|
const originalContent = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Procesando...';
|
|
|
|
const formData = new FormData();
|
|
formData.append('action', 'create_oxxo_intent');
|
|
formData.append('clientId', selectedOxxoClient.id);
|
|
formData.append('amount', amount);
|
|
|
|
try {
|
|
const res = await fetch(window.location.href, { method: 'POST', body: formData });
|
|
const data = await res.json();
|
|
|
|
if (data.success && data.data) {
|
|
const oxxoData = data.data;
|
|
const voucherUrl = oxxoData.voucher_image_url || `?action=image&file=${oxxoData.voucher_filename}`;
|
|
|
|
const resultHtml = `
|
|
<div class="alert alert-success" style="border: 1px solid #c3e6cb; background-color: #d4edda; color: #155724; padding: 25px; border-radius: 12px; margin-top: 1.5rem;">
|
|
<div style="display: flex; flex-wrap: wrap; gap: 30px; align-items: flex-start;">
|
|
|
|
<!-- Columna Izquierda: Datos del Pago (Texto) -->
|
|
<div style="flex: 1 1 350px; min-width: 0;">
|
|
<h3 style="margin-top: 0; margin-bottom: 20px; color: #155724; font-weight: 700; display: flex; align-items: center;">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 10px;"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
|
¡Ficha Generada Exitosamente!
|
|
</h3>
|
|
|
|
<div style="margin-bottom: 25px;">
|
|
<p style="margin-bottom: 5px; font-size: 1rem; text-transform: uppercase; letter-spacing: 1px; color: #155724; opacity: 0.8;">Referencia OXXO Pay</p>
|
|
<div style="font-family: monospace; font-size: 1.6rem; font-weight: 700; background: rgba(255,255,255,0.7); padding: 10px 15px; border-radius: 8px; word-break: break-all; line-height: 1.2; border: 1px solid rgba(21, 87, 36, 0.2);">
|
|
${oxxoData.oxxo_reference}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 25px;">
|
|
<p style="margin-bottom: 5px; font-size: 1rem; text-transform: uppercase; letter-spacing: 1px; color: #155724; opacity: 0.8;">Monto a Pagar</p>
|
|
<p style="font-size: 1.8rem; font-weight: 800; margin: 0;">$${parseFloat(data.data.amount || amount).toFixed(2)} <span style="font-size: 1rem; font-weight: 600;">MXN</span></p>
|
|
</div>
|
|
|
|
<div style="background: white; padding: 15px; border-radius: 8px; border: 1px solid #c3e6cb; font-size: 0.9rem;">
|
|
<p style="margin-bottom: 8px;"><strong>📥 Enlace Local:</strong> <a href="${voucherUrl}" target="_blank" style="word-break: break-all;">${voucherUrl}</a></p>
|
|
<p style="margin-bottom: 0;"><strong>🌍 Enlace Stripe:</strong> <a href="${oxxoData.url}" target="_blank" style="word-break: break-all;">${oxxoData.url}</a></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Columna Derecha: Imagen del Voucher (Grande) -->
|
|
<div style="flex: 1 1 350px; text-align: center;">
|
|
<div style="background: white; padding: 15px; border-radius: 12px; border: 1px solid #c3e6cb; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);">
|
|
<p style="margin-bottom: 12px; font-weight: 600; color: #155724; font-size: 0.95rem; text-align: left;">Vista Previa del Comprobante</p>
|
|
<a href="${voucherUrl}" target="_blank" style="display: block; overflow: hidden; border-radius: 6px; border: 1px solid #eee;">
|
|
<img src="${voucherUrl}" alt="Ficha OXXO" style="width: 100%; height: auto; display: block; max-height: 500px; object-fit: contain;">
|
|
</a>
|
|
<div style="margin-top: 15px;">
|
|
<a href="${voucherUrl}" target="_blank" class="btn btn-primary" style="width: 100%; text-decoration: none; justify-content: center; font-weight: 600; padding: 10px;">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
|
|
Descargar / Imprimir
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const container = document.getElementById('oxxoInlineResult');
|
|
container.innerHTML = resultHtml;
|
|
container.style.display = 'block';
|
|
|
|
} else {
|
|
showToast('Error: ' + (data.error || 'Fallo desconocido'), true);
|
|
}
|
|
} catch (e) {
|
|
showToast('Fallo de conexión con el servidor', true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalContent;
|
|
}
|
|
};
|
|
|
|
function showStripeResult(data, isOxxo = false) {
|
|
const container = document.getElementById('stripeResultContent');
|
|
|
|
// Verificación extra de error en datos OXXO aunque success sea true
|
|
if (isOxxo && (!data.oxxo_reference || data.error)) {
|
|
showToast('Error en generación: ' + (data.error || 'Referencia vacía'), true);
|
|
return;
|
|
}
|
|
|
|
const amountVal = parseFloat(data.amount) || 0;
|
|
|
|
let html = `
|
|
<div style="text-align: center; margin-bottom: 1.5rem;">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2" style="margin-bottom: 1rem;"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
|
<h3 style="color: var(--success); font-weight: 700;">¡Referencia Creada!</h3>
|
|
<p style="color: var(--text-muted); font-size: 1.1rem;">Monto: <strong>$${amountVal.toFixed(2)} MXN</strong></p>
|
|
</div>
|
|
`;
|
|
|
|
if (isOxxo) {
|
|
// Revertido a URL simple
|
|
const voucherUrl = data.voucher_image_url || `?action=image&file=${data.voucher_filename}`;
|
|
html += `
|
|
<div style="text-align: center;">
|
|
<div class="badge" style="background: rgba(235, 28, 36, 0.1); color: #eb1c24; margin-bottom: 1rem; font-size: 1rem; padding: 6px 16px;">OXXO PAY</div>
|
|
<p style="font-size: 1.1rem; margin-bottom: 1rem;">Referencia: <strong style="color: var(--primary); font-size: 1.4rem; letter-spacing: 2px;">${data.oxxo_reference}</strong></p>
|
|
|
|
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; border: 1px solid #dee2e6; margin-bottom: 1.5rem; text-align: left; font-family: monospace; font-size: 0.9rem; word-break: break-all;">
|
|
<p style="margin-bottom: 5px;"><strong>URL Local:</strong> <a href="${voucherUrl}" target="_blank">${voucherUrl}</a></p>
|
|
<p style="margin-bottom: 0;"><strong>URL Stripe:</strong> <a href="${data.url}" target="_blank">${data.url}</a></p>
|
|
</div>
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
|
<a href="${voucherUrl}" download="voucher_${data.oxxo_reference}.jpeg" target="_blank" class="btn btn-secondary" style="justify-content: center; text-decoration: none; border-color: var(--primary); color: var(--primary);">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
|
|
Descargar
|
|
</a>
|
|
<a href="${data.url}" target="_blank" class="btn btn-primary" style="justify-content: center; text-decoration: none;">
|
|
Ver en Stripe
|
|
</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else if (data.status === 'requires_action' && data.next_action && data.next_action.type === 'display_bank_transfer_instructions') {
|
|
const instr = data.next_action.display_bank_transfer_instructions;
|
|
html += `
|
|
<div style="background: #f1f5f9; padding: 1.5rem; border-radius: 12px; font-family: monospace; font-size: 1rem; border: 1px solid var(--border);">
|
|
<p style="margin-bottom: 0.8rem; color: #1e293b; font-weight: 700; border-bottom: 1px solid #cbd5e1; padding-bottom: 0.5rem;">DATOS PARA TRANSFERENCIA SPEI:</p>
|
|
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
|
|
<span style="color: var(--text-muted);">Banco:</span> <strong>${instr.financial_addresses[0].spei.bank_name}</strong>
|
|
</div>
|
|
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
|
|
<span style="color: var(--text-muted);">CLABE:</span> <strong style="color: var(--primary); font-size: 1.1rem;">${instr.financial_addresses[0].spei.clabe}</strong>
|
|
</div>
|
|
<div style="display: flex; justify-content: space-between;">
|
|
<span style="color: var(--text-muted);">Beneficiario:</span> <strong>${instr.financial_addresses[0].supported_networks[0].toUpperCase()}</strong>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
html += `
|
|
<div style="background: rgba(37, 99, 235, 0.05); padding: 1rem; border-radius: 8px; text-align: center;">
|
|
<p>Estado del pago: <strong>${data.status}</strong></p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
container.innerHTML = html;
|
|
|
|
// Intentar abrir modal con Bootstrap 5, fallback a jQuery (Bootstrap 4/UCRM)
|
|
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
|
const modalEl = document.getElementById('stripeResultModal');
|
|
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
|
modal.show();
|
|
} else if (typeof jQuery !== 'undefined') {
|
|
jQuery('#stripeResultModal').modal('show');
|
|
} else {
|
|
// Último recurso: intentar forzar display, pero probablemente falte CSS
|
|
document.getElementById('stripeResultModal').style.display = 'flex';
|
|
document.getElementById('stripeResultModal').style.opacity = '1';
|
|
document.getElementById('stripeResultModal').classList.add('show');
|
|
}
|
|
}
|
|
|
|
function renderTable() {
|
|
const tbody = document.querySelector('#installersTable tbody');
|
|
tbody.innerHTML = store.installers.map((inst, index) => `
|
|
<tr>
|
|
<td><span class="badge">#${inst.id}</span></td>
|
|
<td><strong>${inst.nombre}</strong></td>
|
|
<td>${inst.whatsapp}</td>
|
|
<td>
|
|
<div class="action-btns">
|
|
<button class="action-btn edit-btn" onclick="editInstaller(${index})" title="Editar">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
|
</button>
|
|
<button class="action-btn delete-btn" onclick="deleteInstaller(${index})" title="Eliminar">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
async function saveToServer() {
|
|
const formData = new FormData();
|
|
formData.append('action', 'save_installers');
|
|
formData.append('installers_data', JSON.stringify({ instaladores: store.installers }));
|
|
|
|
try {
|
|
const res = await fetch(window.location.href, { method: 'POST', body: formData });
|
|
const data = await res.json();
|
|
if (data.success) showToast('Éxito: Cambios guardados en config.json');
|
|
else showToast('Error al guardar datos', true);
|
|
} catch (e) { showToast('Fallo de conexión', true); }
|
|
}
|
|
|
|
function openModal(index = null) {
|
|
const form = document.getElementById('installerForm');
|
|
form.reset();
|
|
const idxField = document.getElementById('editIndex');
|
|
idxField.value = index !== null ? index : '';
|
|
document.getElementById('modalTitle').textContent = index !== null ? 'Editar Datos' : 'Registrar desde UCRM';
|
|
|
|
// Si es edición, ocultamos el selector de admins para evitar cambiar el ID accidentalmente
|
|
document.getElementById('adminSelectGroup').style.display = index !== null ? 'none' : 'block';
|
|
|
|
if (index !== null) {
|
|
const inst = store.installers[index];
|
|
document.getElementById('installerId').value = inst.id;
|
|
document.getElementById('installerName').value = inst.nombre;
|
|
document.getElementById('installerWhatsApp').value = inst.whatsapp;
|
|
}
|
|
|
|
document.getElementById('modalOverlay').style.display = 'flex';
|
|
}
|
|
|
|
function closeModal() { document.getElementById('modalOverlay').style.display = 'none'; }
|
|
|
|
// Manejar selección de admin
|
|
document.getElementById('adminSelect').onchange = (e) => {
|
|
const opt = e.target.options[e.target.selectedIndex];
|
|
if (opt.value) {
|
|
document.getElementById('installerId').value = opt.value;
|
|
document.getElementById('installerName').value = opt.dataset.name;
|
|
} else {
|
|
document.getElementById('installerId').value = '';
|
|
document.getElementById('installerName').value = '';
|
|
}
|
|
};
|
|
|
|
document.getElementById('installerForm').onsubmit = (e) => {
|
|
e.preventDefault();
|
|
const index = document.getElementById('editIndex').value;
|
|
const id = parseInt(document.getElementById('installerId').value);
|
|
|
|
// Validar existencia si es nuevo
|
|
if (index === '' && store.installers.some(inst => inst.id === id)) {
|
|
showToast('Este instalador ya está registrado', true);
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
id: id,
|
|
nombre: document.getElementById('installerName').value,
|
|
whatsapp: document.getElementById('installerWhatsApp').value
|
|
};
|
|
|
|
if (index === '') store.installers.push(data);
|
|
else store.installers[index] = data;
|
|
|
|
closeModal();
|
|
renderTable();
|
|
saveToServer();
|
|
};
|
|
|
|
function editInstaller(index) { openModal(index); }
|
|
function deleteInstaller(index) {
|
|
if (confirm('¿Eliminar a este instalador?')) {
|
|
store.installers.splice(index, 1);
|
|
renderTable();
|
|
saveToServer();
|
|
}
|
|
}
|
|
|
|
document.getElementById('themeBtn').onclick = () => {
|
|
store.theme = store.theme === 'light' ? 'dark' : 'light';
|
|
document.documentElement.setAttribute('data-theme', store.theme);
|
|
localStorage.setItem('theme', store.theme);
|
|
};
|
|
|
|
document.getElementById('addBtn').onclick = () => openModal();
|
|
|
|
function showToast(msg, isError = false) {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = msg;
|
|
toast.style.background = isError ? 'var(--danger)' : '#1e293b';
|
|
toast.classList.add('show');
|
|
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
}
|
|
|
|
// Payment Visualizer Functions
|
|
let paymentsChart = null;
|
|
|
|
async function loadPaymentsData() {
|
|
const month = document.getElementById('monthSelector').value;
|
|
if (!month) {
|
|
showToast('Por favor selecciona un mes', true);
|
|
return;
|
|
}
|
|
|
|
const [year, monthNum] = month.split('-');
|
|
const firstDay = `${year}-${monthNum.padStart(2, '0')}-01`;
|
|
const lastDay = new Date(year, monthNum, 0).toISOString().split('T')[0];
|
|
|
|
// Show loader
|
|
document.getElementById('paymentsLoader').style.display = 'block';
|
|
document.getElementById('paymentsStats').style.display = 'none';
|
|
|
|
try {
|
|
// 1. Fetch active clients
|
|
const clients = await fetchActiveClients();
|
|
|
|
// 2. Fetch payments for the month
|
|
const payments = await fetchPaymentsByMonth(firstDay, lastDay);
|
|
|
|
// 3. Calculate stats
|
|
const stats = calculateStats(clients, payments);
|
|
|
|
// 4. Update UI
|
|
updateStatsDisplay(stats);
|
|
updateChart(stats);
|
|
updatePendingTable(stats.pendingClients);
|
|
|
|
// Hide loader, show stats
|
|
document.getElementById('paymentsLoader').style.display = 'none';
|
|
document.getElementById('paymentsStats').style.display = 'block';
|
|
|
|
} catch (error) {
|
|
console.error('Error loading payments data:', error);
|
|
showToast('Error al cargar datos: ' + error.message, true);
|
|
document.getElementById('paymentsLoader').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async function fetchActiveClients() {
|
|
const response = await fetch('/crm/api/v1.0/clients?isArchived=0&limit=1000', {
|
|
headers: { 'X-Auth-App-Key': '<?= $config["apitoken"] ?>' }
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Error al obtener clientes');
|
|
return await response.json();
|
|
}
|
|
|
|
async function fetchPaymentsByMonth(from, to) {
|
|
const response = await fetch(`/crm/api/v1.0/payments?createdDateFrom=${from}&createdDateTo=${to}&limit=5000`, {
|
|
headers: { 'X-Auth-App-Key': '<?= $config["apitoken"] ?>' }
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Error al obtener pagos');
|
|
return await response.json();
|
|
}
|
|
|
|
function calculateStats(clients, payments) {
|
|
// Filter only clients with active services (not suspended or archived)
|
|
const activeClients = clients.filter(c => !c.hasSuspendedService && !c.isArchived);
|
|
|
|
// Create set of client IDs who paid this month
|
|
const paidClientIds = new Set(payments.map(p => p.clientId));
|
|
|
|
// Clients who paid
|
|
const clientsPaid = activeClients.filter(c => paidClientIds.has(c.id));
|
|
|
|
// Pending clients
|
|
const clientsPending = activeClients.filter(c => !paidClientIds.has(c.id));
|
|
|
|
const paidPercentage = activeClients.length > 0
|
|
? (clientsPaid.length / activeClients.length * 100).toFixed(1)
|
|
: 0;
|
|
|
|
return {
|
|
totalClients: activeClients.length,
|
|
clientsPaid: clientsPaid.length,
|
|
clientsPending: clientsPending.length,
|
|
pendingClients: clientsPending,
|
|
paidPercentage: paidPercentage
|
|
};
|
|
}
|
|
|
|
function updateStatsDisplay(stats) {
|
|
document.getElementById('total-clients').textContent = stats.totalClients;
|
|
document.getElementById('clients-paid').textContent = stats.clientsPaid;
|
|
document.getElementById('clients-pending').textContent = stats.clientsPending;
|
|
document.getElementById('paid-percentage').textContent = `${stats.paidPercentage}% del total`;
|
|
document.getElementById('pending-percentage').textContent = `${(100 - stats.paidPercentage).toFixed(1)}% del total`;
|
|
}
|
|
|
|
function updateChart(stats) {
|
|
const ctx = document.getElementById('paymentsChart').getContext('2d');
|
|
|
|
if (paymentsChart) paymentsChart.destroy();
|
|
|
|
paymentsChart = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: ['Pagaron', 'Pendientes'],
|
|
datasets: [{
|
|
data: [stats.clientsPaid, stats.clientsPending],
|
|
backgroundColor: ['#22c55e', '#ef4444'],
|
|
borderWidth: 0,
|
|
hoverOffset: 10
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom',
|
|
labels: {
|
|
font: { size: 14, weight: '600' },
|
|
padding: 20
|
|
}
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: `Estado de Pagos del Mes (${stats.paidPercentage}% completado)`,
|
|
font: { size: 18, weight: '700' },
|
|
padding: { bottom: 30 }
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
const label = context.label || '';
|
|
const value = context.parsed || 0;
|
|
const percentage = ((value / stats.totalClients) * 100).toFixed(1);
|
|
return `${label}: ${value} clientes (${percentage}%)`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function updatePendingTable(pendingClients) {
|
|
const tbody = document.querySelector('#pendingTable tbody');
|
|
tbody.innerHTML = '';
|
|
|
|
if (pendingClients.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; padding: 2rem; color: var(--text-muted);">¡Todos los clientes activos han pagado este mes! 🎉</td></tr>';
|
|
return;
|
|
}
|
|
|
|
pendingClients.forEach(client => {
|
|
const email = client.contacts?.find(c => c.email)?.email || 'Sin email';
|
|
const nombre = `${client.firstName || ''} ${client.lastName || ''}`.trim() || client.companyName || 'Sin nombre';
|
|
const saldo = client.accountBalance < 0
|
|
? `$${Math.abs(client.accountBalance).toFixed(2)} pendientes`
|
|
: `$${client.accountBalance.toFixed(2)} a favor`;
|
|
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td>${client.id}</td>
|
|
<td>${nombre}</td>
|
|
<td>${email}</td>
|
|
<td style="color: ${client.accountBalance < 0 ? 'var(--danger)' : 'var(--success)'}; font-weight: 600;">${saldo}</td>
|
|
`;
|
|
tbody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
renderTable();
|
|
</script>
|
|
</body>
|
|
</html>
|