Caso real resuelto con la bitácora (23/08, 12:11): tres rellamados de E001, el del medio no sonó. No fue la voz: la pestaña que anunciaba estaba en segundo plano —era el Mac de pruebas, no el televisor; lo delata la voz Paulina— y Chrome frena los relojes de las pestañas ocultas a ~1 tic por minuto. En ese hueco el llamado intermedio fue invisible: cuando la pestaña volvió a consultar, el servidor ya solo mostraba el estado más reciente. Para que ese estado deje de confundirse con un fallo de la voz, la pantalla registra ahora el evento 'lag' cuando detecta un hueco de más de 10 s entre tics o cuando pasa a segundo plano, y la tarjeta de Actividad lo traduce como «Pantalla congelada» con su explicación. En el televisor real —siempre visible, en primer plano— este estado no debe aparecer; si aparece, es la señal de que alguien lo dejó oculto o el equipo se suspendió. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
382 lines
19 KiB
PHP
382 lines
19 KiB
PHP
<?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ó'],
|
||
lag: ['⏸ Pantalla congelada', 'text-warning', 'La pestaña estuvo oculta o el equipo suspendido: en ese lapso no ve llamados. En el televisor debe estar siempre visible'],
|
||
};
|
||
|
||
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 => ({'<':'<','>':'>','&':'&','"':'"'}[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>
|