feat(turnero): tomas de muestras prolongadas con temporizador y progreso

- DB: ALTER TABLE turnero_consentimientos ADD siguiente_toma_at, toma_inicio_at;
  MODIFY estado para incluir 'en_progreso'
- guardar_toma.php (nuevo): guarda firma parcial por campo_id, parsea el
  esquema para calcular siguiente_toma_at desde 'Minuto X' o hora fija,
  marca 'en_progreso' o 'firmado' según tomas restantes
- get_consentimientos.php: expone siguiente_toma_at, es_toma_progresiva,
  tomas_total, tomas_firmadas, campos_firma_pro en cada consentimiento
- lugar.php: estado en_progreso con badge X/Y, countdown en tiempo real,
  animación de alerta cuando el tiempo llega, botón 'Siguiente toma' que
  abre el modal embebido en modo toma_progresiva

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-08 08:42:43 -05:00
co-authored by Claude Sonnet 4.6
parent cf8d011f09
commit ea1f0e30c9
3 changed files with 288 additions and 19 deletions
+89 -8
View File
@@ -265,11 +265,18 @@ try {
padding: .5rem .7rem; border-radius: 10px; margin-bottom: .35rem;
font-size: .84rem; border: 1px solid transparent; min-height: 46px;
}
.consent-row.firmado { background: #f0fdf4; color: #166534; border-color: #bbf7d0; }
.consent-row.rechazado { background: #f8fafc; color: #64748b; border-color: #e2e8f0; }
.consent-row.enviado { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
.consent-row.pendiente { background: #fffbeb; color: #92400e; border-color: #fde68a; }
.consent-row.visto { background: #faf5ff; color: #6b21a8; border-color: #ddd6fe; }
.consent-row.firmado { background: #f0fdf4; color: #166534; border-color: #bbf7d0; }
.consent-row.rechazado { background: #f8fafc; color: #64748b; border-color: #e2e8f0; }
.consent-row.enviado { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
.consent-row.pendiente { background: #fffbeb; color: #92400e; border-color: #fde68a; }
.consent-row.visto { background: #faf5ff; color: #6b21a8; border-color: #ddd6fe; }
.consent-row.en_progreso { background: #fff7ed; color: #9a3412; border-color: #fed7aa; }
.consent-row.en_progreso.alerta { background: #fef2f2; color: #991b1b; border-color: #fca5a5;
animation: parpadeo-alerta 1s infinite; }
@keyframes parpadeo-alerta { 0%,100%{opacity:1} 50%{opacity:.6} }
.toma-progreso { font-size:.67rem; background:#ea580c; color:#fff;
border-radius:99px; padding:1px 7px; font-weight:700; white-space:nowrap; }
.toma-countdown { font-size:.67rem; color:inherit; opacity:.75; white-space:nowrap; }
.consent-row .nom-form { flex: 1; font-weight: 500; }
.consent-row .c-badge { font-size: .67rem; padding: 1px 8px; border-radius: 99px;
border: 1px solid currentColor; font-weight: 700; opacity: .85; }
@@ -1090,17 +1097,46 @@ async function cargarFichaSolicitud(turnoId) {
const CONSENT_IC = {
firmado:'fa-check-circle', rechazado:'fa-ban',
enviado:'fa-envelope', visto:'fa-eye', pendiente:'fa-clock',
en_progreso:'fa-hourglass-half',
};
const CONSENT_LBL = {
firmado:'Firmado', rechazado:'Rechazado', enviado:'Enviado', visto:'Visto', pendiente:'Pendiente',
en_progreso:'En progreso',
};
// ── Tomas progresivas: actualiza countdowns cada segundo ──────
let _tomaTimers = {}; // consent_id → { siguiente_toma_at, es_alerta }
setInterval(() => {
const ahora = Date.now();
for (const [cid, info] of Object.entries(_tomaTimers)) {
const row = document.querySelector(`[data-consent-id="${cid}"]`);
if (!row) continue;
const cdEl = row.querySelector('.toma-countdown');
if (!cdEl) continue;
if (!info.siguiente_toma_at) { cdEl.textContent = ''; continue; }
const diff = Math.floor((info.siguiente_toma_at - ahora) / 1000);
if (diff <= 0) {
cdEl.textContent = '¡Hora de toma!';
row.classList.add('alerta');
if (!info.alertado) {
info.alertado = true;
try { new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAA...').play().catch(()=>{}); } catch(_) {}
}
} else {
row.classList.remove('alerta');
const m = Math.floor(diff / 60), s = diff % 60;
cdEl.textContent = `próx. toma en ${m}:${String(s).padStart(2,'0')}`;
}
}
}, 1000);
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);
tieneConsent = lista.length > 0;
hayPendientes = lista.some(c => !['firmado','rechazado'].includes(c.estado));
const pendCount = lista.filter(c => !['firmado','rechazado'].includes(c.estado)).length;
// en_progreso bloquea finalizar pero no muestra aviso de "pendiente sin acción"
const pendCount = lista.filter(c => ['pendiente','enviado','visto'].includes(c.estado)).length;
const listEl = document.getElementById('lista-consent');
const sinEl = document.getElementById('sin-consent');
@@ -1131,6 +1167,17 @@ function renderConsentimientos(lista) {
banner.classList.toggle('d-none', !hayPendientes);
if (btnFin) btnFin.disabled = hayPendientes;
// Actualizar mapa de timers para tomas progresivas
_tomaTimers = {};
lista.forEach(c => {
if (c.es_toma_progresiva && c.estado === 'en_progreso') {
_tomaTimers[c.id] = {
siguiente_toma_at: c.siguiente_toma_at ? new Date(c.siguiente_toma_at).getTime() : null,
alertado: false,
};
}
});
listEl.innerHTML = lista.map(c => {
const ya = ['firmado','rechazado'].includes(c.estado);
const ico = CONSENT_IC[c.estado] || 'fa-clock';
@@ -1139,6 +1186,15 @@ function renderConsentimientos(lista) {
const nomJs = JSON.stringify(c.formulario_nombre || 'Consentimiento');
const idJs = parseInt(c.id) || 0;
const tId = parseInt(c.turno_id) || (turnoActivo?.id) || 0;
const fId = parseInt(c.formulario_id) || 0;
// Badge de progreso para tomas progresivas
let progresoBadge = '';
if (c.es_toma_progresiva && c.tomas_total > 0) {
progresoBadge = `<span class="toma-progreso">${c.tomas_firmadas}/${c.tomas_total}</span>`;
}
const countdownEl = (c.es_toma_progresiva && c.estado === 'en_progreso')
? `<span class="toma-countdown"></span>` : '';
// Botón ver
const btnVer = (ya && c.token)
@@ -1149,8 +1205,15 @@ function renderConsentimientos(lista) {
// Acción de firma según modo
let btnAccion = '';
if (!ya && c.token) {
if (LUGAR_FORM_MODO === 'embebido') {
const puedeFirmar = !ya || (c.es_toma_progresiva && c.estado === 'en_progreso');
if (puedeFirmar && c.token) {
if (c.es_toma_progresiva) {
// Toma progresiva: siempre abre el modal embebido para firmar la siguiente toma
btnAccion = `<button class="btn btn-primary" title="Registrar siguiente toma"
onclick="abrirTomaProgresiva('${token}', ${tId}, ${fId})">
<i class="fas fa-flask me-1"></i>Siguiente toma
</button>`;
} else if (LUGAR_FORM_MODO === 'embebido') {
btnAccion = `<button class="btn btn-primary" title="Abrir formulario"
onclick="abrirModalConsentimientoPorToken('${token}')">
<i class="fas fa-pen me-1"></i>Firmar
@@ -1170,6 +1233,8 @@ function renderConsentimientos(lista) {
return `<div class="consent-row ${c.estado}" data-consent-id="${c.id}">
<i class="fas ${ico}"></i>
<span class="nom-form">${escHtml(c.formulario_nombre || 'Consentimiento')}</span>
${progresoBadge}
${countdownEl}
<span class="c-badge">${label}</span>
<div class="acciones-consent">${btnAccion}${btnVer}</div>
</div>`;
@@ -1234,6 +1299,22 @@ function abrirModalConsentimientoPorToken(token) {
_abrirModalConToken(token);
}
// ── Toma progresiva: abre el modal con modo especial ──────────
function abrirTomaProgresiva(token, turnoId, formularioId) {
const modal = document.getElementById('modal-consentimiento');
const iframe = document.getElementById('modal-consent-iframe');
const load = document.getElementById('modal-consent-loading');
iframe.style.display = 'none';
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
setTimeout(() => {
iframe.src = `${BASE_WA}ver_formulario_enviado.php?token=${encodeURIComponent(token)}&embed=1&compact=1&modo=toma_progresiva`;
}, 80);
}
function cerrarModalConsentimiento() {
const modal = document.getElementById('modal-consentimiento');
const iframe = document.getElementById('modal-consent-iframe');