This commit is contained in:
Lizandro Guarnizo
2026-06-16 14:51:46 -05:00
parent 56489a1557
commit eaa8a23af6
3 changed files with 141 additions and 101 deletions
+1 -1
View File
@@ -115,7 +115,7 @@ foreach ($lugares as $lugar) {
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ?
AND t.estado IN ('en_espera_lugar', 'en_servicio')
AND t.estado = 'en_servicio'
AND t.lugar_destino_id = ?
ORDER BY t.llamado_lugar_at DESC
LIMIT 1"
+29 -24
View File
@@ -566,7 +566,7 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
}
/* ── State ── */
let lastTurnoKey = null;
let lastSeenKeys = new Set(); // clave por turno+destino+llamado_at
let llamados = [];
function renderSnapshot(snap) {
@@ -576,48 +576,53 @@ function renderSnapshot(snap) {
const lblEl = document.getElementById('lugar-label');
const ringEl = document.getElementById('pulse-ring');
const turnos = [];
// Recopilar TODOS los turnos activos: recepción + lugares
const allTurnos = [];
(snap.activos_recepcion || []).forEach(d => {
if (d.turno) turnos.push({ ...d.turno, destino: d.desk_nombre, tipo: 'rec' });
if (d.turno) allTurnos.push({ ...d.turno, destino: d.desk_nombre });
});
(snap.lugares || []).forEach(l => {
if (l.turno) turnos.push({ ...l.turno, destino: l.lugar_nombre, tipo: 'mue' });
if (l.turno) allTurnos.push({ ...l.turno, destino: l.lugar_nombre });
});
if (turnos.length > 0) {
const t = turnos[0];
// Detectar turno nuevo en CUALQUIER estación y anunciar
const currentKeys = new Set();
let anuncioNuevo = false;
allTurnos.forEach(t => {
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
currentKeys.add(key);
if (!lastSeenKeys.has(key)) {
const c = t.prioridad_color || '<?= $_labColor ?>';
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: t.paciente_nombre, color: c });
llamados.unshift({ codigo: t.codigo, destino: t.destino, paciente: t.paciente_nombre, color: c });
if (llamados.length > 30) llamados.pop();
anuncioNuevo = true;
}
});
lastSeenKeys = currentKeys;
if (anuncioNuevo) renderLlamados();
// zona-activo: mostrar el turno llamado más recientemente
const sorted = allTurnos.slice().sort((a, b) => {
return new Date(b.llamado_at || 0) - new Date(a.llamado_at || 0);
});
if (sorted.length > 0) {
const t = sorted[0];
codEl.textContent = t.codigo;
lugEl.textContent = t.destino;
pacEl.textContent = t.paciente_nombre || '';
lblEl.textContent = 'Llamado a';
ringEl.classList.add('active');
const c = t.prioridad_color || '<?= $_labColor ?>';
codEl.style.color = c;
document.getElementById('zona-activo').style.setProperty('--brand', c);
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
if (key !== lastTurnoKey) {
queueAnnouncement({
_key: key,
codigo: t.codigo,
destino: t.destino,
paciente: t.paciente_nombre,
color: c
});
lastTurnoKey = key;
llamados.unshift({ codigo: t.codigo, destino: t.destino, paciente: t.paciente_nombre, color: c });
if (llamados.length > 30) llamados.pop();
renderLlamados();
}
} else {
codEl.textContent = '—';
lugEl.textContent = '—';
pacEl.textContent = '';
lblEl.textContent = 'Llamado a';
codEl.style.color = '<?= $_labColor ?>';
lastTurnoKey = null;
ringEl.classList.remove('active');
}
+111 -76
View File
@@ -176,6 +176,29 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
}
.selector-lugar-box h2 { font-size: 1.4rem; font-weight: 700; margin-bottom: 1.5rem; }
/* ── Toast ── */
.rec-toast {
position: fixed; bottom: 2rem; right: 2rem; z-index: 9999;
display: flex; align-items: center; gap: .7rem;
padding: .8rem 1.2rem; border-radius: 12px;
background: #fff; border: 1px solid #e2e8f0;
box-shadow: 0 8px 30px rgba(0,0,0,.12);
font-size: .9rem; font-weight: 500;
transform: translateY(120%); opacity: 0;
transition: transform .3s ease, opacity .3s ease;
pointer-events: none;
}
.rec-toast.show { transform: translateY(0); opacity: 1; }
.rec-toast.success { border-left: 4px solid #22c55e; }
.rec-toast.info { border-left: 4px solid #3b82f6; }
.rec-toast.warn { border-left: 4px solid #f59e0b; }
.rec-toast.error { border-left: 4px solid #ef4444; }
.rec-toast .ico { font-size: 1.2rem; }
.rec-toast.success .ico { color: #22c55e; }
.rec-toast.info .ico { color: #3b82f6; }
.rec-toast.warn .ico { color: #f59e0b; }
.rec-toast.error .ico { color: #ef4444; }
/* ── Indicador de bloqueo ── */
.bloqueo-banner {
background: #fef2f2; border: 1px solid #fca5a5; border-radius: 10px;
@@ -313,8 +336,8 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<!-- ── Barra de acciones ── -->
<div class="ficha-acciones">
<button class="btn btn-success" id="btn-iniciar" onclick="iniciarAtencion()">
<i class="fas fa-play me-1"></i>Iniciar atención
<button class="btn btn-primary" id="btn-iniciar" onclick="iniciarAtencion()">
<i class="fas fa-bell me-1"></i>Llamar
</button>
<button class="btn btn-primary d-none" id="btn-finalizar" onclick="finalizarAtencion()">
<i class="fas fa-flag-checkered me-1"></i>Finalizar
@@ -442,7 +465,7 @@ async function recuperarTurnoActivo() {
function confirmarLugar() {
const sel = document.getElementById('sel-lugar-init');
const id = parseInt(sel.value);
if (!id) return alert('Seleccione un lugar.');
if (!id) return mostrarError('Seleccione un lugar.');
lugarId = id;
const txt = sel.options[sel.selectedIndex].text;
document.getElementById('lbl-lugar-titulo').textContent = txt;
@@ -470,11 +493,16 @@ async function cargarCola() {
} catch (_) {}
}
let _colaMap = new Map(); // id → turno object
function renderCola(snap) {
const items = snap.cola || [];
const espera = items.filter(t => t.estado === 'en_espera_lugar');
document.getElementById('badge-count').textContent = espera.length;
// Guardar mapa para selección sin llamar
_colaMap = new Map(items.map(t => [t.id, t]));
const lista = document.getElementById('cola-items');
if (!items.length) {
lista.innerHTML = `<div class="cola-vacia">
@@ -484,39 +512,50 @@ function renderCola(snap) {
lista.innerHTML = items.map(t => {
const enServicio = t.estado === 'en_servicio';
const esActivo = turnoActivo?.id == t.id;
if (enServicio) {
return `
<div class="cola-card en-servicio-card ${esActivo ? 'activo' : ''}"
style="cursor:pointer" onclick="rellamarDesdeCard(${t.id})"
title="Re-llamar turno ${escHtml(t.codigo)}">
<div class="prio-dot" style="background:${t.prioridad_color}">${t.prioridad_codigo}</div>
<div class="turno-info">
<div class="cod">${escHtml(t.codigo)}</div>
<div class="pac">${escHtml(t.paciente_nombre || 'Paciente')}</div>
</div>
<div class="ms-auto d-flex flex-column align-items-center" style="gap:1px">
<i class="fas fa-bell text-success" style="font-size:.85rem"></i>
<span style="font-size:.58rem;color:#16a34a;font-weight:700">RE-LLAMAR</span>
</div>
</div>`;
}
const onclick = enServicio
? `seleccionarSinLlamar(${t.id})`
: `seleccionarSinLlamar(${t.id})`;
const indicador = enServicio
? `<div class="ms-auto d-flex flex-column align-items-center" style="gap:1px">
<i class="fas fa-stethoscope text-success" style="font-size:.85rem"></i>
<span style="font-size:.58rem;color:#16a34a;font-weight:700">EN SERVICIO</span>
</div>`
: `<i class="fas fa-chevron-right text-muted ms-auto" style="font-size:.7rem"></i>`;
return `
<div class="cola-card ${esActivo ? 'activo' : ''}"
style="cursor:pointer" onclick="llamarTurnoEspecifico(${t.id})"
title="Llamar turno ${escHtml(t.codigo)}">
<div class="cola-card ${enServicio ? 'en-servicio-card' : ''} ${esActivo ? 'activo' : ''}"
style="cursor:pointer" onclick="${onclick}"
title="${enServicio ? 'Ver turno en servicio' : `Ver turno ${escHtml(t.codigo)}`}">
<div class="prio-dot" style="background:${t.prioridad_color}">${t.prioridad_codigo}</div>
<div class="turno-info">
<div class="cod">${escHtml(t.codigo)}</div>
<div class="pac">${escHtml(t.paciente_nombre || 'Paciente')}</div>
</div>
<div class="ms-auto d-flex flex-column align-items-center" style="gap:1px">
<i class="fas fa-bell text-primary" style="font-size:.85rem"></i>
<span style="font-size:.58rem;color:#1d4ed8;font-weight:700">LLAMAR</span>
</div>
${indicador}
</div>`;
}).join('');
}
// Abre la ficha SIN llamar ni cambiar estado
async function seleccionarSinLlamar(turnoId) {
const t = _colaMap.get(turnoId);
if (!t) return;
turnoActivo = t;
document.getElementById('ficha-placeholder').classList.add('d-none');
document.getElementById('ficha-turno').classList.remove('d-none');
document.getElementById('ficha-codigo').textContent = t.codigo;
document.getElementById('ficha-prio-nombre').textContent = t.prioridad_nombre || '';
const dot = document.getElementById('ficha-prio-dot');
dot.textContent = t.prioridad_codigo || '—';
dot.style.background = t.prioridad_color || '#6366f1';
const badgeCod = document.getElementById('badge-turno-activo');
badgeCod.textContent = t.codigo;
badgeCod.classList.remove('d-none');
actualizarEstadoBadge(t.estado);
await cargarFichaSolicitud(t.id);
clearInterval(pollingConsentId);
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
}
async function rellamarDesdeCard(turnoId) {
const res = await fetch(API + 'llamar_turno.php', {
method: 'POST',
@@ -529,24 +568,22 @@ async function rellamarDesdeCard(turnoId) {
await abrirFicha(json.turno);
}
// ── Llamar siguiente ─────────────────────────────────────────
// ── Llamar siguiente: selecciona el primero en espera sin llamarlo
async function llamarSiguiente() {
await _llamar({});
const primero = [..._colaMap.values()].find(t => t.estado === 'en_espera_lugar');
if (!primero) { mostrarToast('Cola vacía — no hay turnos en espera.', 'warn', 3000); return; }
await seleccionarSinLlamar(primero.id);
}
async function llamarTurnoEspecifico(turnoId) {
if (turnoActivo?.id == turnoId) {
// Ya está activo — solo mostrar la ficha sin re-llamar API
await abrirFicha(turnoActivo);
return;
}
await _llamar({ turno_id: turnoId });
await seleccionarSinLlamar(turnoId);
}
async function _llamar(extra) {
if (!lugarId) { alert('Primero seleccione un lugar.'); return; }
if (!lugarId) { mostrarError('Primero seleccione un lugar.'); return; }
const btn = document.getElementById('btn-llamar');
btn.disabled = true;
mostrarToast('Consultando siguiente turno…', 'info', 1500);
try {
const res = await fetch(API + 'llamar_turno.php', {
method: 'POST',
@@ -554,13 +591,14 @@ async function _llamar(extra) {
body: JSON.stringify({ area: 'lugar', lugar_id: lugarId, ...extra }),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
if (!json.turno) { alert('Cola del lugar vacía.'); return; }
if (!json.ok) { mostrarError(json.error); return; }
if (!json.turno) { mostrarToast('Cola vacía — no hay turnos en espera.', 'warn', 3000); return; }
mostrarLlamando(json.turno.codigo);
anunciarTurno(json.turno.codigo);
await abrirFicha(json.turno);
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
mostrarError(err.message);
} finally {
btn.disabled = false;
}
@@ -722,10 +760,10 @@ async function reenviarConsentimiento(turnoId) {
body: JSON.stringify({ turno_id: turnoId }),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
if (!json.ok) { mostrarError(json.error); return; }
await actualizarConsentimientos(turnoId);
} catch (err) {
alert('Error: ' + err.message);
mostrarError(err.message);
}
}
@@ -763,35 +801,9 @@ document.addEventListener('DOMContentLoaded', () => {
// ── Acciones del turno ────────────────────────────────────────
async function iniciarAtencion() {
if (!turnoActivo) return;
if (hayPendientes) return; // botón ya deshabilitado, doble seguro
const btn = document.getElementById('btn-iniciar');
btn.disabled = true;
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'en_servicio' }),
});
const json = await res.json();
if (!json.ok) {
alert('Error: ' + json.error);
btn.disabled = hayPendientes;
return;
}
turnoActivo = json.turno;
actualizarEstadoBadge('en_servicio');
// Mostrar "Finalizar" y "Regresar", ocultar "Iniciar"
btn.classList.add('d-none');
document.getElementById('btn-finalizar').classList.remove('d-none');
document.getElementById('btn-regresar').classList.remove('d-none');
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
btn.disabled = false;
}
if (hayPendientes) return;
// Llamar via llamar_turno.php para que suene la campana y notifique el display
await _llamar({ turno_id: turnoActivo.id });
}
async function finalizarAtencion() {
@@ -824,11 +836,11 @@ async function regresarCola() {
}),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
if (!json.ok) { mostrarError(json.error); return; }
resetFicha();
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
mostrarError(err.message);
}
}
@@ -840,11 +852,11 @@ async function cambiarEstadoTurno(nuevoEstado) {
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: nuevoEstado }),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
if (!json.ok) { mostrarError(json.error); return; }
resetFicha();
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
mostrarError(err.message);
}
}
@@ -908,10 +920,10 @@ async function rellamarTurno() {
body: JSON.stringify({ area: 'lugar', lugar_id: lugarId, turno_id: turnoActivo.id }),
});
const json = await res.json();
if (!json.ok) alert('Error: ' + json.error);
else if (json.turno) anunciarTurno(json.turno.codigo);
if (!json.ok) mostrarError(json.error);
else if (json.turno) { mostrarLlamando(json.turno.codigo); anunciarTurno(json.turno.codigo); }
} catch (err) {
alert('Error: ' + err.message);
mostrarError(err.message);
} finally {
btn.disabled = false;
}
@@ -922,6 +934,29 @@ function escHtml(str) {
d.appendChild(document.createTextNode(String(str ?? '')));
return d.innerHTML;
}
// ── Toast (igual que recepción) ───────────────────────────────
let _toastTimer = null;
function mostrarToast(msg, type = 'info', duration = 2500) {
const el = document.getElementById('lug-toast');
const ico = document.getElementById('lug-toast-ico');
const txt = document.getElementById('lug-toast-msg');
const icons = { success: 'fa-check-circle', info: 'fa-info-circle', warn: 'fa-exclamation-triangle', error: 'fa-times-circle' };
el.className = 'rec-toast ' + type;
ico.innerHTML = '<i class="fas ' + (icons[type] || icons.info) + '"></i>';
txt.textContent = msg;
el.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.classList.remove('show'), duration);
}
function mostrarError(msg) { mostrarToast(msg, 'error', 4000); }
function mostrarLlamando(cod) { mostrarToast('Llamando turno ' + cod + '…', 'info', 2000); }
</script>
<div class="rec-toast" id="lug-toast">
<span class="ico" id="lug-toast-ico"><i class="fas fa-info-circle"></i></span>
<span id="lug-toast-msg"></span>
</div>
</body>
</html>