This commit is contained in:
Lizandro Guarnizo
2026-04-21 19:48:30 -05:00
parent 63da89c31a
commit c934125f2f
7 changed files with 878 additions and 212 deletions
@@ -0,0 +1,65 @@
<?php
/**
* GET /modules/turnero/api/get_display_recepcion.php?desk_id=X
* Devuelve el turno activo en_recepcion para un escritorio específico.
* Acceso público (pantalla TV).
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
$deskId = (int)($_GET['desk_id'] ?? 0);
if (!$deskId) jsonError('desk_id requerido.', 400);
$pdo = db();
// Verificar que el escritorio existe y es de tipo recepcion
$stmtDesk = $pdo->prepare(
"SELECT id, nombre FROM turnero_lugares WHERE id = ? AND tipo = 'recepcion' AND activo = 1"
);
$stmtDesk->execute([$deskId]);
$desk = $stmtDesk->fetch(PDO::FETCH_ASSOC);
if (!$desk) jsonError('Escritorio no encontrado.', 404);
$sesionId = obtenerOCrearSesionHoy();
// Turno activo en este escritorio (el más reciente llamado_recepcion_at)
$stmtT = $pdo->prepare(
"SELECT t.id,
t.codigo,
t.numero,
t.estado,
t.paciente_nombre,
t.llamado_recepcion_at AS llamado_at,
p.codigo AS prioridad_codigo,
p.nombre AS prioridad_nombre,
p.color AS prioridad_color
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ?
AND t.estado = 'en_recepcion'
AND t.recepcion_desk_id = ?
ORDER BY t.llamado_recepcion_at DESC
LIMIT 1"
);
$stmtT->execute([$sesionId, $deskId]);
$turno = $stmtT->fetch(PDO::FETCH_ASSOC) ?: null;
// Stats básicos para el footer
$stmtStats = $pdo->prepare(
"SELECT SUM(estado = 'espera') AS en_espera,
SUM(estado IN ('en_recepcion','en_espera_lugar','en_servicio')) AS en_atencion,
SUM(estado = 'finalizado') AS finalizados
FROM turnero_turnos WHERE sesion_id = ?"
);
$stmtStats->execute([$sesionId]);
$stats = $stmtStats->fetch(PDO::FETCH_ASSOC);
jsonOk([
'desk_id' => (int)$desk['id'],
'desk_nombre'=> $desk['nombre'],
'turno' => $turno,
'stats' => $stats,
'timestamp' => date('c'),
]);
+6 -4
View File
@@ -16,6 +16,7 @@ requireTurnero();
$datos = inputJson();
$area = $datos['area'] ?? '';
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
$deskId = isset($datos['desk_id']) ? (int) $datos['desk_id'] : null;
// ── Validación ────────────────────────────────────────────────
if (!in_array($area, ['recepcion', 'lugar'], true)) {
@@ -42,12 +43,13 @@ try {
$stmt = $pdo->prepare(
'UPDATE turnero_turnos
SET estado = "en_recepcion",
llamado_recepcion_at = NOW(),
inicio_recepcion_at = NOW(),
atendido_recepcion_por = ?
llamado_recepcion_at = NOW(),
inicio_recepcion_at = NOW(),
atendido_recepcion_por = ?,
recepcion_desk_id = COALESCE(?, recepcion_desk_id)
WHERE id = ? AND estado = "espera"'
);
$stmt->execute([adminId(), $turno['id']]);
$stmt->execute([adminId(), $deskId, $turno['id']]);
if ($stmt->rowCount() === 0) {
// Otro proceso lo tomó primero (race condition)
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* POST /modules/turnero/api/rellamar.php
* Re-llama un turno que ya está en_recepcion: actualiza llamado_recepcion_at = NOW()
* para que la pantalla TV detecte el cambio y vuelva a anunciar/animar.
*
* Body JSON:
* turno_id int requerido
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$datos = inputJson();
$turnoId = (int)($datos['turno_id'] ?? 0);
$deskId = isset($datos['desk_id']) ? (int)$datos['desk_id'] : null;
if (!$turnoId) jsonError('turno_id es obligatorio.', 400);
$pdo = db();
$stmt = $pdo->prepare(
"UPDATE turnero_turnos
SET llamado_recepcion_at = NOW(),
recepcion_desk_id = COALESCE(?, recepcion_desk_id)
WHERE id = ? AND estado = 'en_recepcion'"
);
$stmt->execute([$deskId, $turnoId]);
if ($stmt->rowCount() === 0) {
jsonError('El turno no está en estado en_recepcion o no existe.', 404);
}
// Devolver el turno actualizado para que recepcion.php abra la ficha
$stmtT = $pdo->prepare(
"SELECT t.*,
p.codigo AS prioridad_codigo,
p.nombre AS prioridad_nombre,
p.color AS prioridad_color
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.id = ?"
);
$stmtT->execute([$turnoId]);
$turno = $stmtT->fetch(PDO::FETCH_ASSOC);
jsonOk(['turno' => $turno]);
+36 -8
View File
@@ -3,6 +3,41 @@
* Descriptor del módulo Turnero — Oleada 1
* Sistema de Turnero Inteligente con gestión de consentimientos informados.
*/
// ── Links estáticos base ──────────────────────────────────────
$_trLinks = [
['name' => 'Dashboard', 'icon' => 'fas fa-tachometer-alt', 'route' => '/erp.php?m=turnero&v=dashboard'],
['name' => 'Configuración', 'icon' => 'fas fa-sliders-h', 'route' => '/erp.php?m=turnero&v=configuracion'],
['name' => 'Kiosko', 'icon' => 'fas fa-desktop', 'route' => '/erp.php?m=turnero&v=kiosko'],
['name' => 'Pantalla TV Global', 'icon' => 'fas fa-th-large', 'route' => '/erp.php?m=turnero&v=display_global'],
];
// ── Links dinámicos: un enlace por escritorio de Recepción ────
try {
$_trPdo = Database::getInstance()->getConnection();
$_trDesks = $_trPdo->query(
"SELECT id, nombre FROM turnero_lugares
WHERE activo = 1 AND tipo = 'recepcion'
ORDER BY sort_order ASC"
)->fetchAll(PDO::FETCH_ASSOC);
if (!empty($_trDesks)) {
foreach ($_trDesks as $_d) {
$_trLinks[] = [
'name' => $_d['nombre'],
'icon' => 'fas fa-concierge-bell',
'route' => '/erp.php?m=turnero&v=recepcion&desk_id=' . (int)$_d['id'],
];
}
} else {
// Sin escritorios configurados: enlace genérico
$_trLinks[] = ['name' => 'Recepción', 'icon' => 'fas fa-concierge-bell', 'route' => '/erp.php?m=turnero&v=recepcion'];
}
} catch (\Throwable $_) {
// BD no disponible todavía (instalación inicial)
$_trLinks[] = ['name' => 'Recepción', 'icon' => 'fas fa-concierge-bell', 'route' => '/erp.php?m=turnero&v=recepcion'];
}
return [
'slug' => 'turnero',
'name' => 'Turnero',
@@ -13,12 +48,5 @@ return [
'sort_order' => 40,
'oleada' => 1,
'description' => 'Sistema de turnos presenciales en recepción con prioridades, consentimientos y pantallas TV',
'links' => [
['name' => 'Dashboard', 'icon' => 'fas fa-tachometer-alt', 'route' => '/erp.php?m=turnero&v=dashboard'],
['name' => 'Recepción', 'icon' => 'fas fa-concierge-bell', 'route' => '/erp.php?m=turnero&v=recepcion'],
['name' => 'Configuración', 'icon' => 'fas fa-sliders-h', 'route' => '/erp.php?m=turnero&v=configuracion'],
['name' => 'Kiosko', 'icon' => 'fas fa-desktop', 'route' => '/erp.php?m=turnero&v=kiosko'],
['name' => 'Pantalla TV', 'icon' => 'fas fa-tv', 'route' => '/erp.php?m=turnero&v=display'],
['name' => 'Pantalla TV Global', 'icon' => 'fas fa-th-large', 'route' => '/erp.php?m=turnero&v=display_global'],
],
'links' => $_trLinks,
];
+310 -192
View File
@@ -231,12 +231,126 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')
.stat-item .lbl { font-size: .6rem; color: rgba(255,255,255,.65); text-transform: uppercase; letter-spacing: 1px; }
.stat-sep { width: 1px; height: 26px; background: rgba(255,255,255,.2); }
</style>
<style>
/* ══ DARK THEME OVERRIDE ═════════════════════════════════════ */
:root {
--bg: #070e1d;
--glass: rgba(255,255,255,.04);
--border:rgba(255,255,255,.08);
--muted: #4a5568;
}
html, body {
background: var(--bg);
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
color: #e2e8f0;
}
/* Header modernize */
.pg-header::before {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(135deg,rgba(0,0,0,.32) 0%,rgba(0,0,0,0) 55%,rgba(255,255,255,.07) 100%);
}
.pg-header { position: relative; }
.pg-header::after {
content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg,transparent,rgba(255,255,255,.28),transparent);
}
.btn-son {
background: rgba(255,255,255,.12); border: 1px solid rgba(255,255,255,.2);
color: rgba(255,255,255,.82); border-radius: 8px; padding: 4px 11px;
font-size: .68rem; font-weight: 600; cursor: pointer; font-family: inherit;
transition: background .2s;
}
.btn-son.on { background: rgba(34,197,94,.2); border-color: rgba(34,197,94,.35); color: #86efac; }
.live-dot { width: 7px; height: 7px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; animation: pdot 2s ease-in-out infinite; }
@keyframes pdot { 0%,100%{opacity:1} 50%{opacity:.35} }
/* Override body/pg-* for dark */
.pg-body { background: var(--bg); }
.v-split { background: linear-gradient(180deg,transparent,var(--border) 12%,var(--border) 88%,transparent); width: 1px; }
.area-col { background: var(--bg); display: flex; flex-direction: column; overflow: hidden; }
.col-hdr { display: flex; align-items: center; gap: .55rem; padding: .38rem 1.1rem; font-size: .62rem; font-weight: 800; text-transform: uppercase; letter-spacing: 2.5px; color: rgba(255,255,255,.88); flex-shrink: 0; }
.col-hdr.rec { background: linear-gradient(90deg,#1e1b4b,#1e3a8a); border-bottom: 1px solid rgba(96,165,250,.18); }
.col-hdr.mue { background: linear-gradient(90deg,#052e16,#064e3b); border-bottom: 1px solid rgba(52,211,153,.18); }
.slot-grid { display: grid; grid-auto-rows: 1fr; height: 100%; overflow: hidden; }
.slot { display: flex; flex-direction: column; border-bottom: 1px solid var(--border); position: relative; overflow: hidden; }
.slot:last-child { border-bottom: none; }
.slot-hdr { display: flex; align-items: center; gap: .4rem; padding: .27rem .9rem; font-size: .59rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1.8px; flex-shrink: 0; z-index: 1; position: relative; }
.slot-hdr.rec { color: #93c5fd; border-bottom: 1px solid rgba(59,130,246,.1); background: rgba(30,58,138,.22); }
.slot-hdr.mue { color: #6ee7b7; border-bottom: 1px solid rgba(16,185,129,.1); background: rgba(6,78,59,.22); }
.slot-hdr .hdot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.slot-hdr.rec .hdot { background: #60a5fa; box-shadow: 0 0 5px #60a5fa88; }
.slot-hdr.mue .hdot { background: #34d399; box-shadow: 0 0 5px #34d39988; }
.slot-body { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: .8rem 1rem; position: relative; overflow: hidden; }
.slot-body::after { content: ''; position: absolute; inset: 0; pointer-events: none; opacity: 0; transition: opacity .6s; background: radial-gradient(ellipse 80% 60% at 50% 60%, var(--pc,transparent) 0%, transparent 70%); }
.slot-body.lit::after { opacity: .07; }
.slot-body::before { content: ''; position: absolute; left: 0; top: 18%; bottom: 18%; width: 3px; border-radius: 0 3px 3px 0; background: var(--pc,transparent); opacity: 0; transition: opacity .5s,background .5s; }
.slot-body.lit::before { opacity: 1; }
.lbl-dest { font-size: clamp(.5rem,.95vw,.66rem); font-weight: 700; text-transform: uppercase; letter-spacing: 3px; color: var(--muted); margin-bottom: .1rem; z-index: 1; position: relative; transition: color .4s; }
.slot-body.lit .lbl-dest { color: var(--pc); opacity: .7; }
.big-code { font-size: clamp(2.5rem,8.5vw,8rem); font-weight: 900; line-height: 1; color: var(--muted); letter-spacing: -2px; font-variant-numeric: tabular-nums; z-index: 1; position: relative; transition: color .45s; }
.slot-body.lit .big-code { color: var(--pc); }
.pac-name { font-size: clamp(.68rem,1.5vw,1.05rem); font-weight: 700; color: rgba(255,255,255,.6); margin-top: .2rem; text-align: center; min-height: 1.3em; z-index: 1; position: relative; }
.prio-badge { display: inline-flex; align-items: center; gap: .3rem; padding: .27rem .82rem; border-radius: 99px; font-size: clamp(.54rem,.92vw,.72rem); font-weight: 700; margin-top: .42rem; border: 1px solid rgba(255,255,255,.08); background: rgba(255,255,255,.04); color: var(--muted); transition: background .4s,color .4s,border-color .4s; z-index: 1; position: relative; }
.ring-pop-el { position: absolute; top: 50%; left: 50%; width: 10px; height: 10px; border-radius: 50%; transform: translate(-50%,-50%); pointer-events: none; z-index: 0; }
.ring-pop-el.do-pop { animation: r-pop .85s ease-out forwards; }
@keyframes r-pop { 0%{transform:translate(-50%,-50%) scale(1);background:var(--pc,var(--brand));opacity:.38} 100%{transform:translate(-50%,-50%) scale(50);background:var(--pc,var(--brand));opacity:0} }
/* Cola dark */
.pg-cola { background: rgba(255,255,255,.022); border-top: 1px solid var(--border); }
.cola-lbl { color: var(--brand); }
.cola-sep { width: 1px; height: 12px; background: var(--border); flex-shrink: 0; }
.cola-chip { background: rgba(255,255,255,.04); font-size: .68rem; }
.cola-chip .cdot { width: 5px; height: 5px; border-radius: 50%; }
.cola-empty { font-size: .68rem; color: var(--muted); }
/* Footer dark */
.pg-footer { background: rgba(0,0,0,.45); border-top: 1px solid var(--border); }
.stat-item .val { font-size: clamp(.78rem,1.6vw,1rem); font-variant-numeric: tabular-nums; }
.stat-sep { background: var(--border); height: 19px; }
/* ══ ANNOUNCEMENT OVERLAY ════════════════════════════════════ */
#ann-overlay {
position: fixed; inset: 0; z-index: 9999;
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .3s ease;
}
#ann-overlay.visible { opacity: 1; pointer-events: all; }
.ann-bg { position: absolute; inset: 0; background: rgba(4,8,20,.92); backdrop-filter: blur(18px); transition: background .4s; }
.ann-card {
position: relative; z-index: 1; width: min(600px,88vw);
background: rgba(255,255,255,.055); border: 1px solid rgba(255,255,255,.11);
border-radius: 28px; padding: 2.8rem 3.5rem 2.4rem; text-align: center;
box-shadow: 0 30px 90px rgba(0,0,0,.7), 0 0 70px var(--ac,#1565c0)44;
transform: scale(.88) translateY(14px);
transition: transform .4s cubic-bezier(.34,1.5,.64,1);
}
#ann-overlay.visible .ann-card { transform: scale(1) translateY(0); }
.ann-card::before { content: ''; position: absolute; left: 0; top: 12%; bottom: 12%; width: 4px; border-radius: 0 4px 4px 0; background: var(--ac,var(--brand)); box-shadow: 0 0 28px var(--ac,var(--brand)); }
.ann-ring { position: absolute; top: 50%; left: 50%; width: 10px; height: 10px; border-radius: 50%; transform: translate(-50%,-50%); pointer-events: none; }
.ann-ring.pop { animation: ann-pop 1.1s ease-out forwards; }
@keyframes ann-pop { 0%{transform:translate(-50%,-50%) scale(1);background:var(--ac,var(--brand));opacity:.4} 100%{transform:translate(-50%,-50%) scale(55);background:var(--ac,var(--brand));opacity:0} }
.ann-label { font-size: clamp(.68rem,1.5vw,.88rem); font-weight: 700; text-transform: uppercase; letter-spacing: 3.5px; color: rgba(255,255,255,.42); margin-bottom: .5rem; }
.ann-code { font-size: clamp(5rem,17vw,11rem); font-weight: 900; line-height: 1; letter-spacing: -4px; font-variant-numeric: tabular-nums; color: var(--ac,var(--brand)); text-shadow: 0 0 90px var(--ac,var(--brand))55; position: relative; z-index: 1; }
.ann-pac { font-size: clamp(.85rem,2.1vw,1.25rem); font-weight: 700; color: rgba(255,255,255,.72); margin-top: .55rem; min-height: 1.5em; }
.ann-chip { display: inline-flex; align-items: center; gap: .35rem; padding: .34rem 1.1rem; border-radius: 99px; font-size: clamp(.6rem,1.05vw,.8rem); font-weight: 700; margin-top: .7rem; border: 1px solid; }
.ann-progress { width: 100%; height: 3px; background: rgba(255,255,255,.07); border-radius: 99px; margin-top: 1.7rem; overflow: hidden; }
.ann-bar { height: 100%; width: 100%; border-radius: 99px; background: var(--ac,var(--brand)); box-shadow: 0 0 12px var(--ac,var(--brand)); }
</style>
</head>
<body>
<div class="pg-grid">
<!-- ══ Announcement overlay ══════════════════════════════════════ -->
<div id="ann-overlay">
<div class="ann-bg" id="ann-bg"></div>
<div class="ann-card">
<div class="ann-ring" id="ann-ring"></div>
<div class="ann-label" id="ann-label">Pase a Recepción</div>
<div class="ann-code" id="ann-code">A01</div>
<div class="ann-pac" id="ann-pac"></div>
<div class="ann-chip" id="ann-chip"></div>
<div class="ann-progress"><div class="ann-bar" id="ann-bar"></div></div>
</div>
</div>
<!-- ══ Main layout ════════════════════════════════════════════════ -->
<div class="pg-wrap">
<!-- Header -->
<header class="pg-header">
<div class="header-brand">
<?php if ($_labLogo): ?>
@@ -248,86 +362,58 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')
</div>
</div>
<div class="header-right">
<button class="btn-sonido" id="btn-sonido" onclick="activarSonido()">
<div class="live-dot" title="En línea"></div>
<button class="btn-son" id="btn-sonido" onclick="event.stopPropagation();activarSonido()">
<i class="fas fa-volume-mute"></i> Sonido
</button>
<div class="reloj" id="reloj">--:--:--</div>
</div>
</header>
<!-- Cuerpo: Recepción | Muestras -->
<div class="pg-body" id="pg-body">
<!-- Panel Recepción (dinámico: N escritorios) -->
<div class="area-panel" id="panel-recepcion">
<div class="area-header recepcion">
<i class="fas fa-door-open"></i> Recepción
</div>
<div class="lugares-grid" id="rec-grid">
<!-- Se genera en JS según escritorios configurados -->
</div>
<div class="pg-body">
<div class="area-col">
<div class="col-hdr rec"><i class="fas fa-door-open"></i> Recepción</div>
<div class="slot-grid" id="rec-grid"></div>
</div>
<!-- Divisor -->
<div class="area-divider"></div>
<!-- Panel Muestras (múltiples lugares) -->
<div class="area-panel">
<div class="area-header muestras">
<i class="fas fa-vials"></i> Toma de Muestras
</div>
<div class="lugares-grid" id="lugares-grid">
<!-- Se genera en JS -->
</div>
<div class="v-split"></div>
<div class="area-col">
<div class="col-hdr mue"><i class="fas fa-vials"></i> Toma de Muestras</div>
<div class="slot-grid" id="lugares-grid"></div>
</div>
</div>
<!-- Cola en espera -->
<div class="pg-cola" id="pg-cola">
<span class="cola-lbl"><i class="fas fa-list-ol me-1"></i>EN ESPERA</span>
<span class="cola-vacia-msg" id="cola-vacia">Cola vacía</span>
<span class="cola-lbl"><i class="fas fa-list-ol" style="margin-right:.3rem"></i>EN ESPERA</span>
<div class="cola-sep"></div>
<span class="cola-empty" id="cola-vacia">Cola vacía</span>
</div>
<!-- Footer -->
<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-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-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">Atendidos hoy</div>
</div>
<div class="stat-item"><div class="val" id="st-finalizados">—</div><div class="lbl">Atendidos</div></div>
<div class="stat-sep"></div>
<div class="stat-item">
<div class="val" id="st-total">—</div>
<div class="lbl">Total del día</div>
</div>
<div class="stat-item"><div class="val" id="st-total">—</div><div class="lbl">Total día</div></div>
<div class="stat-sep"></div>
<div class="stat-item">
<div class="val" id="st-tiempo">—</div>
<div class="lbl">Tiempo prom.</div>
</div>
<div class="stat-item"><div class="val" id="st-tiempo">—</div><div class="lbl">T. promedio</div></div>
</footer>
</div>
<script>
const BASE_API = '<?= BASE_URL ?>modules/turnero/api/';
// ── Reloj ──────────────────────────────────────────────────────
function actualizarReloj() {
function tick() {
const d = new Date();
document.getElementById('reloj').textContent =
new Date().toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
String(d.getHours()).padStart(2,'0') + ':' +
String(d.getMinutes()).padStart(2,'0') + ':' +
String(d.getSeconds()).padStart(2,'0');
}
actualizarReloj();
setInterval(actualizarReloj, 1000);
tick(); setInterval(tick, 1000);
// ── Audio ──────────────────────────────────────────────────────
let audioCtx = null, sonidoActivo = false;
@@ -343,9 +429,7 @@ function activarSonido() {
const btn = document.getElementById('btn-sonido');
if (btn) {
btn.innerHTML = '<i class="fas fa-volume-up"></i> Sonido ON';
btn.style.background = 'rgba(34,197,94,.25)';
btn.style.borderColor = 'rgba(34,197,94,.5)';
btn.onclick = null; btn.style.cursor = 'default';
btn.classList.add('on');
setTimeout(playBeep, 100);
}
}
@@ -383,173 +467,207 @@ function anunciarTurno(codigo, destino) {
}
// ── Estado previo ──────────────────────────────────────────────
const lastCodRecepcion = {}; // { desk_id: codigo }
const lastCodLugar = {}; // { lugar_id: codigo }
// ── Announcement queue ────────────────────────────────────────
const annQueue = [];
let isAnnouncing = false;
// ── Escape HTML ────────────────────────────────────────────────
function esc(str) {
function queueAnnouncement(item) {
if (annQueue.some(q => q._key === item._key)) return;
annQueue.push(item);
if (!isAnnouncing) processQueue();
}
function processQueue() {
if (annQueue.length === 0) { isAnnouncing = false; return; }
isAnnouncing = true;
showAnnouncement(annQueue.shift());
}
function showAnnouncement({ codigo, destino, paciente, prio_codigo, prio_nombre, color }) {
const ov = document.getElementById('ann-overlay');
const bg = document.getElementById('ann-bg');
const ring = document.getElementById('ann-ring');
const code = document.getElementById('ann-code');
const lbl = document.getElementById('ann-label');
const pac = document.getElementById('ann-pac');
const chip = document.getElementById('ann-chip');
const bar = document.getElementById('ann-bar');
const c = color || '#1565c0';
code.textContent = codigo;
lbl.textContent = 'Pase a ' + destino;
pac.textContent = paciente || '';
chip.innerHTML = '<i class="fas fa-ticket-alt"></i> ' + esc(prio_codigo||'') + ' — ' + esc(prio_nombre||'');
ov.style.setProperty('--ac', c);
bg.style.background = 'radial-gradient(ellipse at 50% 45%, ' + c + '2a 0%, rgba(4,8,20,.94) 65%)';
chip.style.color = c;
chip.style.borderColor = c + '55';
chip.style.background = c + '18';
ov.classList.add('visible');
ring.classList.remove('pop');
void ring.offsetWidth;
ring.classList.add('pop');
bar.style.transition = 'none';
bar.style.width = '100%';
requestAnimationFrame(() => requestAnimationFrame(() => {
bar.style.transition = 'width 1.65s linear';
bar.style.width = '0%';
}));
anunciarTurno(codigo, destino);
setTimeout(() => {
ov.classList.remove('visible');
setTimeout(processQueue, 370);
}, 1950);
}
// ── State ───────────────────────────────────────────────
const lastRec = {};
const lastLugar = {};
// ── Helpers ───────────────────────────────────────────────────
function esc(s) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(str));
d.appendChild(document.createTextNode(String(s ?? '')));
return d.innerHTML;
}
// ── Aplicar color de prioridad a un panel ─────────────────────
function aplicarColor(panelEl, ringEl, chipEl, color) {
panelEl.style.setProperty('--prio-color', color);
panelEl.style.background = color + '12';
chipEl.style.background = color + '22';
chipEl.style.color = color;
if (ringEl) ringEl.style.setProperty('--prio-color', color);
}
function limpiarColor(panelEl, ringEl, chipEl) {
panelEl.style.removeProperty('--prio-color');
panelEl.style.background = '#fff';
chipEl.style.background = '#f1f5f9';
chipEl.style.color = '#94a3b8';
if (ringEl) ringEl.style.removeProperty('--prio-color');
function buildSlot(id, nombre, turno, tipo) {
const has = !!turno;
const c = has ? (turno.prioridad_color || '#1565c0') : null;
const pcSt = c ? '--pc:' + c + ';' : '';
const cls = has ? 'slot-body lit' : 'slot-body';
const hCls = tipo === 'rec' ? 'slot-hdr rec' : 'slot-hdr mue';
const icon = tipo === 'rec' ? 'fas fa-concierge-bell' : 'fas fa-flask';
const cod = has ? esc(turno.codigo) : '—';
const pac = has ? esc(turno.paciente_nombre || '') : '';
const chip = has
? "<i class='fas fa-ticket-alt'></i> " + esc(turno.prioridad_codigo) + ' — ' + esc(turno.prioridad_nombre)
: "<i class='fas fa-hourglass-half'></i> En espera";
const chipSt = has && c ? 'style="color:' + c + ';border-color:' + c + '44;background:' + c + '15"' : '';
return '<div class="slot" id="slot-' + tipo + '-' + id + '">'
+ '<div class="' + hCls + '"><span class="hdot"></span><i class="' + icon + '"></i> ' + esc(nombre) + '</div>'
+ '<div class="' + cls + '" id="body-' + tipo + '-' + id + '" style="' + pcSt + '">'
+ '<div class="ring-pop-el" id="ring-' + tipo + '-' + id + '"></div>'
+ '<div class="lbl-dest">' + esc(nombre) + '</div>'
+ '<div class="big-code" id="cod-' + tipo + '-' + id + '">' + cod + '</div>'
+ '<div class="pac-name" id="pac-' + tipo + '-' + id + '">' + pac + '</div>'
+ '<div class="prio-badge" id="chip-' + tipo + '-' + id + '" ' + chipSt + '>' + chip + '</div>'
+ '</div></div>';
}
// ── Render snapshot global ─────────────────────────────────────
function updateSlot(id, turno, tipo) {
const body = document.getElementById('body-' + tipo + '-' + id);
const cod = document.getElementById('cod-' + tipo + '-' + id);
const pac = document.getElementById('pac-' + tipo + '-' + id);
const chip = document.getElementById('chip-' + tipo + '-' + id);
if (!body) return false;
const c = turno ? (turno.prioridad_color || '#1565c0') : null;
if (turno) {
body.className = 'slot-body lit';
body.style.setProperty('--pc', c);
cod.textContent = turno.codigo;
pac.textContent = turno.paciente_nombre || '';
chip.innerHTML = "<i class='fas fa-ticket-alt'></i> " + esc(turno.prioridad_codigo) + ' — ' + esc(turno.prioridad_nombre);
chip.style.color = c; chip.style.borderColor = c + '44'; chip.style.background = c + '15';
} else {
body.className = 'slot-body';
body.style.removeProperty('--pc');
cod.textContent = '—';
pac.textContent = '';
chip.innerHTML = "<i class='fas fa-hourglass-half'></i> En espera";
chip.style.color = chip.style.borderColor = chip.style.background = '';
}
return true;
}
// ── Render ────────────────────────────────────────────────────
function renderSnapshot(snap) {
// ── Recepción (múltiples escritorios) ───────────────────
const recList = snap.activos_recepcion || [];
const mueList = snap.lugares || [];
const recGrid = document.getElementById('rec-grid');
const mueGrid = document.getElementById('lugares-grid');
recGrid.innerHTML = recList.map(d => {
const t = d.turno;
const color = t ? (t.prioridad_color || '#1565c0') : null;
const colorStyle = color ? `style="--prio-color:${color};background:${color}12"` : '';
const codStyle = color ? '' : 'style="color:#cbd5e1"';
const chipStyle = color
? `style="background:${color}22;color:${color}"`
: 'style="background:#f1f5f9;color:#94a3b8"';
const chipContent = t
? `<i class='fas fa-ticket-alt'></i> ${esc(t.prioridad_codigo)} — ${esc(t.prioridad_nombre)}`
: `<i class='fas fa-hourglass-half'></i> En espera`;
const codText = t ? esc(t.codigo) : '—';
const pacText = t ? esc(t.paciente_nombre || '') : '';
return `
<div class="lugar-sub" id="sub-rec-${d.desk_id}">
<div class="area-header" style="background:#1e40af;font-size:.7rem;padding:.35rem 1rem">
<i class="fas fa-door-open"></i> ${esc(d.desk_nombre)}
</div>
<div class="area-turno" id="area-rec-${d.desk_id}" ${colorStyle}>
<div class="ring" id="ring-rec-${d.desk_id}"></div>
<div class="lbl-destino">Pase a ${esc(d.desk_nombre)}</div>
<div class="codigo-num" id="cod-rec-${d.desk_id}" ${codStyle}>${codText}</div>
<div class="pac-nombre">${pacText}</div>
<div class="prio-chip" id="chip-rec-${d.desk_id}" ${chipStyle}>${chipContent}</div>
</div>
</div>`;
}).join('');
// Sonido/animación por desk
// Recepción
const curRecIds = [...recGrid.querySelectorAll('.slot')].map(el => +el.id.replace('slot-rec-',''));
const newRecIds = recList.map(d => d.desk_id);
if (curRecIds.length !== newRecIds.length || newRecIds.some(id => !curRecIds.includes(id))) {
recGrid.innerHTML = recList.map(d => buildSlot(d.desk_id, d.desk_nombre, d.turno, 'rec')).join('');
} else {
recList.forEach(d => updateSlot(d.desk_id, d.turno, 'rec'));
}
recList.forEach(d => {
const t = d.turno;
if (!t) { lastCodRecepcion[d.desk_id] = null; return; }
if (t.codigo !== lastCodRecepcion[d.desk_id]) {
anunciarTurno(t.codigo, d.desk_nombre);
const ring = document.getElementById(`ring-rec-${d.desk_id}`);
if (ring) { ring.classList.remove('animar'); void ring.offsetWidth; ring.classList.add('animar'); }
lastCodRecepcion[d.desk_id] = t.codigo;
if (!t) { lastRec[d.desk_id] = null; return; }
const clave = t.codigo + '|' + (t.llamado_at || '');
if (clave !== lastRec[d.desk_id]) {
queueAnnouncement({ _key: 'rec-' + d.desk_id + '-' + clave, codigo: t.codigo, destino: d.desk_nombre, paciente: t.paciente_nombre, prio_codigo: t.prioridad_codigo, prio_nombre: t.prioridad_nombre, color: t.prioridad_color });
const ring = document.getElementById('ring-rec-' + d.desk_id);
if (ring) { ring.classList.remove('do-pop'); void ring.offsetWidth; ring.classList.add('do-pop'); }
lastRec[d.desk_id] = clave;
}
});
// ── Lugares (muestras) ──────────────────────────────────
const lugares = snap.lugares || [];
const grid = document.getElementById('lugares-grid');
// Construir HTML para todos los lugares
grid.innerHTML = lugares.map(l => {
const t = l.turno;
const color = t ? (t.prioridad_color || '#15803d') : null;
const colorStyle = color ? `style="--prio-color:${color};background:${color}12"` : '';
const codStyle = color ? '' : 'style="color:#cbd5e1"';
const chipStyle = color
? `style="background:${color}22;color:${color}"`
: 'style="background:#f1f5f9;color:#94a3b8"';
const chipContent = t
? `<i class='fas fa-ticket-alt'></i> ${esc(t.prioridad_codigo)} — ${esc(t.prioridad_nombre)}`
: `<i class='fas fa-hourglass-half'></i> En espera`;
const codText = t ? esc(t.codigo) : '—';
const pacText = t ? esc(t.paciente_nombre || '') : '';
const ringId = `ring-lugar-${l.lugar_id}`;
const panelId = `area-lugar-${l.lugar_id}`;
const codId = `cod-lugar-${l.lugar_id}`;
const chipId = `chip-lugar-${l.lugar_id}`;
return `
<div class="lugar-sub" id="sub-lugar-${l.lugar_id}">
<div class="area-header" style="background:#166534;font-size:.7rem;padding:.35rem 1rem">
<i class="fas fa-flask"></i> ${esc(l.lugar_nombre)}
</div>
<div class="area-turno" id="${panelId}" ${colorStyle}>
<div class="ring" id="${ringId}"></div>
<div class="lbl-destino">Pase a ${esc(l.lugar_nombre)}</div>
<div class="codigo-num" id="${codId}" ${codStyle}>${codText}</div>
<div class="pac-nombre">${pacText}</div>
<div class="prio-chip" id="${chipId}" ${chipStyle}>${chipContent}</div>
</div>
</div>`;
}).join('');
// Animaciones/sonido para lugares que cambiaron
lugares.forEach(l => {
const t = l.turno;
if (!t) { lastCodLugar[l.lugar_id] = null; return; }
if (t.codigo !== lastCodLugar[l.lugar_id]) {
anunciarTurno(t.codigo, l.lugar_nombre);
const ring = document.getElementById(`ring-lugar-${l.lugar_id}`);
if (ring) {
ring.classList.remove('animar');
void ring.offsetWidth;
ring.classList.add('animar');
}
lastCodLugar[l.lugar_id] = t.codigo;
}
});
// ── Cola en espera ───────────────────────────────────────
const cola = snap.cola || [];
const pgCola = document.getElementById('pg-cola');
const vaciaMg = document.getElementById('cola-vacia');
// Limpiar chips anteriores (conservar el label y el mensaje)
pgCola.querySelectorAll('.cola-chip').forEach(c => c.remove());
if (cola.length === 0) {
if (vaciaMg) vaciaMg.style.display = '';
// Muestras
const curMueIds = [...mueGrid.querySelectorAll('.slot')].map(el => +el.id.replace('slot-mue-',''));
const newMueIds = mueList.map(l => l.lugar_id);
if (curMueIds.length !== newMueIds.length || newMueIds.some(id => !curMueIds.includes(id))) {
mueGrid.innerHTML = mueList.map(l => buildSlot(l.lugar_id, l.lugar_nombre, l.turno, 'mue')).join('');
} else {
if (vaciaMg) vaciaMg.style.display = 'none';
mueList.forEach(l => updateSlot(l.lugar_id, l.turno, 'mue'));
}
mueList.forEach(l => {
const t = l.turno;
if (!t) { lastLugar[l.lugar_id] = null; return; }
const clave = t.codigo + '|' + (t.llamado_at || '');
if (clave !== lastLugar[l.lugar_id]) {
queueAnnouncement({ _key: 'mue-' + l.lugar_id + '-' + clave, codigo: t.codigo, destino: l.lugar_nombre, paciente: t.paciente_nombre, prio_codigo: t.prioridad_codigo, prio_nombre: t.prioridad_nombre, color: t.prioridad_color });
const ring = document.getElementById('ring-mue-' + l.lugar_id);
if (ring) { ring.classList.remove('do-pop'); void ring.offsetWidth; ring.classList.add('do-pop'); }
lastLugar[l.lugar_id] = clave;
}
});
// Cola
const cola = snap.cola || [];
const pgCola = document.getElementById('pg-cola');
pgCola.querySelectorAll('.cola-chip').forEach(c => c.remove());
const vaciaMg = document.getElementById('cola-vacia');
if (cola.length === 0) {
vaciaMg.style.display = '';
} else {
vaciaMg.style.display = 'none';
cola.forEach(t => {
const chip = document.createElement('span');
chip.className = 'cola-chip';
chip.style.color = t.prioridad_color || '#1565c0';
chip.style.borderColor = t.prioridad_color || '#1565c0';
chip.innerHTML = `<span style="width:8px;height:8px;border-radius:50%;background:${t.prioridad_color || '#1565c0'};display:inline-block"></span> ${esc(t.codigo)}`;
const c = t.prioridad_color || '#1565c0';
chip.className = 'cola-chip';
chip.style.color = c; chip.style.borderColor = c + '55'; chip.style.background = c + '12';
chip.innerHTML = '<span class="cdot" style="background:' + c + ';box-shadow:0 0 5px ' + c + '88"></span>' + esc(t.codigo);
pgCola.appendChild(chip);
});
}
// ── Stats ────────────────────────────────────────────────
// Stats
const s = snap.stats || {};
document.getElementById('st-espera').textContent = s.en_espera || 0;
document.getElementById('st-atencion').textContent = s.en_atencion || 0;
document.getElementById('st-finalizados').textContent = s.finalizados || 0;
document.getElementById('st-total').textContent = s.total || 0;
document.getElementById('st-espera').textContent = s.en_espera || 0;
document.getElementById('st-atencion').textContent = s.en_atencion || 0;
document.getElementById('st-finalizados').textContent = s.finalizados || 0;
document.getElementById('st-total').textContent = s.total || 0;
document.getElementById('st-tiempo').textContent = s.tiempo_promedio_atencion
? s.tiempo_promedio_atencion.substring(0, 5) : '—';
}
// ── Polling cada 4 s ──────────────────────────────────────────
// ── Polling ───────────────────────────────────────────────────
async function cargarSnapshot() {
try {
const res = await fetch(BASE_API + 'get_display_global.php', { cache: 'no-store' });
const json = await res.json();
if (json.ok) renderSnapshot(json);
} catch (_) {}
} catch(_) {}
}
cargarSnapshot();
+378
View File
@@ -0,0 +1,378 @@
<?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, 4000);
</script>
</body>
</html>
+36 -8
View File
@@ -27,11 +27,26 @@ try {
$cat = $ex['categoria'] ?: 'General';
$examenesAgrupados[$cat][] = $ex;
}
// Escritorio de recepción (opcional, vía ?desk_id=X)
$deskId = (int)($_GET['desk_id'] ?? 0);
$desk = null;
if ($deskId) {
$stmtDesk = $pdo->prepare(
"SELECT id, nombre FROM turnero_lugares WHERE id = ? AND tipo = 'recepcion' AND activo = 1"
);
$stmtDesk->execute([$deskId]);
$desk = $stmtDesk->fetch(PDO::FETCH_ASSOC) ?: null;
if (!$desk) { $deskId = 0; } // ID inválido → sin desk
}
} catch (\Throwable $e) {
$lugares = [];
$examenesAgrupados = [];
$deskId = 0;
$desk = null;
}
$deskNombre = $desk['nombre'] ?? null; // null = vista genérica
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Operador';
?>
<!DOCTYPE html>
@@ -39,7 +54,7 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recepción — Turnero</title>
<title><?= htmlspecialchars($deskNombre ?? 'Recepción') ?> — Turnero</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="<?= BASE_URL ?>assets/css/styles.css?v=12" rel="stylesheet">
@@ -169,17 +184,19 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<div class="d-flex align-items-center justify-content-between px-3 py-2 border-bottom bg-white"
style="position:sticky;top:0;z-index:100">
<div class="d-flex align-items-center gap-2">
<i class="fas fa-ticket-alt text-primary"></i>
<strong>Recepción</strong>
<i class="fas fa-concierge-bell text-primary"></i>
<strong><?= htmlspecialchars($deskNombre ?? 'Recepción') ?></strong>
<span id="badge-turno-activo" class="badge-turno-activo d-none">
Turno: <span id="badge-codigo">—</span>
</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted small"><?= htmlspecialchars($adminNombre) ?></span>
<a href="<?= BASE_URL ?>erp.php?m=turnero&v=lugar" class="btn btn-outline-secondary btn-sm" target="_blank">
<i class="fas fa-flask me-1"></i>Ver Lugar
<?php if ($deskId): ?>
<a href="<?= BASE_URL ?>erp.php?m=turnero&v=display_recepcion&desk_id=<?= $deskId ?>" class="btn btn-outline-info btn-sm" target="_blank">
<i class="fas fa-tv me-1"></i>Ver Pantalla
</a>
<?php endif; ?>
<button class="btn btn-primary btn-sm" id="btn-llamar" onclick="llamarSiguiente()">
<i class="fas fa-bell me-1"></i>Llamar siguiente
</button>
@@ -364,8 +381,9 @@ let solicitudActiva = null;
let consentimientos = [];
let pollingColaId = null;
const API = '<?= BASE_URL ?>modules/turnero/api/';
const API = '<?= BASE_URL ?>modules/turnero/api/';
const API_PAC = '<?= BASE_URL ?>api/lab/get_pacientes.php';
const DESK_ID = <?= $deskId ?: 'null' ?>;
// ── Arranque ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
@@ -421,7 +439,7 @@ async function llamarSiguiente() {
const res = await fetch(API + 'llamar_turno.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ area: 'recepcion' }),
body: JSON.stringify({ area: 'recepcion', desk_id: DESK_ID }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
@@ -454,8 +472,18 @@ async function seleccionarTurno(turnoId) {
// ── Llamar turno específico (cambia estado → en_recepcion) ─────────
async function llamarTurnoEspecifico(turnoId, estadoActual) {
// Si ya está en recepción, solo abrir ficha sin intentar re-transicionar
// Si ya está en recepción: re-llamar (actualiza llamado_at para que la pantalla TV reactive)
if (estadoActual === 'en_recepcion') {
try {
const res = await fetch(API + 'rellamar.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoId, desk_id: DESK_ID }),
});
const json = await res.json();
if (json.ok && json.turno) { abrirFicha(json.turno); return; }
} catch (_) {}
// Fallback: solo abrir ficha
seleccionarTurno(turnoId);
return;
}