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
+72 -17
View File
@@ -37,12 +37,16 @@ if ($modoTurnero) {
p.numero_documento, p.tipo_documento,
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
t.sesion_id,
ts.numero_orden
ts.numero_orden,
med.nombres AS medico_nombres, med.apellidos AS medico_apellidos,
med.cod_especialidad AS medico_especialidad,
med.codigo AS medico_codigo, med.docidmedico AS medico_docid
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id
JOIN turnero_turnos t ON t.id = tc.turno_id
LEFT JOIN turnero_solicitudes ts ON ts.turno_id = tc.turno_id
LEFT JOIN lab_pacientes p ON p.id = COALESCE(ts.paciente_id, t.paciente_id)
LEFT JOIN medicos med ON med.id = ts.medico_id
WHERE tc.token = ?",
[$tokenTurnero]
);
@@ -124,11 +128,15 @@ if ($modoTurnero) {
'firma_profesional_svg' => $tcRow['firma_profesional_svg'] ?? null,
'datos_cliente' => $tcRow['datos_respuestas'] ?: '{}',
'datos_prefilled' => json_encode(['__paciente' => [
'nombre_completo' => $tcRow['paciente_nombre'] ?? '',
'numero_documento' => $tcRow['numero_documento'] ?? '',
'tipo_documento' => $tcRow['tipo_documento'] ?? '',
'telefono' => $tcRow['paciente_telefono'] ?? '',
'eps' => $tcRow['eps'] ?? '',
'nombre_completo' => $tcRow['paciente_nombre'] ?? '',
'numero_documento' => $tcRow['numero_documento'] ?? '',
'tipo_documento' => $tcRow['tipo_documento'] ?? '',
'telefono' => $tcRow['paciente_telefono'] ?? '',
'eps' => $tcRow['eps'] ?? '',
'medico_nombre' => trim(($tcRow['medico_nombres'] ?? '') . ' ' . ($tcRow['medico_apellidos'] ?? '')),
'medico_especialidad'=> $tcRow['medico_especialidad'] ?? '',
'medico_codigo' => $tcRow['medico_codigo'] ?? '',
'medico_docid' => $tcRow['medico_docid'] ?? '',
]]),
'created_at' => $tcRow['enviado_at'] ?? date('Y-m-d H:i:s'),
'completado_en' => $tcRow['firmado_at'],
@@ -271,6 +279,17 @@ if ($modoTurnero && $embebido && $_soloFirmaPro) {
}
}
// Primera firma pendiente en toma progresiva (las demás se ocultan hasta su turno)
$_mpPrimeraPendiente = null;
if (!empty($_mpMap)) {
foreach ($_mpMap as $_fid => $_) {
if (empty($datosCliente[$_fid . '_svg'])) {
$_mpPrimeraPendiente = $_fid;
break;
}
}
}
// Mapa id → label
$labelMap = [];
foreach ($esquema as $c) {
@@ -704,11 +723,18 @@ function esc2(mixed $v): string {
<script>window._fpwPreFirma = <?= json_encode($firmaProfPreguardada) ?>;</script>
<?php endif; ?>
<!-- Canvas del profesional -->
<?php
$_mpBloqueado = !empty($_mpMap)
&& $_mpPrimeraPendiente !== null
&& $cid !== $_mpPrimeraPendiente
&& empty($datosCliente[$cid . '_svg']);
?>
<div class="firma-pro-widget no-print" id="fpw-<?= htmlspecialchars($cid) ?>"
data-envio="<?= (int)$envio['id'] ?>" data-campo="<?= htmlspecialchars($cid) ?>"
data-solo-pro="<?= ($modoTurnero && $_soloFirmaPro) ? '1' : '0' ?>"
data-turno="<?= isset($tcRow) ? (int)$tcRow['turno_id'] : '' ?>"
data-formulario="<?= isset($tcRow) ? (int)$tcRow['formulario_id'] : '' ?>">
data-formulario="<?= isset($tcRow) ? (int)$tcRow['formulario_id'] : '' ?>"
<?= $_mpBloqueado ? 'style="display:none"' : '' ?>>
<?php if ($firmaProfPreguardada): ?>
<div class="fpw-oneclic">
<button class="btn btn-success fpw-oneclic-btn w-100" style="font-size:1rem;padding:.55rem 1rem">
@@ -1139,39 +1165,68 @@ function _guardarFirmaMP(widget, svg, msgEl, entry, campoId) {
});
}
function _mpPlayBeep() {
try {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
[880, 1100, 880, 1100].forEach(function(freq, i) {
var osc = ctx.createOscillator(), gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.frequency.value = freq;
var t0 = ctx.currentTime + i * 0.22;
gain.gain.setValueAtTime(0.7, t0);
gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.18);
osc.start(t0); osc.stop(t0 + 0.18);
});
} catch(e) {}
}
function _mpIniciarCountdown(minutos, nextLabel, nextFirmaId) {
var targetMs = Date.now() + minutos * 60000;
var box = document.getElementById('mp-countdown');
if (!box) {
box = document.createElement('div');
box.id = 'mp-countdown';
box.style.cssText = 'position:sticky;bottom:12px;background:#1565c0;color:#fff;padding:10px 16px;border-radius:8px;margin:12px 0;text-align:center;font-size:.9rem;z-index:50';
box.style.cssText = 'position:sticky;bottom:0;left:0;right:0;background:#1565c0;color:#fff;'
+ 'padding:14px 20px;margin:16px -14px -12px;text-align:center;z-index:60;'
+ 'box-shadow:0 -4px 20px rgba(21,101,192,.35)';
document.querySelector('.doc-body').appendChild(box);
}
if (box._mpTick) clearInterval(box._mpTick);
// Notificar al padre que bloquee el cierre
try { window.parent.postMessage({ type: 'tomaProgresivaIniciada' }, '*'); } catch(e) {}
box._mpTick = setInterval(function() {
var left = targetMs - Date.now();
if (left <= 0) {
clearInterval(box._mpTick);
box.style.background = '#dc3545';
box.style.animation = 'mp-pulse 1s ease-in-out infinite';
box.innerHTML = '<strong><i class="fas fa-bell me-2"></i>¡Hora de la siguiente muestra!</strong>'
+ (nextLabel ? '<br><small>' + nextLabel + '</small>' : '');
if (navigator.vibrate) navigator.vibrate([400,200,400,200,400]);
var nw = nextFirmaId ? document.getElementById('fpw-' + nextFirmaId) : document.querySelector('.firma-pro-widget');
box.innerHTML = '<div style="font-size:1.1rem;font-weight:700"><i class="fas fa-bell me-2"></i>¡Hora de la siguiente muestra!</div>'
+ (nextLabel ? '<div style="font-size:.85rem;opacity:.9;margin-top:4px">' + nextLabel + '</div>' : '');
if (navigator.vibrate) navigator.vibrate([500,150,500,150,500]);
_mpPlayBeep();
// Desbloquear y mostrar siguiente canvas
var nw = nextFirmaId ? document.getElementById('fpw-' + nextFirmaId) : null;
if (!nw) nw = document.querySelector('.firma-pro-widget[style*="display:none"]');
if (nw) {
nw.scrollIntoView({ behavior:'smooth', block:'center' });
nw.style.display = '';
nw.style.outline = '3px solid #dc3545';
nw.style.borderRadius = '6px';
nw.style.borderRadius = '8px';
setTimeout(function() { nw.scrollIntoView({ behavior:'smooth', block:'center' }); }, 120);
}
return;
}
var m = Math.floor(left / 60000);
var s = Math.floor((left % 60000) / 1000);
box.innerHTML = '⏱ Próxima muestra'
+ (nextLabel ? ' <small>(' + nextLabel + ')</small>' : '')
+ ' en <strong>' + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0') + '</strong>';
var pct = Math.max(0, left / (minutos * 60000)) * 100;
box.style.background = pct < 15 ? '#f59e0b' : '#1565c0';
box.style.animation = '';
box.innerHTML = '<div style="font-size:.75rem;opacity:.85;margin-bottom:4px;text-transform:uppercase;letter-spacing:.05em">'
+ '<i class="fas fa-clock me-1"></i>Próxima muestra' + (nextLabel ? ' · ' + nextLabel : '') + '</div>'
+ '<div style="font-size:2.4rem;font-weight:900;font-family:monospace;line-height:1;letter-spacing:.04em">'
+ String(m).padStart(2,'0') + '<span style="opacity:.6;animation:mp-pulse .8s infinite">:</span>' + String(s).padStart(2,'0')
+ '</div>';
}, 1000);
}