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>
This commit is contained in:
Lizandro Guarnizo
2026-08-23 11:36:33 -05:00
co-authored by Claude Fable 5
parent a28ae82402
commit 26efe10ae6
2 changed files with 103 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* GET /api/lab/get_tv_log.php — Bitácora de la voz del televisor.
* Para la pantalla de Actividad: resumen de salud + últimos fallos, en llano.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
$pdo = db();
// Resumen de los últimos 7 días por evento
$resumen = $pdo->query(
"SELECT evento, COUNT(*) n FROM turnero_tv_log
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY evento"
)->fetchAll(PDO::FETCH_KEY_PAIR);
// Últimos incidentes (lo normal —start/end— no se lista: solo lo que falló)
$stmt = $pdo->query(
"SELECT evento, detalle, created_at FROM turnero_tv_log
WHERE evento IN ('mudo', 'sin_start', 'error')
ORDER BY id DESC LIMIT 30"
);
$incidentes = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$d = json_decode($r['detalle'] ?? '{}', true) ?: [];
$incidentes[] = [
'cuando' => $r['created_at'],
'evento' => $r['evento'],
'turno' => $d['cod'] ?? null,
'voz' => $d['voz'] ?? null,
];
}
$anuncios = (int)($resumen['start'] ?? 0);
$mudos = (int)($resumen['mudo'] ?? 0);
$avisos = (int)($resumen['sin_start'] ?? 0) + (int)($resumen['error'] ?? 0);
jsonOk([
'anuncios_7d' => $anuncios,
'completos_7d' => (int)($resumen['end'] ?? 0),
'mudos_7d' => $mudos,
'avisos_7d' => $avisos,
'incidentes' => $incidentes,
]);
+58
View File
@@ -111,6 +111,18 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</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">
@@ -277,6 +289,52 @@ function verDetalle(i) {
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,