up
This commit is contained in:
@@ -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'),
|
||||
]);
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user