- 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>
390 lines
14 KiB
PHP
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>
|