Files
whatsapp/lab_actividad.php
T
Lizandro GuarnizoandClaude Fable 5 26efe10ae6 Actividad: tarjeta de salud de la voz del televisor
La bitácora turnero_tv_log solo se podía consultar con SQL, así que en la
práctica nadie la iba a mirar. La pantalla de Actividad (Reportes → Actividad)
muestra ahora, en lenguaje llano: cuántos llamados con voz hubo en 7 días, qué
porcentaje habló completo, cuántos salieron mudos y cuántos se recuperaron con
reintento, más los últimos fallos con fecha, turno y voz usada.

La tarjeta no aparece mientras no haya datos (recién desplegado), para no
mostrar una sección vacía.

De paso: el ancla del parche se comió el async de exportarCSV; restaurado y
verificado bloque por bloque.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:36:33 -05:00

381 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Log de Actividad — Trazabilidad del Módulo Administrativo de Laboratorio
*/
require_once 'config/config.php';
if (!defined('APP_ROOT')) { define('APP_ROOT', __DIR__); }
if (!isUserLoggedIn()) { header('Location: login.php'); exit; }
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log de Actividad — Módulo Lab</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
<style>
.log-row td { font-size: .85rem; vertical-align: middle; }
.detalle-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.detalle-cell:hover { text-decoration: underline dotted; color: #0d6efd; }
.badge-accion { font-size: .72rem; }
.ctrl { height: 31px; }
</style>
</head>
<body>
<?php
$SIDEBAR_TITLE = 'Panel Lab';
$SIDEBAR_ICON = 'fas fa-flask';
require_once __DIR__ . '/shared/components/sidebar.php';
?>
<main class="main-content">
<header class="content-header d-flex align-items-center justify-content-between">
<div>
<h1><i class="fas fa-history text-primary"></i> Log de Actividad</h1>
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
</div>
<button class="btn btn-success btn-sm" onclick="exportarCSV()">
<i class="fas fa-file-csv me-1"></i> Exportar CSV
</button>
</header>
<div class="container-fluid py-3">
<!-- Filtros -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label mb-0 small fw-semibold">Desde</label>
<input type="date" id="act-desde" class="form-control form-control-sm ctrl">
</div>
<div class="col-auto">
<label class="form-label mb-0 small fw-semibold">Hasta</label>
<input type="date" id="act-hasta" class="form-control form-control-sm ctrl">
</div>
<div class="col-auto">
<label class="form-label mb-0 small fw-semibold">Módulo</label>
<select id="act-modulo" class="form-select form-select-sm ctrl">
<option value="">Todos</option>
<option value="ordenes">Órdenes</option>
<option value="domicilios">Domicilios</option>
<option value="pacientes">Pacientes</option>
<option value="enfermeras">Enfermeras</option>
<option value="formularios">Formularios</option>
<option value="asignaciones">Asignaciones</option>
<option value="turnero">Turnero</option>
<option value="turnero_config">Turnero · configuración</option>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-0 small fw-semibold">Acción</label>
<select id="act-accion" class="form-select form-select-sm ctrl">
<option value="">Todas</option>
<option value="crear">crear</option>
<option value="editar">editar</option>
<option value="eliminar">eliminar</option>
<option value="autorizar">autorizar</option>
<option value="rechazar">rechazar</option>
<option value="asignar">asignar</option>
<option value="enviar">enviar</option>
<option value="reprogramar">reprogramar</option>
<option value="cambiar_estado">cambiar_estado</option>
<option value="quitar_consentimiento">quitar_consentimiento</option>
<option value="resetear_consentimiento">resetear_consentimiento</option>
<option value="resetear_toma">resetear_toma</option>
<option value="cancelar_toma_pendiente">cancelar_toma_pendiente</option>
<option value="vincular_paciente">vincular_paciente</option>
</select>
</div>
<div class="col">
<label class="form-label mb-0 small fw-semibold">Buscar</label>
<input type="text" id="act-buscar" class="form-control form-control-sm ctrl" placeholder="Usuario o detalle…">
</div>
<div class="col-auto d-flex gap-2">
<button class="btn btn-primary btn-sm ctrl" onclick="cargar(1)">
<i class="fas fa-search"></i>
</button>
<button class="btn btn-outline-secondary btn-sm ctrl" onclick="limpiar()">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Tabla -->
<!-- Salud de la voz del televisor: alimentada por turnero_tv_log.
Responde de un vistazo «¿la pantalla está llamando bien?» sin
tener que consultar la base a mano. -->
<div class="card border-0 shadow-sm mb-3" id="card-voz-tv" style="display:none">
<div class="card-body py-3">
<h6 class="mb-2"><i class="fas fa-volume-up text-primary me-1"></i>
Voz del televisor — últimos 7 días</h6>
<div class="d-flex gap-4 flex-wrap mb-2" id="voz-tv-resumen"></div>
<div id="voz-tv-incidentes" class="small"></div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div id="act-spinner" class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i>
</div>
<div id="act-wrap" class="d-none">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th style="width:145px">Fecha / Hora</th>
<th>Usuario</th>
<th style="width:110px">Módulo</th>
<th style="width:120px">Acción</th>
<th style="width:70px" class="text-center">Entidad</th>
<th>Detalle</th>
<th style="width:105px">IP</th>
</tr>
</thead>
<tbody id="act-tbody"></tbody>
</table>
</div>
<div id="act-empty" class="d-none text-center py-5 text-muted">
<i class="fas fa-history fa-2x mb-2 d-block opacity-25"></i>
Sin actividad para los filtros seleccionados
</div>
</div>
<div class="card-footer bg-white" id="act-footer"></div>
</div>
</div><!-- /container -->
</main>
<!-- Modal detalle -->
<div class="modal fade" id="modalDetalle" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-info-circle me-2 text-primary"></i>Detalle del evento</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<pre id="modal-detalle-pre" class="bg-light p-3 rounded small mb-0" style="white-space:pre-wrap;word-break:break-all;max-height:420px;overflow-y:auto"></pre>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const MODULO_COLOR = {
ordenes:'warning', domicilios:'success', pacientes:'primary',
enfermeras:'info', formularios:'secondary', asignaciones:'dark',
};
const ACCION_COLOR = {
crear:'success', editar:'primary', eliminar:'danger',
autorizar:'success', rechazar:'danger', asignar:'info',
enviar:'secondary', reprogramar:'warning', cambiar_estado:'primary',
};
let _actPag = 1;
let _actData = [];
let _debounce;
document.addEventListener('DOMContentLoaded', () => {
const hoy = new Date().toISOString().slice(0,10);
document.getElementById('act-desde').value = hoy.slice(0,7) + '-01';
document.getElementById('act-hasta').value = hoy;
['act-desde','act-hasta','act-modulo','act-accion'].forEach(id =>
document.getElementById(id).addEventListener('change', () => cargar(1))
);
document.getElementById('act-buscar').addEventListener('input', () => {
clearTimeout(_debounce);
_debounce = setTimeout(() => cargar(1), 400);
});
cargar(1);
});
async function cargar(pag = 1) {
_actPag = pag;
document.getElementById('act-spinner').classList.remove('d-none');
document.getElementById('act-wrap').classList.add('d-none');
document.getElementById('act-empty').classList.add('d-none');
const params = new URLSearchParams({
desde: document.getElementById('act-desde').value,
hasta: document.getElementById('act-hasta').value,
modulo: document.getElementById('act-modulo').value,
accion: document.getElementById('act-accion').value,
buscar: document.getElementById('act-buscar').value,
page: pag,
limit: 50,
});
try {
const r = await fetch(`api/lab/get_actividad.php?${params}`);
const d = await r.json();
document.getElementById('act-spinner').classList.add('d-none');
if (!d.data?.length) {
document.getElementById('act-empty').classList.remove('d-none');
document.getElementById('act-footer').innerHTML = '';
return;
}
_actData = d.data;
renderTabla(d.data);
renderPaginacion(d.total, d.paginas, pag);
} catch(e) {
document.getElementById('act-spinner').classList.add('d-none');
document.getElementById('act-empty').classList.remove('d-none');
}
}
function renderTabla(rows) {
document.getElementById('act-wrap').classList.remove('d-none');
document.getElementById('act-tbody').innerHTML = rows.map((a, i) => {
const det = a.detalle || '';
const detShort = det.length > 70 ? det.slice(0,70) + '…' : det;
const mColor = MODULO_COLOR[a.modulo] || 'secondary';
const aColor = ACCION_COLOR[a.accion] || 'secondary';
const usuario = esc(a.admin_nombre || a.admin_username || 'Sistema');
const sub = (a.admin_username && a.admin_nombre)
? `<div class="text-muted" style="font-size:.74rem">@${esc(a.admin_username)}</div>` : '';
return `<tr class="log-row">
<td class="text-muted text-nowrap">${formatFecha(a.created_at)}</td>
<td><span class="fw-semibold">${usuario}</span>${sub}</td>
<td><span class="badge bg-${mColor} badge-accion">${esc(a.modulo)}</span></td>
<td><span class="badge bg-${aColor} bg-opacity-75 badge-accion text-dark">${esc(a.accion)}</span></td>
<td class="text-center text-muted">${a.entidad_id ? '#' + a.entidad_id : '—'}</td>
<td class="detalle-cell" onclick="verDetalle(${i})" title="Click para ver detalle completo">${esc(detShort) || '<span class="text-muted">—</span>'}</td>
<td class="text-muted small">${esc(a.ip_address || '—')}</td>
</tr>`;
}).join('');
}
function renderPaginacion(total, paginas, pag) {
const el = document.getElementById('act-footer');
if (paginas <= 1) {
el.innerHTML = `<small class="text-muted">${total} evento(s)</small>`;
return;
}
const pages = [];
for (let p = Math.max(1, pag - 2); p <= Math.min(paginas, pag + 2); p++) pages.push(p);
el.innerHTML = `
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<small class="text-muted">${total} evento(s) — página ${pag} de ${paginas}</small>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" onclick="cargar(1)" ${pag===1?'disabled':''}>«</button>
<button class="btn btn-outline-secondary" onclick="cargar(${pag-1})" ${pag===1?'disabled':''}></button>
${pages.map(p => `<button class="btn ${p===pag?'btn-primary':'btn-outline-secondary'}" onclick="cargar(${p})">${p}</button>`).join('')}
<button class="btn btn-outline-secondary" onclick="cargar(${pag+1})" ${pag>=paginas?'disabled':''}></button>
<button class="btn btn-outline-secondary" onclick="cargar(${paginas})" ${pag>=paginas?'disabled':''}>»</button>
</div>
</div>`;
}
function verDetalle(i) {
const a = _actData[i];
let det = a.detalle || '(sin detalle)';
try { det = JSON.stringify(JSON.parse(det), null, 2); } catch(e) {}
document.getElementById('modal-detalle-pre').textContent =
`Módulo: ${a.modulo}\nAcción: ${a.accion}\nEntidad: ${a.entidad_id ? '#' + a.entidad_id : '—'}\nUsuario: ${a.admin_nombre || a.admin_username || 'Sistema'}\nFecha: ${a.created_at}\nIP: ${a.ip_address || '—'}\n\nDetalle:\n${det}`;
new bootstrap.Modal(document.getElementById('modalDetalle')).show();
}
// ── Salud de la voz del televisor ────────────────────────────
const VOZ_EVENTO_TXT = {
mudo: ['🔇 No sonó', 'text-danger', 'El llamado salió sin voz: ni el reintento habló'],
sin_start: ['⚠️ Arrancó con reintento','text-warning','La voz elegida no arrancó; habló la del navegador'],
error: ['⚠️ Error del motor', 'text-warning', 'El motor de voz falló a mitad; se reintentó'],
};
async function cargarVozTV() {
try {
const r = await fetch('api/lab/get_tv_log.php');
const d = await r.json();
if (!d.ok) return;
// Sin datos todavía (recién desplegado): la tarjeta no aparece
if (!d.anuncios_7d && !d.incidentes.length) return;
document.getElementById('card-voz-tv').style.display = '';
const pct = d.anuncios_7d ? Math.round(d.completos_7d / d.anuncios_7d * 100) : 0;
document.getElementById('voz-tv-resumen').innerHTML = `
<div><span class="fw-bold fs-5">${d.anuncios_7d}</span>
<span class="text-muted small">llamados con voz</span></div>
<div><span class="fw-bold fs-5 ${pct >= 97 ? 'text-success' : 'text-warning'}">${pct}%</span>
<span class="text-muted small">hablados completos</span></div>
<div><span class="fw-bold fs-5 ${d.mudos_7d ? 'text-danger' : 'text-success'}">${d.mudos_7d}</span>
<span class="text-muted small">salieron mudos</span></div>
<div><span class="fw-bold fs-5 ${d.avisos_7d ? 'text-warning' : 'text-success'}">${d.avisos_7d}</span>
<span class="text-muted small">se recuperaron con reintento</span></div>`;
const inc = document.getElementById('voz-tv-incidentes');
if (!d.incidentes.length) {
inc.innerHTML = '<span class="text-success"><i class="fas fa-check-circle me-1"></i>Sin fallos registrados</span>';
return;
}
inc.innerHTML = '<div class="text-muted mb-1">Últimos fallos:</div>' +
d.incidentes.slice(0, 10).map(i => {
const [txt, cls, ayuda] = VOZ_EVENTO_TXT[i.evento] || [i.evento, '', ''];
return `<div class="py-1 border-bottom" title="${ayuda}">
<span class="text-muted">${(i.cuando || '').slice(0, 16)}</span>
· turno <b>${i.turno || '—'}</b>
· <span class="${cls}">${txt}</span>
${i.voz ? `<span class="text-muted"> (voz: ${i.voz})</span>` : ''}
</div>`;
}).join('');
} catch (_) {}
}
cargarVozTV();
async function exportarCSV() {
const params = new URLSearchParams({
desde: document.getElementById('act-desde').value,
hasta: document.getElementById('act-hasta').value,
modulo: document.getElementById('act-modulo').value,
accion: document.getElementById('act-accion').value,
buscar: document.getElementById('act-buscar').value,
page: 1,
limit: 2000,
});
const r = await fetch(`api/lab/get_actividad.php?${params}`);
const d = await r.json();
if (!d.data?.length) { alert('Sin datos para exportar'); return; }
const cols = ['id','created_at','admin_nombre','admin_username','modulo','accion','entidad_id','detalle','ip_address'];
const rows = d.data.map(a =>
cols.map(c => '"' + String(a[c] ?? '').replace(/"/g, '""') + '"').join(',')
);
const csv = '\uFEFF' + [cols.join(','), ...rows].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `actividad_${new Date().toISOString().slice(0,10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
function limpiar() {
const hoy = new Date().toISOString().slice(0,10);
document.getElementById('act-desde').value = hoy.slice(0,7) + '-01';
document.getElementById('act-hasta').value = hoy;
document.getElementById('act-modulo').value = '';
document.getElementById('act-accion').value = '';
document.getElementById('act-buscar').value = '';
cargar(1);
}
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
const formatFecha = s => s ? new Date(s).toLocaleString('es-CO', {day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '—';
</script>
</body>
</html>