feat(turnero): toma prolongada secuencial + bloqueo muestras/cierre

- ver_formulario_enviado: auto-fill médico (nombres/especialidad/código) en datos_prefilled desde solicitud
- ver_formulario_enviado: canvases de toma progresiva bloqueados secuencialmente — solo el turno activo es firmable, los siguientes aparecen al expirar el countdown
- ver_formulario_enviado: countdown prominente con reloj grande, color dinámico, sonido (Web Audio) y vibración al llegar la hora
- lugar: bloquear cierre del modal durante toma progresiva activa (botón X deshabilitado hasta mp_completar)
- lugar: bloquear Finalizar atención si hay muestras pendientes sin decisión (recibida/rechazada)
- lugar: _syncBtnFinalizar combina consentimientos + muestras para el aviso y estado del botón
- lugar: restricción de usuario a lugar específico via turnero_lugar_id (admin_users) + bloqueo JS en confirmarLugar()
- migrations: turnero_lugar_id en admin_users para restringir bacteriólogos por usuario

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-08 21:16:07 -05:00
co-authored by Claude Sonnet 4.6
parent 0e116359f8
commit f4da864db7
8 changed files with 208 additions and 55 deletions
+28 -11
View File
@@ -1,23 +1,40 @@
<?php
/**
* GET modules/turnero/api/get_consecutivo.php
* Devuelve el siguiente número de orden del día (YYYYMMDD-NNN).
* Devuelve el siguiente número de orden del día.
*
* Parámetros:
* tipo string 'F' = Físico/turnero (default) | 'D' = Domicilio
*
* Formato resultante:
* F-YYYYMMDD-NNN para tipo=F
* D-YYYYMMDD-NNN para tipo=D
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('GET');
$pdo = db();
$prefix = date('Ymd') . '-';
$tipo = strtoupper(trim($_GET['tipo'] ?? 'F'));
if (!in_array($tipo, ['F', 'D'], true)) $tipo = 'F';
$row = $pdo->prepare(
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden,'-',-1) AS UNSIGNED)) AS ultimo
FROM turnero_solicitudes
WHERE numero_orden LIKE ?"
);
$row->execute([$prefix . '%']);
$ultimo = (int)($row->fetch(PDO::FETCH_ASSOC)['ultimo'] ?? 0);
$pdo = db();
$prefix = $tipo . '-' . date('Ymd') . '-';
if ($tipo === 'D') {
$stmt = $pdo->prepare(
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden, '-', -1) AS UNSIGNED)) AS ultimo
FROM lab_domicilios
WHERE numero_orden LIKE ?"
);
} else {
$stmt = $pdo->prepare(
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden, '-', -1) AS UNSIGNED)) AS ultimo
FROM turnero_solicitudes
WHERE numero_orden LIKE ?"
);
}
$stmt->execute([$prefix . '%']);
$ultimo = (int)($stmt->fetch(PDO::FETCH_ASSOC)['ultimo'] ?? 0);
$siguiente = $prefix . str_pad($ultimo + 1, 3, '0', STR_PAD_LEFT);
jsonOk(['consecutivo' => $siguiente]);
+73 -26
View File
@@ -10,6 +10,7 @@ if (!isUserLoggedIn()) {
}
// Tablet asignada → forzar su lugar, bloquear cualquier otro
$lugarForzado = 0;
if (!empty($_SESSION['turnero_dispositivo'])) {
$_d = $_SESSION['turnero_dispositivo'];
$_forzado = (int)$_d['lugar_id'];
@@ -19,6 +20,17 @@ if (!empty($_SESSION['turnero_dispositivo'])) {
if ((int)($_GET['lugar_id'] ?? 0) !== $_forzado) {
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_forzado); exit;
}
$lugarForzado = $_forzado;
}
// Restricción a nivel de usuario (turnero_lugar_id en admin_users)
if (!$lugarForzado) {
$_userLugar = (int)($_SESSION['admin_user']['turnero_lugar_id'] ?? 0);
if ($_userLugar) {
if ((int)($_GET['lugar_id'] ?? 0) !== $_userLugar) {
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_userLugar); exit;
}
$lugarForzado = $_userLugar;
}
}
try {
@@ -495,9 +507,11 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
onclick="abrirModalMiFirma()" title="<?= $adminFirmaSvg ? 'Ver / cambiar mi firma' : 'Configurar mi firma' ?>">
<i class="fas fa-signature me-1"></i><?= $adminFirmaSvg ? 'Mi firma' : 'Configurar firma' ?>
</button>
<?php if (!$lugarForzado): ?>
<button class="btn btn-sm btn-outline-secondary" onclick="cambiarLugar()">
<i class="fas fa-exchange-alt me-1"></i>Cambiar
</button>
<?php endif; ?>
</div>
</div>
@@ -751,8 +765,9 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<span style="font-weight:700;font-size:.95rem;color:#1e293b">
<i class="fas fa-file-signature me-2 text-primary"></i>Formulario de consentimiento
</span>
<button onclick="cerrarModalConsentimiento()"
style="background:none;border:none;font-size:1.2rem;color:#94a3b8;cursor:pointer;padding:2px 6px">
<button id="btn-cerrar-modal-consent" onclick="cerrarModalConsentimiento()"
style="background:none;border:none;font-size:1.2rem;color:#94a3b8;cursor:pointer;padding:2px 6px"
title="Cerrar">
<i class="fas fa-times"></i>
</button>
</div>
@@ -769,6 +784,7 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<script>
// ── Estado global ─────────────────────────────────────────────
const LUGAR_FORZADO = <?= $lugarForzado ?: 0 ?>;
let lugarId = <?= $lugarIdParam ?: 0 ?>;
let turnoActivo = null;
let tieneConsent = false;
@@ -777,6 +793,8 @@ let pollingColaId = null;
let pollingConsentId = null;
let _consentTokenCache = null;
let _pacienteActivo = null;
let _tomaProgresivaActiva = false;
let hayMuestrasPendientes = false;
let _solicitudActiva = null;
let _muestrasActivas = [];
@@ -828,6 +846,7 @@ function confirmarLugar() {
const sel = document.getElementById('sel-lugar-init');
const id = parseInt(sel.value);
if (!id) return mostrarError('Seleccione un lugar.');
if (LUGAR_FORZADO && id !== LUGAR_FORZADO) return mostrarError('No tiene acceso a esa estación.');
lugarId = id;
const txt = sel.options[sel.selectedIndex].text;
document.getElementById('lbl-lugar-titulo').textContent = txt;
@@ -1146,6 +1165,24 @@ setInterval(() => {
}
}, 1000);
function _syncBtnFinalizar() {
const btnFin = document.getElementById('btn-finalizar');
const warnStrip = document.getElementById('accion-consent-warn');
const warnTxt = document.getElementById('accion-warn-txt');
const bloqueado = hayPendientes || hayMuestrasPendientes;
if (btnFin) btnFin.disabled = bloqueado;
if (!warnStrip || !warnTxt) return;
if (bloqueado) {
const msgs = [];
if (hayPendientes) msgs.push('consentimientos pendientes');
if (hayMuestrasPendientes) msgs.push('muestras sin decisión');
warnTxt.textContent = msgs.join(' · ');
warnStrip.classList.remove('d-none');
} else {
warnStrip.classList.add('d-none');
}
}
function renderConsentimientos(lista) {
// Mostrar consentimientos de examen (origen null) + los de esta estación
lista = lista.filter(c => !c.origen_lugar_id || c.origen_lugar_id == lugarId);
@@ -1159,29 +1196,17 @@ function renderConsentimientos(lista) {
const banner = document.getElementById('bloqueo-banner');
const btnFin = document.getElementById('btn-finalizar');
// Aviso en barra de acciones
const warnStrip = document.getElementById('accion-consent-warn');
const warnTxt = document.getElementById('accion-warn-txt');
if (warnStrip) {
if (hayPendientes) {
warnStrip.classList.remove('d-none');
warnTxt.textContent = pendCount + ' consentimiento' + (pendCount > 1 ? 's' : '') + ' pendiente' + (pendCount > 1 ? 's' : '');
} else {
warnStrip.classList.add('d-none');
}
}
_syncBtnFinalizar();
if (!tieneConsent) {
listEl.innerHTML = '';
sinEl.classList.remove('d-none');
banner.classList.add('d-none');
if (btnFin) btnFin.disabled = false;
return;
}
sinEl.classList.add('d-none');
banner.classList.toggle('d-none', !hayPendientes);
if (btnFin) btnFin.disabled = hayPendientes;
// Actualizar mapa de timers para tomas progresivas
_tomaTimers = {};
@@ -1324,14 +1349,25 @@ function abrirTomaProgresiva(token, turnoId, formularioId) {
iframe.src = '';
load.style.display = '';
modal.style.display = 'flex';
// Pasa modo=toma_progresiva para que el renderer solo muestre
// los campos del siguiente tiempo pendiente y el botón de guardar parcial
_tomaProgresivaActiva = true;
_setBtnCerrarConsent(false);
setTimeout(() => {
iframe.src = `${BASE_WA}ver_formulario_enviado.php?token=${encodeURIComponent(token)}&embed=1&compact=1&modo=toma_progresiva`;
}, 80);
}
function cerrarModalConsentimiento() {
function _setBtnCerrarConsent(habilitado) {
const btn = document.getElementById('btn-cerrar-modal-consent');
if (!btn) return;
btn.disabled = !habilitado;
btn.style.opacity = habilitado ? '' : '.25';
btn.title = habilitado ? 'Cerrar' : 'No puede cerrar durante una toma en progreso';
}
function cerrarModalConsentimiento(forzar) {
if (!forzar && _tomaProgresivaActiva) return; // bloqueado hasta finalizar toma
_tomaProgresivaActiva = false;
_setBtnCerrarConsent(true);
const modal = document.getElementById('modal-consentimiento');
const iframe = document.getElementById('modal-consent-iframe');
modal.style.display = 'none';
@@ -1342,8 +1378,14 @@ function cerrarModalConsentimiento() {
}
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'tomaProgresivaIniciada') {
_tomaProgresivaActiva = true;
_setBtnCerrarConsent(false);
}
if (e.data && e.data.type === 'turneroFirmado') {
cerrarModalConsentimiento();
_tomaProgresivaActiva = false;
_setBtnCerrarConsent(true);
cerrarModalConsentimiento(true);
mostrarToast('Consentimiento firmado ✓', 'success', 3000);
if (turnoActivo) actualizarConsentimientos(turnoActivo.id);
}
@@ -1380,7 +1422,8 @@ async function iniciarAtencion() {
async function finalizarAtencion() {
if (!turnoActivo) return;
if (hayPendientes) { mostrarError('Debe firmar todos los consentimientos antes de finalizar.'); return; }
if (hayPendientes) { mostrarError('Debe firmar todos los consentimientos antes de finalizar.'); return; }
if (hayMuestrasPendientes) { mostrarError('Debe marcar cada muestra como recibida o rechazada antes de finalizar.'); return; }
if (!confirm(`¿Finalizar atención del turno ${turnoActivo.codigo}?`)) return;
await cambiarEstadoTurno('finalizado');
}
@@ -1429,12 +1472,13 @@ async function cambiarEstadoTurno(nuevoEstado) {
function resetFicha() {
clearInterval(pollingConsentId);
turnoActivo = null;
tieneConsent = false;
hayPendientes = false;
_consentTokenCache = null;
_pacienteActivo = null;
_solicitudActiva = null;
_muestrasActivas = [];
tieneConsent = false;
hayPendientes = false;
hayMuestrasPendientes = false;
_consentTokenCache = null;
_pacienteActivo = null;
_solicitudActiva = null;
_muestrasActivas = [];
const secMuestras = document.getElementById('sec-muestras');
if (secMuestras) secMuestras.classList.add('d-none');
document.getElementById('ficha-orden').classList.add('d-none');
@@ -1750,6 +1794,9 @@ function renderMuestras(lista) {
const nRec = lista.filter(m => m.estado === 'recibida').length;
const nRech = lista.filter(m => m.estado === 'rechazada').length;
hayMuestrasPendientes = nPend > 0;
_syncBtnFinalizar();
let chips = '';
if (nPend) chips += `<span class="mc pend">${nPend} pendiente${nPend > 1 ? 's' : ''}</span>`;
if (nRec) chips += `<span class="mc rec">${nRec} recibida${nRec > 1 ? 's' : ''}</span>`;