turnero: sala de espera entre muestras (toma progresiva)

- DB: columna muestra_espera_at en turnero_turnos
- Panel 'Esperando siguiente muestra' en la cola de lugar.php
  · Muestra pacientes en espera con countdown a siguiente_toma_at
  · Botón 'Llamar' los devuelve a la cola activa
- Botón 'Mandar a espera' en la ficha (visible cuando toma progresiva activa)
  · Desaparece de la cola principal, queda en sección de espera
- get_cola.php: excluye muestra_espera_at IS NOT NULL de cola y activo
  · Agrega array en_espera_muestra en la respuesta
- Nuevas APIs: poner_en_espera_muestra.php, llamar_desde_espera.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-27 22:41:34 -05:00
co-authored by Claude Sonnet 4.6
parent e634eb8932
commit 4012ae97be
4 changed files with 181 additions and 10 deletions
+33 -10
View File
@@ -83,7 +83,8 @@ if ($area === 'recepcion') {
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
WHERE t.sesion_id = ?
AND t.estado = 'en_espera_lugar'
AND t.lugar_destino_id IN ($inLugares))
AND t.lugar_destino_id IN ($inLugares)
AND t.muestra_espera_at IS NULL)
UNION ALL
(SELECT $cols
FROM turnero_turnos t
@@ -91,7 +92,8 @@ if ($area === 'recepcion') {
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
WHERE t.sesion_id = ?
AND t.estado = 'en_servicio'
AND t.lugar_destino_id = ?)
AND t.lugar_destino_id = ?
AND t.muestra_espera_at IS NULL)
) AS cola_union
ORDER BY solo_muestras DESC, orden_peso ASC, creado_at ASC"
);
@@ -126,7 +128,7 @@ if (!empty($cola)) {
$campoLlamado = $area === 'recepcion' ? 'llamado_recepcion_at' : 'llamado_lugar_at';
$estadoActivo = $area === 'recepcion' ? "'en_recepcion'" : "'en_servicio'";
// Activo: para lugar filtrar SOLO por esta estación (ya fue asignada al llamar)
$filtroActivo = $area === 'lugar' ? 'AND t.lugar_destino_id = ?' : '';
$filtroActivo = $area === 'lugar' ? 'AND t.lugar_destino_id = ? AND t.muestra_espera_at IS NULL' : '';
$bindActivo = $area === 'lugar' ? [$sesionId, $lugarId] : [$sesionId];
$stmt = $pdo->prepare(
@@ -173,12 +175,33 @@ $stmt = $pdo->prepare(
$stmt->execute([$sesionId]);
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
// ── Pacientes en espera entre muestras (toma progresiva) ─────
$enEsperaMuestra = [];
if ($area === 'lugar') {
$stmtEsp = $pdo->prepare("
SELECT t.id, t.paciente_nombre, ts.numero_orden, t.muestra_espera_at,
(SELECT tc.siguiente_toma_at
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
WHERE tc.turno_id = t.id AND tc.estado = 'en_progreso'
ORDER BY tc.siguiente_toma_at ASC LIMIT 1) AS siguiente_toma_at
FROM turnero_turnos t
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
WHERE t.sesion_id = ? AND t.lugar_destino_id IN ($inLugares)
AND t.muestra_espera_at IS NOT NULL AND t.fin_lugar_at IS NULL
ORDER BY t.muestra_espera_at ASC
");
$stmtEsp->execute([$sesionId]);
$enEsperaMuestra = $stmtEsp->fetchAll(PDO::FETCH_ASSOC);
}
jsonOk([
'sesion_id' => $sesionId,
'area' => $area,
'lugar_id' => $lugarId,
'activo' => $activo,
'cola' => $cola,
'stats' => $stats,
'timestamp' => date('c'),
'sesion_id' => $sesionId,
'area' => $area,
'lugar_id' => $lugarId,
'activo' => $activo,
'cola' => $cola,
'en_espera_muestra' => $enEsperaMuestra,
'stats' => $stats,
'timestamp' => date('c'),
]);
@@ -0,0 +1,19 @@
<?php
/**
* POST llamar_desde_espera.php
* {turno_id} → devuelve el paciente a la cola activa para la siguiente muestra.
* SET muestra_espera_at = NULL.
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('POST');
$input = inputJson();
$turnoId = (int)($input['turno_id'] ?? 0);
if (!$turnoId) jsonError('turno_id requerido');
db()->prepare(
"UPDATE turnero_turnos SET muestra_espera_at = NULL WHERE id = ? AND muestra_espera_at IS NOT NULL"
)->execute([$turnoId]);
jsonOk([], 'Paciente de vuelta en cola');
@@ -0,0 +1,20 @@
<?php
/**
* POST poner_en_espera_muestra.php
* {turno_id} → mueve el paciente a la sala de espera entre muestras.
* SET muestra_espera_at = NOW(). El turno queda en_servicio pero sale de la cola activa.
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('POST');
$input = inputJson();
$turnoId = (int)($input['turno_id'] ?? 0);
if (!$turnoId) jsonError('turno_id requerido');
db()->prepare(
"UPDATE turnero_turnos SET muestra_espera_at = NOW()
WHERE id = ? AND estado = 'en_servicio' AND muestra_espera_at IS NULL"
)->execute([$turnoId]);
jsonOk([], 'Paciente enviado a sala de espera');
+109
View File
@@ -201,6 +201,33 @@ foreach ($lugares as $_el) {
.esp-pac-orden { font-weight: 700; font-family: monospace; color: var(--esp-gc); font-size: .7rem; flex-shrink: 0; }
.esp-pac-hora { margin-left: auto; font-size: .68rem; color: #94a3b8; flex-shrink: 0; }
/* ── Sala de espera entre muestras ── */
#espera-muestras-panel { flex-shrink: 0; border-bottom: 2px solid #fde68a; background: #fffbeb; }
.espera-m-hdr {
display: flex; align-items: center; gap: .4rem;
padding: .4rem .75rem .25rem; font-size: .72rem; font-weight: 700; color: #92400e;
}
.espera-m-count {
background: #f97316; color: #fff; border-radius: 99px; padding: 0 6px; font-size: .65rem;
}
.espera-m-row {
display: flex; align-items: center; gap: .5rem;
padding: .22rem .75rem; font-size: .78rem; color: #1e293b;
border-top: 1px solid #fde68a;
}
.espera-m-orden { font-weight: 700; font-family: monospace; color: #f97316; font-size: .7rem; flex-shrink: 0; }
.espera-m-cd { font-size: .71rem; font-weight: 700; flex-shrink: 0; white-space: nowrap; }
.espera-m-cd.ok { color: #16a34a; }
.espera-m-cd.warn { color: #d97706; }
.espera-m-cd.crit { color: #dc2626; animation: parpadeo-alerta .8s infinite; }
.btn-llamar-espera {
margin-left: auto; flex-shrink: 0;
padding: .18rem .55rem; font-size: .72rem; font-weight: 700;
border: 1.5px solid #22c55e; color: #16a34a; background: #fff;
border-radius: 7px; cursor: pointer; transition: background .1s;
}
.btn-llamar-espera:hover { background: #f0fdf4; }
.cola-section-sep {
font-size: .65rem; text-transform: uppercase; letter-spacing: .06em; font-weight: 700;
color: #94a3b8; padding: .3rem .4rem .15rem; margin-top: .25rem;
@@ -676,6 +703,8 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<div id="esp-panel" style="display:none"></div>
<?php endif; ?>
<div id="espera-muestras-panel" style="display:none"></div>
<div class="cola-items" id="cola-items">
<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>
@@ -868,6 +897,10 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<button class="btn-accion-sec warning d-none" id="btn-devolver" onclick="devolverRecepcion()">
<i class="fas fa-reply"></i>A recepción
</button>
<button class="btn-accion-sec d-none" id="btn-espera" onclick="_ponerEnEspera()"
style="color:#c2410c;border-color:#fed7aa">
<i class="fas fa-hourglass-half"></i>Mandar a espera
</button>
<button class="btn-accion-sec danger" id="btn-ausente" onclick="marcarAusente()">
<i class="fas fa-user-slash"></i>Ausente
</button>
@@ -1087,6 +1120,7 @@ function tiempoEspera(iso) {
}
function renderCola(snap) {
_renderEsperaMuestras(snap.en_espera_muestra || []);
const items = snap.cola || [];
const enEspera = items.filter(t => t.estado === 'en_espera_lugar');
const enServ = items.filter(t => t.estado === 'en_servicio');
@@ -1532,6 +1566,12 @@ function renderConsentimientos(lista) {
};
}
});
// Mostrar botón "Mandar a espera" si hay toma progresiva activa y estado en_servicio
const btnEsp = document.getElementById('btn-espera');
if (btnEsp) {
const hasProg = Object.keys(_tomaTimers).length > 0;
btnEsp.classList.toggle('d-none', !(hasProg && turnoActivo?.estado === 'en_servicio'));
}
listEl.innerHTML = lista.map(c => {
const ya = ['firmado','rechazado'].includes(c.estado);
@@ -2028,18 +2068,22 @@ function actualizarEstadoBadge(estado) {
const btnRellamar = document.getElementById('btn-rellamar');
const btnDev = document.getElementById('btn-devolver');
const btnEsp = document.getElementById('btn-espera');
if (estado === 'en_espera_lugar') {
btnIni.classList.remove('d-none'); btnIni.disabled = false;
btnFin.classList.add('d-none');
btnReg.classList.add('d-none');
btnRellamar.classList.add('d-none');
btnDev.classList.remove('d-none');
if (btnEsp) btnEsp.classList.add('d-none');
} else if (estado === 'en_servicio') {
btnIni.classList.add('d-none');
btnFin.classList.remove('d-none');
btnReg.classList.remove('d-none');
btnRellamar.classList.remove('d-none');
btnDev.classList.remove('d-none');
// btn-espera: solo si hay toma progresiva activa (sincronizado desde renderConsentimientos)
if (btnEsp) btnEsp.classList.add('d-none');
}
}
@@ -2396,6 +2440,71 @@ function mostrarToast(msg, type = 'info', duration = 2500) {
function mostrarError(msg) { mostrarToast(msg, 'error', 4000); }
function mostrarLlamando(cod) { mostrarToast('Llamando turno ' + cod + '…', 'info', 2000); }
function _renderEsperaMuestras(items) {
const panel = document.getElementById('espera-muestras-panel');
if (!panel) return;
if (!items.length) { panel.style.display = 'none'; panel.innerHTML = ''; return; }
const ahora = Date.now();
let html = `<div class="espera-m-hdr">
<i class="fas fa-hourglass-half" style="color:#f97316"></i>
Esperando siguiente muestra
<span class="espera-m-count">${items.length}</span>
</div>`;
for (const p of items) {
let cdHtml = '';
if (p.siguiente_toma_at) {
const ms = new Date(p.siguiente_toma_at.replace(' ', 'T')).getTime() - ahora;
const mins = Math.ceil(ms / 60000);
if (ms <= 0) {
cdHtml = `<span class="espera-m-cd crit">¡Lista ya!</span>`;
} else if (mins <= 5) {
cdHtml = `<span class="espera-m-cd warn">en ${mins} min</span>`;
} else {
cdHtml = `<span class="espera-m-cd ok">en ${mins} min</span>`;
}
}
html += `<div class="espera-m-row">
<span class="espera-m-orden">#${escHtml(String(p.numero_orden))}</span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p.paciente_nombre)}</span>
${cdHtml}
<button class="btn-llamar-espera" onclick="_llamarDesdeEspera(${(int = p.id, int)})">
<i class="fas fa-bell fa-xs me-1"></i>Llamar
</button>
</div>`;
}
panel.innerHTML = html;
panel.style.display = '';
}
async function _ponerEnEspera() {
if (!turnoActivo) return;
const btn = document.getElementById('btn-espera');
if (btn) btn.disabled = true;
try {
const res = await fetch(API + 'poner_en_espera_muestra.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id }),
});
const j = await res.json();
if (!j.ok) { mostrarError(j.error); return; }
resetFicha();
cargarCola();
} catch (e) { mostrarError(e.message); }
finally { if (btn) btn.disabled = false; }
}
async function _llamarDesdeEspera(turnoId) {
try {
const res = await fetch(API + 'llamar_desde_espera.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoId }),
});
const j = await res.json();
if (!j.ok) mostrarError(j.error);
else cargarCola();
} catch (e) { mostrarError(e.message); }
}
async function _cargarEspecialidades() {
if (!ESP_LUGARES.length) return;
try {