Files
whatsapp/modules/turnero/views/display_recepcion.php
Lizandro GuarnizoandClaude Sonnet 4.6 d24b234e30 fix(display): corregir turnos que no aparecen en pantalla TV
- sse_turno.php: sleep(2)→sleep(1) para detectar notificarSSE en máx 1s
- display*.php: visibilitychange + focus → fetch inmediato al despertar pantalla
- display*.php: Wake Lock API para evitar throttling del browser en TVs
- display.php: SSE open → cargarSnapshot() inmediato al reconectar

Causa raíz: browsers throttlean setInterval a >1min en tabs sin foco;
las TVs inactivas dejaban de recibir actualizaciones aunque el servidor
las enviara correctamente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:47:13 -05:00

390 lines
14 KiB
PHP

<?php
/**
* display_recepcion.php — Pantalla TV por escritorio de Recepción
* Acceso: erp.php?m=turnero&v=display_recepcion&desk_id=X
* Sin autenticación (pantalla pública de TV).
*/
$_deskId = (int)($_GET['desk_id'] ?? 0);
$_deskNom = 'Recepción';
$_labNom = 'Sistema de Turnos';
$_labLogo = '';
$_labColor = '#1565c0';
try {
$_pdo = Database::getInstance()->getConnection();
// Nombre del escritorio
if ($_deskId) {
$_row = $_pdo->prepare("SELECT nombre FROM turnero_lugares WHERE id = ? AND tipo='recepcion' AND activo=1");
$_row->execute([$_deskId]);
$_found = $_row->fetchColumn();
if ($_found) $_deskNom = $_found;
}
// Config visual del lab
$_cfg = $_pdo->query(
"SELECT clave, valor FROM lab_config WHERE clave IN ('empresa_nombre','doc_logo_base64','doc_color')"
)->fetchAll(PDO::FETCH_KEY_PAIR);
$_labNom = htmlspecialchars($_cfg['empresa_nombre'] ?? 'Sistema de Turnos');
$_labLogo = $_cfg['doc_logo_base64'] ?? '';
if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $_cfg['doc_color'] ?? '')) {
$_labColor = $_cfg['doc_color'];
}
} catch (\Throwable $_) {}
$_deskNomSafe = htmlspecialchars($_deskNom);
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $_deskNomSafe ?> — Pantalla TV</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%; width: 100%;
overflow: hidden;
font-family: 'Segoe UI', system-ui, sans-serif;
background: #f0f4f8; color: #1e293b;
}
:root {
--brand: <?= $_labColor ?>;
--prio-color: <?= $_labColor ?>;
}
/* Layout */
.pg-wrap {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh;
}
/* Header */
.pg-header {
display: flex; align-items: center; justify-content: space-between;
padding: .7rem 2rem;
background: var(--brand); gap: 1rem; flex-shrink: 0;
}
.header-brand { display: flex; align-items: center; gap: .8rem; flex: 1; min-width: 0; }
.header-brand img.logo {
height: 44px; max-width: 120px; object-fit: contain;
background: rgba(255,255,255,.12); border-radius: 8px; padding: 4px 6px;
}
.lab-nombre {
font-size: clamp(.9rem, 2vw, 1.3rem); font-weight: 800; color: #fff;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.lab-sub {
font-size: .68rem; color: rgba(255,255,255,.7); font-weight: 500;
text-transform: uppercase; letter-spacing: 1.5px; display: block; margin-top: 1px;
}
.reloj {
font-size: clamp(1rem, 2.5vw, 1.6rem); font-weight: 800; color: #fff;
font-variant-numeric: tabular-nums; letter-spacing: 1px;
}
.btn-sonido {
background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.3);
color: #fff; border-radius: 8px; padding: 5px 12px; font-size: .78rem; cursor: pointer;
}
/* Cuerpo central */
.pg-body {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 2rem; overflow: hidden;
position: relative;
}
/* Tarjeta principal del turno */
.turno-card {
width: min(600px, 88vw);
background: #fff;
border-radius: 24px;
box-shadow: 0 8px 40px rgba(0,0,0,.12);
border-top: 6px solid var(--prio-color);
padding: 3rem 3.5rem;
text-align: center;
position: relative;
transition: border-color .4s, background .4s;
}
.turno-card::before {
content: '';
position: absolute; inset: 0; border-radius: 24px;
border: 3px solid var(--prio-color);
opacity: .3; pointer-events: none;
}
/* Etiqueta destino */
.lbl-destino {
font-size: clamp(.85rem, 2vw, 1.1rem);
font-weight: 600; color: #64748b;
text-transform: uppercase; letter-spacing: 2px;
margin-bottom: 1.2rem;
}
/* Ring de animación */
.ring {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 0; height: 0;
border-radius: 50%;
pointer-events: none; z-index: 0;
background: transparent;
}
.ring.animar {
animation: ringPulse 1.4s ease-out forwards;
background: var(--prio-color);
}
@keyframes ringPulse {
0% { width: 0; height: 0; opacity: .5; }
100% { width: 120%; height: 120%; opacity: 0; margin: -60% 0 0 -60%; }
}
/* Código grande */
.codigo-num {
font-size: clamp(5rem, 18vw, 12rem);
font-weight: 900;
line-height: 1;
color: var(--prio-color);
letter-spacing: -4px;
font-variant-numeric: tabular-nums;
position: relative; z-index: 1;
transition: color .4s;
}
.codigo-num.vacio { color: #cbd5e1; }
/* Nombre paciente */
.pac-nombre {
font-size: clamp(1rem, 2.5vw, 1.6rem);
font-weight: 600; color: #334155;
margin-top: .5rem; min-height: 1.8em;
position: relative; z-index: 1;
}
/* Chip prioridad */
.prio-chip {
display: inline-flex; align-items: center; gap: .4rem;
background: #f1f5f9; color: #94a3b8;
border-radius: 50px; padding: .4rem 1rem;
font-size: .85rem; font-weight: 600;
margin-top: 1.2rem; transition: background .4s, color .4s;
position: relative; z-index: 1;
}
/* Footer */
.pg-footer {
display: flex; align-items: center; justify-content: center; gap: 2.5rem;
padding: .6rem 2rem;
background: #1e293b; flex-shrink: 0;
}
.stat-item { text-align: center; }
.stat-item .val {
font-size: clamp(.9rem, 2vw, 1.3rem); font-weight: 800; color: #f1f5f9;
}
.stat-item .lbl { font-size: .65rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 1px; }
.stat-sep { width: 1px; height: 28px; background: rgba(255,255,255,.12); }
/* Sin escritorio */
.no-desk {
text-align: center; color: #64748b; padding: 3rem;
}
.no-desk i { font-size: 3rem; color: #cbd5e1; margin-bottom: 1rem; }
</style>
</head>
<body onclick="activarSonido()">
<div class="pg-wrap">
<!-- Header -->
<header class="pg-header">
<div class="header-brand">
<?php if ($_labLogo): ?>
<img class="logo" src="<?= $_labLogo ?>" alt="Logo">
<?php endif; ?>
<div>
<div class="lab-nombre"><?= $_labNom ?></div>
<span class="lab-sub"><?= $_deskNomSafe ?></span>
</div>
</div>
<div style="display:flex;align-items:center;gap:.75rem">
<button class="btn-sonido" id="btn-sonido">
<i class="fas fa-volume-mute"></i> Sonido
</button>
<div class="reloj" id="reloj">--:--:--</div>
</div>
</header>
<!-- Cuerpo -->
<main class="pg-body">
<?php if (!$_deskId): ?>
<div class="no-desk">
<i class="fas fa-exclamation-circle d-block"></i>
<p class="fw-semibold">No se especificó un escritorio de recepción.</p>
<small>Usa <code>?desk_id=X</code> en la URL.</small>
</div>
<?php else: ?>
<div class="turno-card" id="turno-card">
<div class="ring" id="ring-turno"></div>
<div class="lbl-destino">Pase a <?= $_deskNomSafe ?></div>
<div class="codigo-num vacio" id="cod-turno">—</div>
<div class="pac-nombre" id="pac-turno"></div>
<div class="prio-chip" id="chip-turno">
<i class="fas fa-hourglass-half"></i> En espera
</div>
</div>
<?php endif; ?>
</main>
<!-- Footer stats -->
<footer class="pg-footer">
<div class="stat-item">
<div class="val" id="st-espera">—</div>
<div class="lbl">En espera</div>
</div>
<div class="stat-sep"></div>
<div class="stat-item">
<div class="val" id="st-atencion">—</div>
<div class="lbl">En atención</div>
</div>
<div class="stat-sep"></div>
<div class="stat-item">
<div class="val" id="st-finalizados">—</div>
<div class="lbl">Finalizados</div>
</div>
</footer>
</div>
<script>
const BASE_API = '<?= BASE_URL ?>modules/turnero/api/';
const DESK_ID = <?= $_deskId ?: 'null' ?>;
const DESK_NOM = <?= json_encode($_deskNom) ?>;
let sonidoActivo = false;
let lastClave = null; // codigo|llamado_at
// ── Reloj ─────────────────────────────────────────────────────
function tick() {
const d = new Date();
document.getElementById('reloj').textContent =
String(d.getHours()).padStart(2,'0') + ':' +
String(d.getMinutes()).padStart(2,'0') + ':' +
String(d.getSeconds()).padStart(2,'0');
}
tick(); setInterval(tick, 1000);
// ── Activar TTS ───────────────────────────────────────────────
function activarSonido() {
if (sonidoActivo) return;
sonidoActivo = true;
document.getElementById('btn-sonido').innerHTML = '<i class="fas fa-volume-up"></i> Sonido';
document.getElementById('btn-sonido').style.background = 'rgba(255,255,255,.35)';
}
document.getElementById('btn-sonido').addEventListener('click', e => {
e.stopPropagation();
activarSonido();
});
// ── TTS ───────────────────────────────────────────────────────
function anunciarTurno(codigo) {
if (!sonidoActivo || !('speechSynthesis' in window)) return;
setTimeout(() => {
window.speechSynthesis.cancel();
const letras = codigo.split('').join(' ');
const utt = new SpeechSynthesisUtterance(`Turno ${letras}, pase a ${DESK_NOM}`);
utt.lang = 'es-CO'; utt.rate = 0.85; utt.pitch = 1.05; utt.volume = 1;
window.speechSynthesis.speak(utt);
}, 700);
}
// ── Escape HTML ───────────────────────────────────────────────
function esc(s) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(s));
return d.innerHTML;
}
// ── Render ────────────────────────────────────────────────────
function render(snap) {
const t = snap.turno;
const card = document.getElementById('turno-card');
const ring = document.getElementById('ring-turno');
const cod = document.getElementById('cod-turno');
const pac = document.getElementById('pac-turno');
const chip = document.getElementById('chip-turno');
if (!card) return;
if (t) {
const color = t.prioridad_color || '#1565c0';
const clave = t.codigo + '|' + (t.llamado_at || '');
// Actualizar colores
card.style.setProperty('--prio-color', color);
card.style.background = '#fff';
card.style.borderTopColor = color;
chip.style.background = color + '22';
chip.style.color = color;
cod.textContent = t.codigo;
cod.className = 'codigo-num';
cod.style.color = '';
pac.textContent = t.paciente_nombre || '';
chip.innerHTML = `<i class='fas fa-ticket-alt'></i> ${esc(t.prioridad_codigo)} — ${esc(t.prioridad_nombre)}`;
if (clave !== lastClave) {
anunciarTurno(t.codigo);
ring.classList.remove('animar');
void ring.offsetWidth;
ring.classList.add('animar');
lastClave = clave;
}
} else {
card.style.removeProperty('--prio-color');
card.style.background = '#fff';
card.style.borderTopColor = '#e2e8f0';
chip.style.background = '#f1f5f9';
chip.style.color = '#94a3b8';
cod.textContent = '—';
cod.className = 'codigo-num vacio';
pac.textContent = '';
chip.innerHTML = '<i class="fas fa-hourglass-half"></i> En espera';
lastClave = null;
}
// Stats footer
const s = snap.stats || {};
const el = id => document.getElementById(id);
el('st-espera').textContent = s.en_espera || 0;
el('st-atencion').textContent = s.en_atencion || 0;
el('st-finalizados').textContent= s.finalizados || 0;
}
// ── Polling ───────────────────────────────────────────────────
async function cargar() {
if (!DESK_ID) return;
try {
const res = await fetch(BASE_API + 'get_display_recepcion.php?desk_id=' + DESK_ID, { cache: 'no-store' });
const json = await res.json();
if (json.ok) render(json);
} catch (_) {}
}
cargar();
setInterval(cargar, 2000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) cargar(); });
window.addEventListener('focus', cargar);
if ('wakeLock' in navigator) {
async function pedirWakeLock() {
try { await navigator.wakeLock.request('screen'); } catch (_) {}
}
pedirWakeLock();
document.addEventListener('visibilitychange', () => { if (!document.hidden) pedirWakeLock(); });
}
</script>
</body>
</html>