This commit is contained in:
Lizandro Guarnizo
2026-03-10 18:42:51 -05:00
parent b57fc16ab3
commit db469b2134
98 changed files with 16365 additions and 303 deletions
+359 -259
View File
@@ -15,10 +15,15 @@ if (!isUserLoggedIn()) {
// Verificar conexión a la base de datos
try {
$db = Database::getInstance();
$dbStatus = 'Conectado';
$db = Database::getInstance();
$pdo = $db->getConnection();
$pdo->query('SELECT 1'); // prueba real
$dbHost = DB_HOST ?? 'desconocido';
$serverIp = $_SERVER['SERVER_ADDR'] ?? gethostbyname(gethostname());
$dbStatus = '<span class="text-success fw-bold">Conectado</span> <small class="text-muted">(' . htmlspecialchars($dbHost) . ')</small>';
} catch (Exception $e) {
$dbStatus = 'Error: ' . $e->getMessage();
$dbStatus = '<span class="text-danger fw-bold">Error:</span> ' . htmlspecialchars($e->getMessage());
$serverIp = $_SERVER['SERVER_ADDR'] ?? '—';
}
?>
<!DOCTYPE html>
@@ -44,7 +49,7 @@ try {
</div>
<ul class="sidebar-menu">
<li><a href="#dashboard" class="nav-link active" data-tab="dashboard"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
<li><a href="#conversations" class="nav-link" data-tab="conversations"><i class="fas fa-comments"></i> Conversaciones <span id="conversations-count" class="sidebar-badge badge bg-danger" style="display:none; font-size:0.72rem; padding:0.15rem 0.4rem;">0</span></a></li>
<li><a href="#conversations" class="nav-link" data-tab="conversations"><i class="fas fa-comments"></i> Conversaciones</a></li>
<li><a href="#users" class="nav-link" data-tab="users"><i class="fas fa-users"></i> Usuarios</a></li>
<li><a href="#menus" class="nav-link" data-tab="menus"><i class="fas fa-list"></i> Menús</a></li>
<li><a href="#admin_users" class="nav-link" data-tab="admin_users"><i class="fas fa-user-shield"></i> Usuarios del Sistema</a></li>
@@ -55,6 +60,9 @@ try {
<li><a href="#autoresponses" class="nav-link" data-tab="autoresponses"><i class="fas fa-robot"></i> Respuestas Auto</a></li>
<li><a href="#system_config" class="nav-link" data-tab="system_config"><i class="fas fa-cog"></i> Configuración</a></li>
<li><a href="#logs" class="nav-link" data-tab="logs"><i class="fas fa-file-alt"></i> Logs</a></li>
<li><a href="#terms" class="nav-link" data-tab="terms"><i class="fas fa-file-contract"></i> T&amp;C Aceptaciones</a></li>
<li class="sidebar-divider"></li>
<li><a href="lab_dashboard.php" class="nav-link text-warning fw-semibold"><i class="fas fa-flask"></i> Módulo Laboratorio <span class="badge bg-warning text-dark ms-1" style="font-size:.65rem">LAB</span></a></li>
<li class="sidebar-divider"></li>
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Está seguro que desea cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
</ul>
@@ -63,8 +71,7 @@ try {
<!-- Main Content -->
<main class="main-content">
<!-- Notification toasts container (global) -->
<div id="global-notification-toasts" style="position:fixed; top:12px; right:12px; z-index:9999;"></div>
<!-- Header -->
<header class="content-header">
<div>
@@ -89,12 +96,6 @@ try {
<div class="status-badge">
<span class="status-dot status-online"></span>
<span class="status-text">En línea</span>
</div> <!-- Notification bell -->
<div class="me-2">
<button id="notification-bell" class="btn btn-light position-relative" title="Notificaciones">
<i class="fas fa-bell"></i>
<span id="notification-count" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger" style="display:none;">0</span>
</button>
</div> <button class="btn btn-primary me-2" onclick="refreshData()">
<i class="fas fa-sync-alt"></i> Actualizar
</button>
@@ -312,7 +313,7 @@ try {
</h5>
<small class="text-white-50">Administra tus chats de WhatsApp</small>
</div>
<div class="col-auto">
<div class="col-auto d-flex align-items-center gap-2">
<div class="input-group">
<span class="input-group-text bg-light border-0">
<i class="fas fa-search"></i>
@@ -321,6 +322,9 @@ try {
placeholder="Buscar conversaciones..."
id="search-conversations">
</div>
<button class="btn btn-light btn-sm text-nowrap" onclick="exportConversations()" title="Exportar todas las conversaciones a CSV">
<i class="fas fa-file-csv"></i> Exportar
</button>
</div>
</div>
</div>
@@ -417,40 +421,17 @@ try {
<!-- Admin Users Tab -->
<div id="admin_users" class="tab-content">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="fas fa-user-shield"></i> Usuarios del Sistema</h5>
<button class="btn btn-primary" onclick="showCreateAdminUserModal()">
<i class="fas fa-plus"></i> Nuevo Usuario
</button>
</div>
<div class="card-body">
<div class="alert alert-info mb-3">
<i class="fas fa-info-circle"></i>
Gestiona los usuarios administradores que tienen acceso al sistema.
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Usuario</th>
<th>Nombre Completo</th>
<th>Email</th>
<th>Estado</th>
<th>Último Acceso</th>
<th>Fecha Creación</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="admin-users-table">
<tr>
<td colspan="7" class="text-center">
<i class="fas fa-spinner fa-spin"></i> Cargando usuarios...
</td>
</tr>
</tbody>
</table>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body text-center py-5">
<i class="fas fa-users-cog fa-3x text-primary mb-3"></i>
<h5 class="fw-semibold mb-2">Gestión de Usuarios &amp; Roles</h5>
<p class="text-muted mb-4">
La administración de usuarios, roles y permisos de módulo<br>
se encuentra centralizada en el Módulo de Laboratorio.
</p>
<a href="lab_usuarios.php" class="btn btn-primary px-4">
<i class="fas fa-external-link-alt me-2"></i>Ir a Usuarios &amp; Roles
</a>
</div>
</div>
</div>
@@ -802,8 +783,71 @@ try {
<label class="form-label">Mensaje de Bienvenida</label>
<textarea class="form-control" id="welcome-message" rows="3" placeholder="Mensaje que se envía a usuarios nuevos"></textarea>
</div>
<div class="mb-3">
<label class="form-label">
<i class="fas fa-file-contract me-1 text-primary"></i>Mensaje de Términos y Condiciones
</label>
<textarea class="form-control" id="terms-message" rows="4"
placeholder="Mensaje de aceptación de términos y condiciones. Se muestra antes de cualquier interacción."></textarea>
<small class="form-text text-muted">Este mensaje se muestra al usuario antes de aceptar cualquier cosa. Incluye las opciones de aceptar o rechazar.</small>
</div>
<div class="mb-3">
<label class="form-label">
<i class="fas fa-ban me-1 text-danger"></i>Mensaje cuando rechaza los términos
</label>
<textarea class="form-control" id="terms-rejected-message" rows="3"
placeholder="Mensaje que se envía cuando el usuario rechaza los términos y condiciones."></textarea>
<small class="form-text text-muted">Se envía automáticamente si el usuario elige no aceptar.</small>
</div>
</div>
<!-- ── GESTIÓN DOCUMENTO PDF TÉRMINOS ─────────────────────────── -->
<div class="card border-0 bg-light mb-4">
<div class="card-body p-3">
<h6 class="mb-3"><i class="fas fa-file-pdf text-danger me-1"></i>Documento PDF de Términos</h6>
<div class="mb-3" id="terms-doc-current" style="display:none;">
<div class="d-flex align-items-center gap-2 p-2 bg-white rounded border">
<i class="fas fa-file-pdf text-danger fa-lg"></i>
<div class="flex-grow-1 overflow-hidden">
<div class="text-truncate small fw-bold" id="terms-doc-name">—</div>
<div class="text-muted" style="font-size:.75rem;">Versión: <span id="terms-doc-version">—</span></div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="terms-doc-copy-btn" title="Copiar URL">
<i class="fas fa-copy"></i>
</button>
<a href="#" target="_blank" id="terms-doc-link" class="btn btn-sm btn-outline-primary" title="Abrir PDF">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
</div>
<div class="mb-3">
<label class="form-label small">Versión del nuevo documento <span class="text-muted">(ej. 2026-06)</span></label>
<input type="text" class="form-control form-control-sm" id="terms-new-version" placeholder="2026-06">
</div>
<div class="mb-3">
<label class="form-label small">Subir nuevo PDF</label>
<input class="form-control form-control-sm" type="file" id="terms-pdf-file" accept="application/pdf">
</div>
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="terms-force-reaccept">
<label class="form-check-label small" for="terms-force-reaccept">
<strong>Forzar re-aceptación a todos los usuarios</strong>
<span class="text-muted d-block" style="font-size:.75rem;">Al guardar, todos los usuarios deberán aceptar los nuevos términos la próxima vez que escriban.</span>
</label>
</div>
<button type="button" class="btn btn-danger btn-sm" id="terms-upload-btn">
<i class="fas fa-upload me-1"></i>Guardar documento y configuración
</button>
<div id="terms-upload-status" class="mt-2 small" style="display:none;"></div>
</div>
</div>
<!-- ── / GESTIÓN DOCUMENTO PDF ────────────────────────────────── -->
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Guardar Configuración
</button>
@@ -843,7 +887,7 @@ try {
<div class="card-body">
<dl class="row">
<dt class="col-sm-6">Versión:</dt>
<dd class="col-sm-6">1.0.0</dd>
<dd class="col-sm-6"><span class="badge bg-primary">v<?php echo APP_VERSION; ?></span></dd>
<dt class="col-sm-6">PHP:</dt>
<dd class="col-sm-6"><?php echo PHP_VERSION; ?></dd>
@@ -851,9 +895,12 @@ try {
<dt class="col-sm-6">Base de Datos:</dt>
<dd class="col-sm-6"><?php echo $dbStatus; ?></dd>
<dt class="col-sm-6">IP del Servidor:</dt>
<dd class="col-sm-6"><code><?php echo htmlspecialchars($serverIp); ?></code></dd>
<dt class="col-sm-6">Webhook URL:</dt>
<dd class="col-sm-6">
<code><?php echo APP_URL; ?>/api/webhook.php</code>
<code><?php echo htmlspecialchars(APP_URL); ?>/api/webhook.php</code>
</dd>
</dl>
</div>
@@ -907,6 +954,73 @@ try {
</div>
</div>
</div>
<!-- T&C Aceptaciones Tab -->
<div id="terms" class="tab-content" style="display:none;">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i>Aceptaciones de Términos y Condiciones</h5>
<div class="d-flex gap-2 align-items-center flex-wrap">
<!-- Filtros -->
<select id="terms-filter-estado" class="form-select form-select-sm" style="width:auto;">
<option value="">Todos los estados</option>
<option value="pendiente">Pendiente</option>
<option value="aceptado">Aceptado</option>
<option value="rechazado">Rechazado</option>
</select>
<input type="date" id="terms-filter-fecha" class="form-control form-control-sm" style="width:auto;">
<input type="text" id="terms-filter-phone" class="form-control form-control-sm" placeholder="Teléfono..." style="width:140px;">
<button class="btn btn-sm btn-primary" onclick="termsAdmin.cargar()"><i class="fas fa-search"></i></button>
<a id="terms-export-btn" href="lab_terminos_export.php" target="_blank" class="btn btn-sm btn-outline-success">
<i class="fas fa-file-csv me-1"></i>CSV
</a>
</div>
</div>
<div class="card-body p-0">
<!-- Stats row -->
<div class="d-flex gap-0 border-bottom" id="terms-stats-row">
<div class="flex-fill text-center py-3 border-end">
<div class="h4 mb-0 text-success fw-bold" id="terms-stat-aceptado">—</div>
<div class="text-muted" style="font-size:.8rem;">Aceptaron</div>
</div>
<div class="flex-fill text-center py-3 border-end">
<div class="h4 mb-0 text-danger fw-bold" id="terms-stat-rechazado">—</div>
<div class="text-muted" style="font-size:.8rem;">Rechazaron</div>
</div>
<div class="flex-fill text-center py-3 border-end">
<div class="h4 mb-0 text-warning fw-bold" id="terms-stat-pendiente">—</div>
<div class="text-muted" style="font-size:.8rem;">Pendientes</div>
</div>
<div class="flex-fill text-center py-3">
<div class="h4 mb-0 fw-bold" id="terms-stat-total">—</div>
<div class="text-muted" style="font-size:.8rem;">Total</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Teléfono</th>
<th>Nombre</th>
<th>Estado</th>
<th>Versión T&amp;C</th>
<th>Fecha envío</th>
<th>Fecha respuesta</th>
</tr>
</thead>
<tbody id="terms-table-body">
<tr><td colspan="7" class="text-center text-muted py-4">Haz clic en <i class="fas fa-search"></i> para cargar</td></tr>
</tbody>
</table>
</div>
<!-- Paginación -->
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top" id="terms-pagination-row" style="display:none!important;">
<small class="text-muted" id="terms-pagination-info"></small>
<div class="d-flex gap-1" id="terms-pagination-btns"></div>
</div>
</div>
</div>
</div>
</main>
<!-- Modal - Crear Plantilla -->
@@ -987,97 +1101,7 @@ try {
</div>
</div>
<!-- Modal para crear/editar usuario administrador -->
<div class="modal fade" id="adminUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="adminUserModalLabel">
<i class="fas fa-user-shield"></i> <span id="admin-user-modal-title">Nuevo Usuario</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="admin-user-form">
<input type="hidden" id="admin-user-id">
<div class="mb-3">
<label class="form-label">Nombre de Usuario *</label>
<input type="text" class="form-control" id="admin-username" required
placeholder="usuario123">
<small class="text-muted">Usado para iniciar sesión</small>
</div>
<div class="mb-3">
<label class="form-label">Nombre Completo</label>
<input type="text" class="form-control" id="admin-fullname"
placeholder="Juan Pérez">
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="admin-email"
placeholder="usuario@ejemplo.com">
</div>
<div class="mb-3" id="password-group">
<label class="form-label">Contraseña *</label>
<input type="password" class="form-control" id="admin-password"
placeholder="Mínimo 6 caracteres">
<small class="text-muted">Mínimo 6 caracteres</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times"></i> Cancelar
</button>
<button type="button" class="btn btn-primary" onclick="saveAdminUser()">
<i class="fas fa-save"></i> Guardar
</button>
</div>
</div>
</div>
</div>
<!-- Modal para cambiar contraseña -->
<div class="modal fade" id="changePasswordModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-key"></i> Cambiar Contraseña
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="change-password-user-id">
<p class="mb-3">Usuario: <strong id="change-password-username"></strong></p>
<div class="mb-3">
<label class="form-label">Nueva Contraseña *</label>
<input type="password" class="form-control" id="new-password"
placeholder="Mínimo 6 caracteres" required>
<small class="text-muted">Mínimo 6 caracteres</small>
</div>
<div class="mb-3">
<label class="form-label">Confirmar Contraseña *</label>
<input type="password" class="form-control" id="confirm-password"
placeholder="Repite la contraseña" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times"></i> Cancelar
</button>
<button type="button" class="btn btn-primary" onclick="saveNewPassword()">
<i class="fas fa-key"></i> Cambiar Contraseña
</button>
</div>
</div>
</div>
</div>
<!-- Los modales de usuario se centralizaron en lab_usuarios.php -->
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
@@ -1092,173 +1116,138 @@ try {
async function refreshRateLimitStats() {
try {
const response = await fetch('api/get_rate_limit_stats.php?action=current');
// Verificar que la respuesta sea válida
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await response.text();
console.error('Respuesta no-JSON recibida:', text.substring(0, 200));
console.error('Respuesta no-JSON:', text.substring(0, 200));
throw new Error('El servidor no devolvió JSON válido');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Error obteniendo estadísticas');
}
const stats = result.data;
updateRateLimitUI(stats);
if (!result.success) throw new Error(result.error || 'Error obteniendo estadísticas');
updateRateLimitUI(result.data);
} catch (error) {
console.error('Error cargando rate limit stats:', error);
const statusEl = document.getElementById('rate-limit-status');
if (statusEl) {
statusEl.innerHTML =
'<i class="fas fa-exclamation-triangle"></i> Error cargando estadísticas: ' + error.message;
statusEl.className = 'alert alert-warning';
statusEl.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Error cargando estadísticas: ' + error.message;
}
}
}
function updateRateLimitUI(stats) {
// Verificar que los elementos existan
const statusEl = document.getElementById('rate-limit-status');
const callCountBar = document.getElementById('fb-call-count-bar');
if (!statusEl || !callCountBar) {
console.log('Elementos del rate limit monitor no encontrados en el DOM');
return;
}
// Manejar caso donde Redis no está disponible
if (!statusEl) return;
// Redis no disponible
if (stats.status === 'unavailable' || stats.error) {
statusEl.className = 'alert alert-info';
statusEl.innerHTML = '<i class="fas fa-info-circle"></i> Rate Limiting Monitor: Redis no disponible. Las estadísticas de Facebook se mostrarán cuando se realice la próxima llamada a la API.';
// Mostrar mensaje en las barras de progreso
const metricsContainer = callCountBar.closest('.row');
if (metricsContainer) {
metricsContainer.innerHTML = '<div class="col-12"><div class="alert alert-info text-center"><i class="fas fa-info-circle"></i> Los datos de Facebook API se capturarán automáticamente en la próxima llamada. Redis: ' + (stats.error || 'No disponible') + '</div></div>';
}
statusEl.innerHTML = '<i class="fas fa-info-circle"></i> Redis no disponible. Los datos de Facebook API se capturarán en la próxima llamada. <small class="d-block text-muted mt-1">' + (stats.error || '') + '</small>';
setProgressBarsEmpty();
setLocalLimits(stats.local_limits);
return;
}
// Actualizar status general
const statusEl = document.getElementById('rate-limit-status');
let statusClass = 'alert-success';
let statusIcon = 'fa-check-circle';
let statusText = 'Sistema operando normalmente';
// Status general
let statusClass = 'alert-success', statusIcon = 'fa-check-circle', statusText = 'Sistema operando normalmente';
if (stats.status === 'critical') {
statusClass = 'alert-danger';
statusIcon = 'fa-exclamation-triangle';
statusText = '¡ALERTA! Rate limit cerca del límite crítico';
statusClass = 'alert-danger'; statusIcon = 'fa-exclamation-triangle'; statusText = '¡ALERTA! Rate limit cerca del límite crítico';
} else if (stats.status === 'warning') {
statusClass = 'alert-warning';
statusIcon = 'fa-exclamation-circle';
statusText = 'Advertencia: Acercándose al límite';
statusClass = 'alert-warning'; statusIcon = 'fa-exclamation-circle'; statusText = 'Advertencia: Acercándose al límite de Facebook';
}
statusEl.className = `alert ${statusClass}`;
statusEl.innerHTML = `<i class="fas ${statusIcon}"></i> ${statusText}`;
// Actualizar métricas de Facebook
statusEl.className = 'alert ' + statusClass;
statusEl.innerHTML = '<i class="fas ' + statusIcon + '"></i> ' + statusText +
(stats.datetime ? ' <small class="float-end text-muted">Actualizado: ' + stats.datetime + '</small>' : '');
// Métricas Facebook
if (stats.app_usage) {
updateProgressBar('fb-call-count-bar', stats.app_usage.call_count || 0);
updateProgressBar('fb-total-time-bar', stats.app_usage.total_time || 0);
updateProgressBar('fb-cpu-time-bar', stats.app_usage.total_cputime || 0);
} else {
// Sin datos de Facebook aún
const callCountBar = document.getElementById('fb-call-count-bar');
if (callCountBar) {
const metricsRow = callCountBar.closest('.row');
if (metricsRow) {
metricsRow.innerHTML =
'<div class="col-12"><div class="alert alert-info text-center"><i class="fas fa-info-circle"></i> Los datos de Facebook API se capturarán automáticamente en la próxima llamada.</div></div>';
}
}
setProgressBarsEmpty();
}
// Actualizar límites locales
if (stats.local_limits) {
const limitSecondEl = document.getElementById('local-limit-second-max');
const limitHourEl = document.getElementById('local-limit-hour-max');
const limitDayEl = document.getElementById('local-limit-day-max');
if (limitSecondEl) limitSecondEl.textContent = stats.local_limits.per_second;
if (limitHourEl) limitHourEl.textContent = stats.local_limits.per_hour;
if (limitDayEl) limitDayEl.textContent = stats.local_limits.per_day;
}
// Mostrar alertas si existen
// Límites locales
setLocalLimits(stats.local_limits);
// Alertas recientes
const alertsSection = document.getElementById('rate-limit-alerts-section');
const alertsContainer = document.getElementById('rate-limit-alerts');
if (stats.alerts && stats.alerts.length > 0 && alertsSection && alertsContainer) {
alertsSection.style.display = 'block';
alertsContainer.innerHTML = '';
stats.alerts.slice(0, 5).forEach(alert => {
const alertClass = alert.severity === 'critical' ? 'list-group-item-danger' : 'list-group-item-warning';
const alertIcon = alert.severity === 'critical' ? 'fa-exclamation-triangle' : 'fa-exclamation-circle';
const alertEl = document.createElement('div');
alertEl.className = `list-group-item ${alertClass}`;
alertEl.innerHTML = `
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1"><i class="fas ${alertIcon}"></i> Alerta de ${alert.level}</h6>
<small>${alert.datetime}</small>
</div>
<p class="mb-1">Uso al ${alert.usage}% - Endpoint: ${alert.data.endpoint || 'N/A'}</p>
`;
alertsContainer.appendChild(alertEl);
if (!alert) return;
const cls = alert.severity === 'critical' ? 'list-group-item-danger' : 'list-group-item-warning';
const icon = alert.severity === 'critical' ? 'fa-exclamation-triangle' : 'fa-exclamation-circle';
const el = document.createElement('div');
el.className = 'list-group-item ' + cls;
el.innerHTML = `<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1"><i class="fas ${icon}"></i> Alerta de ${alert.level || '—'}</h6>
<small>${alert.datetime || ''}</small>
</div>
<p class="mb-1">Uso al ${alert.usage || 0}% — Endpoint: ${(alert.data && alert.data.endpoint) || 'N/A'}</p>`;
alertsContainer.appendChild(el);
});
} else if (alertsSection) {
alertsSection.style.display = 'none';
}
}
function setProgressBarsEmpty() {
['fb-call-count-bar','fb-total-time-bar','fb-cpu-time-bar'].forEach(id => {
const bar = document.getElementById(id);
if (!bar) return;
bar.style.width = '0%';
bar.textContent = 'Sin datos';
bar.className = 'progress-bar bg-secondary';
});
}
function setLocalLimits(limits) {
if (!limits) return;
const map = {
'local-limit-second-max': limits.per_second,
'local-limit-hour-max': limits.per_hour,
'local-limit-day-max': limits.per_day,
};
Object.entries(map).forEach(([id, val]) => {
const el = document.getElementById(id);
if (el && val !== undefined) el.textContent = val.toLocaleString();
});
// Limpiar guiones de contadores actuales
['local-limit-second','local-limit-hour','local-limit-day'].forEach(id => {
const el = document.getElementById(id);
if (el && el.textContent === '-') el.textContent = '—';
});
}
function updateProgressBar(id, percentage) {
const bar = document.getElementById(id);
if (!bar) return;
percentage = Math.min(100, Math.max(0, percentage));
percentage = Math.min(100, Math.max(0, parseFloat(percentage) || 0));
bar.style.width = percentage + '%';
bar.textContent = percentage.toFixed(1) + '%';
// Cambiar color según el porcentaje
bar.className = 'progress-bar';
if (percentage >= 90) {
bar.classList.add('bg-danger');
} else if (percentage >= 75) {
bar.classList.add('bg-warning');
} else if (percentage >= 50) {
bar.classList.add('bg-info');
} else {
bar.classList.add('bg-success');
}
if (percentage >= 90) bar.classList.add('bg-danger');
else if (percentage >= 75) bar.classList.add('bg-warning');
else if (percentage >= 50) bar.classList.add('bg-info');
else bar.classList.add('bg-success');
}
// Cargar estadísticas al cargar la página
document.addEventListener('DOMContentLoaded', function() {
// Cargar inmediatamente
refreshRateLimitStats();
// Actualizar cada 30 segundos
setInterval(refreshRateLimitStats, 30000);
});
// Actualizar también cuando se cambia a la pestaña de dashboard
// Cargar al iniciar
document.addEventListener('DOMContentLoaded', refreshRateLimitStats);
// Recargar al activar el tab dashboard
document.querySelectorAll('.nav-link[data-tab="dashboard"]').forEach(link => {
link.addEventListener('click', function() {
setTimeout(refreshRateLimitStats, 500);
});
link.addEventListener('click', () => setTimeout(refreshRateLimitStats, 500));
});
</script>
<script>
@@ -2154,6 +2143,117 @@ try {
});
});
</script>
<!-- ── T&C Aceptaciones JS ───────────────────────────────────────────── -->
<script>
const termsAdmin = (() => {
let _page = 1;
const perPage = 50;
function estadoBadge(e) {
const map = {
aceptado: '<span class="badge bg-success">Aceptado</span>',
rechazado: '<span class="badge bg-danger">Rechazado</span>',
pendiente: '<span class="badge bg-warning text-dark">Pendiente</span>',
};
return map[e] || `<span class="badge bg-secondary">${e}</span>`;
}
function formatDate(d) {
if (!d) return '—';
const dt = new Date(d.replace(' ', 'T'));
return dt.toLocaleString('es-CO', { dateStyle: 'short', timeStyle: 'short' });
}
async function cargar(page) {
_page = page || 1;
const estado = document.getElementById('terms-filter-estado')?.value || '';
const fecha = document.getElementById('terms-filter-fecha')?.value || '';
const phone = document.getElementById('terms-filter-phone')?.value || '';
const params = new URLSearchParams({ page: _page, per_page: perPage });
if (estado) params.set('estado', estado);
if (fecha) params.set('fecha', fecha);
if (phone) params.set('phone', phone);
// Actualizar link de exportación
const expBtn = document.getElementById('terms-export-btn');
if (expBtn) {
const ep = new URLSearchParams();
if (estado) ep.set('estado', estado);
if (fecha) ep.set('fecha', fecha);
if (phone) ep.set('phone', phone);
expBtn.href = 'lab_terminos_export.php?' + ep.toString();
}
const tbody = document.getElementById('terms-table-body');
if (tbody) tbody.innerHTML = '<tr><td colspan="7" class="text-center py-3"><div class="spinner-border spinner-border-sm text-primary"></div></td></tr>';
try {
const res = await fetch('api/get_terms_acceptances.php?' + params.toString());
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Error al cargar');
const rows = json.data || [];
const stats = json.stats || {};
const total = json.total || 0;
const pages = Math.ceil(total / perPage);
// Stats
document.getElementById('terms-stat-aceptado').textContent = stats.aceptado ?? 0;
document.getElementById('terms-stat-rechazado').textContent = stats.rechazado ?? 0;
document.getElementById('terms-stat-pendiente').textContent = stats.pendiente ?? 0;
document.getElementById('terms-stat-total').textContent = total;
// Tabla
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Sin registros</td></tr>';
} else {
tbody.innerHTML = rows.map(r => `
<tr>
<td class="text-muted" style="font-size:.8rem;">${r.id}</td>
<td><code style="font-size:.8rem;">${r.phone_number}</code></td>
<td>${r.user_name || '<span class="text-muted">—</span>'}</td>
<td>${estadoBadge(r.estado)}</td>
<td><span class="badge bg-secondary">${r.terms_version || '—'}</span></td>
<td style="font-size:.8rem;">${formatDate(r.fecha_envio)}</td>
<td style="font-size:.8rem;">${formatDate(r.fecha_respuesta)}</td>
</tr>`).join('');
}
// Paginación
const pInfo = document.getElementById('terms-pagination-info');
const pBtns = document.getElementById('terms-pagination-btns');
const pRow = document.getElementById('terms-pagination-row');
if (pRow) pRow.style.display = pages > 1 ? 'flex' : 'none';
if (pInfo) pInfo.textContent = `Página ${_page} de ${pages} (${total} registros)`;
if (pBtns) {
pBtns.innerHTML = '';
for (let p = 1; p <= Math.min(pages, 10); p++) {
const btn = document.createElement('button');
btn.className = 'btn btn-sm ' + (p === _page ? 'btn-primary' : 'btn-outline-secondary');
btn.textContent = p;
btn.onclick = () => cargar(p);
pBtns.appendChild(btn);
}
}
} catch (e) {
console.error('termsAdmin.cargar error', e);
if (tbody) tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${e.message}</td></tr>`;
}
}
// Cargar cuando se activa el tab
document.addEventListener('DOMContentLoaded', () => {
const link = document.querySelector('.nav-link[data-tab="terms"]');
if (link) link.addEventListener('click', () => setTimeout(() => cargar(1), 50));
});
return { cargar };
})();
</script>
<!-- ── / T&C Aceptaciones JS ─────────────────────────────────────────── -->
</body>
</html>