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
+13
View File
@@ -91,6 +91,19 @@ try {
$eid = enfermeraId();
if ($eid) $datos['enfermera_id'] = $eid;
}
// Asignar número de orden D-YYYYMMDD-NNN si no viene uno
if (empty($datos['numero_orden'])) {
$pdo = db();
$prefix = 'D-' . date('Ymd') . '-';
$st = $pdo->prepare(
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden, '-', -1) AS UNSIGNED)) AS ultimo
FROM lab_domicilios WHERE numero_orden LIKE ?"
);
$st->execute([$prefix . '%']);
$ultimo = (int)($st->fetch(\PDO::FETCH_ASSOC)['ultimo'] ?? 0);
$datos['numero_orden'] = $prefix . str_pad($ultimo + 1, 3, '0', STR_PAD_LEFT);
}
$id = $dom->crear($datos, $admin);
// Crear asignación automática si se indicó enfermera_id
+1 -1
View File
@@ -364,7 +364,7 @@ class Domicilio {
private function filtrarCampos(array $datos): array {
$permitidos = [
'orden_id', 'paciente_id', 'direccion', 'ciudad', 'barrio',
'orden_id', 'paciente_id', 'numero_orden', 'direccion', 'ciudad', 'barrio',
'indicaciones_dir', 'fecha_programada', 'hora_programada',
'tipo_servicio', 'tipo_cliente', 'examenes_solicitados',
'estado', 'motivo_cancelacion',
+8
View File
@@ -681,6 +681,7 @@ async function cargarLista(pag = 1) {
<td>
<div class="fw-semibold">${esc(dom.paciente_nombre)}</div>
<small class="text-muted">${esc(dom.barrio||'')}</small>
${dom.numero_orden ? `<div><span style="font-size:.68rem;background:#f0fdf4;color:#15803d;border:1px solid #86efac;border-radius:20px;padding:0 7px;font-weight:700">#${esc(dom.numero_orden)}</span></div>` : ''}
</td>
<td>
${dom.enfermera_nombre ? `<small>${esc(dom.enfermera_nombre)}</small>` : '<span class="text-danger small"><i class="fas fa-exclamation-triangle me-1"></i>Sin asignar</span>'}
@@ -799,6 +800,13 @@ async function verDomicilio(id) {
</dl>
</div>
<div class="col-md-6">
${dom.numero_orden ? `<div class="mb-3">
<label class="form-label small text-muted text-uppercase">Número de orden</label><br>
<span style="font-size:.82rem;background:#f0fdf4;color:#15803d;border:1px solid #86efac;
border-radius:20px;padding:2px 12px;font-weight:700">
<i class="fas fa-hashtag me-1" style="font-size:.7rem"></i>${esc(dom.numero_orden)}
</span>
</div>` : ''}
<div class="mb-3">
<label class="form-label small text-muted text-uppercase">Estado actual</label><br>
<span class="badge bg-${COLOR_DOM[dom.estado]||'secondary'} fs-6">${esc(dom.estado)}</span>
@@ -0,0 +1,7 @@
-- Agrega número de orden visible a domicilios (formato D-YYYYMMDD-NNN)
ALTER TABLE lab_domicilios
ADD COLUMN numero_orden VARCHAR(20) NULL DEFAULT NULL
COMMENT 'Número de orden del domicilio. Formato D-YYYYMMDD-NNN'
AFTER paciente_id;
CREATE INDEX idx_lab_domicilios_numero_orden ON lab_domicilios (numero_orden);
@@ -0,0 +1,6 @@
-- Restricción de usuario a un lugar específico del turnero.
-- NULL = acceso libre (admin, recepcionista, etc.)
-- Valor = solo puede operar en ese lugar (ej. bacteriólogo asignado a Toma 1)
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS turnero_lugar_id INT UNSIGNED DEFAULT NULL
COMMENT 'FK turnero_lugares.id — si != NULL el usuario solo opera en ese lugar';
+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>`;
+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);
}