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
+47 -11
View File
@@ -48,6 +48,9 @@ $stmt = $pdo->prepare(
tc.firmado_at,
(tc.firma_profesional_svg IS NOT NULL) AS tiene_firma_profesional,
tc.firmado_profesional_at,
tc.siguiente_toma_at,
tc.toma_inicio_at,
tc.datos_respuestas,
f.nombre AS formulario_nombre,
f.esquema AS formulario_esquema
FROM turnero_consentimientos tc
@@ -66,14 +69,31 @@ foreach ($consentimientos as &$c) {
$decoded = json_decode($esquema, true);
$campos = is_array($decoded) ? $decoded : ($decoded['campos'] ?? []);
}
$tieneFirmaPro = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'));
$tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma'));
$camposFirmaPro = array_values(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'));
$tieneFirmaPro = !empty($camposFirmaPro);
$tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma'));
// Toma progresiva: más de 1 campo firma_profesional
$esTomaProg = count($camposFirmaPro) > 1;
$tomasTotal = count($camposFirmaPro);
$tomasFirm = 0;
if ($esTomaProg && $c['datos_respuestas']) {
$dr = json_decode($c['datos_respuestas'], true) ?? [];
foreach ($camposFirmaPro as $fp) {
if (!empty($dr[$fp['id']]) && strlen($dr[$fp['id']]) > 10) $tomasFirm++;
}
}
$c['requiere_firma_profesional'] = $tieneFirmaPro;
$c['requiere_firma_paciente'] = !($tieneFirmaPro && !$tieneFirmaPac);
// Si el formulario está asignado al lugar destino → es de toma de muestras, no de recepción
$c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true)
? $lugarDestinoId : null;
unset($c['formulario_esquema']);
$c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true)
? $lugarDestinoId : null;
$c['es_toma_progresiva'] = $esTomaProg;
$c['tomas_total'] = $tomasTotal;
$c['tomas_firmadas'] = $tomasFirm;
// Exponer campos firma_profesional (ids) para el frontend
$c['campos_firma_pro'] = array_column($camposFirmaPro, 'id');
unset($c['formulario_esquema'], $c['datos_respuestas']);
}
unset($c);
@@ -202,6 +222,9 @@ if ($incluirSolicitud) {
tc.estado, tc.enviado_at, tc.firmado_at,
(tc.firma_profesional_svg IS NOT NULL) AS tiene_firma_profesional,
tc.firmado_profesional_at,
tc.siguiente_toma_at,
tc.toma_inicio_at,
tc.datos_respuestas,
f.nombre AS formulario_nombre,
f.esquema AS formulario_esquema
FROM turnero_consentimientos tc
@@ -218,13 +241,26 @@ if ($incluirSolicitud) {
$decoded = json_decode($esquema, true);
$campos = is_array($decoded) ? $decoded : ($decoded['campos'] ?? []);
}
$tieneFirmaPro = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'));
$tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma'));
$camposFirmaPro = array_values(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'));
$tieneFirmaPro = !empty($camposFirmaPro);
$tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma'));
$esTomaProg = count($camposFirmaPro) > 1;
$tomasFirm = 0;
if ($esTomaProg && $c['datos_respuestas']) {
$dr = json_decode($c['datos_respuestas'], true) ?? [];
foreach ($camposFirmaPro as $fp) {
if (!empty($dr[$fp['id']]) && strlen($dr[$fp['id']]) > 10) $tomasFirm++;
}
}
$c['requiere_firma_profesional'] = $tieneFirmaPro;
$c['requiere_firma_paciente'] = !($tieneFirmaPro && !$tieneFirmaPac);
$c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true)
? $lugarDestinoId : null;
unset($c['formulario_esquema']);
$c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true)
? $lugarDestinoId : null;
$c['es_toma_progresiva'] = $esTomaProg;
$c['tomas_total'] = count($camposFirmaPro);
$c['tomas_firmadas'] = $tomasFirm;
$c['campos_firma_pro'] = array_column($camposFirmaPro, 'id');
unset($c['formulario_esquema'], $c['datos_respuestas']);
}
unset($c);
$respuesta['consentimientos'] = $consentimientos;
+152
View File
@@ -0,0 +1,152 @@
<?php
/**
* POST /modules/turnero/api/guardar_toma.php
* Guarda una firma parcial en formularios de tomas prolongadas.
* Calcula el próximo intervalo y actualiza estado + siguiente_toma_at.
*
* Body JSON:
* turno_id int requerido
* formulario_id int requerido
* campo_id string requerido (id del campo firma_profesional firmado)
* svg string requerido (data URI)
* datos_respuestas object requerido (todos los valores actuales del form)
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$body = inputJson();
$turnoId = (int)($body['turno_id'] ?? 0);
$formularioId = (int)($body['formulario_id'] ?? 0);
$campoId = trim($body['campo_id'] ?? '');
$svg = $body['svg'] ?? '';
$datosInput = is_array($body['datos_respuestas'] ?? null) ? $body['datos_respuestas'] : [];
if (!$turnoId) jsonError('turno_id requerido.');
if (!$formularioId) jsonError('formulario_id requerido.');
if (!$campoId) jsonError('campo_id requerido.');
if (strlen($svg) < 100) jsonError('Firma requerida.');
if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
jsonError('Formato de firma no válido.');
}
$pdo = db();
// ── Cargar consentimiento y esquema ──────────────────────────
$stmt = $pdo->prepare(
"SELECT tc.id, tc.estado, tc.datos_respuestas, tc.toma_inicio_at, t.sesion_id,
f.esquema
FROM turnero_consentimientos tc
JOIN turnero_turnos t ON t.id = tc.turno_id
JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.turno_id = ? AND tc.formulario_id = ?"
);
$stmt->execute([$turnoId, $formularioId]);
$tc = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$tc) jsonError('Consentimiento no encontrado.', 404);
$esquema = json_decode($tc['esquema'] ?? '[]', true);
if (!is_array($esquema)) $esquema = [];
// ── Merge datos_respuestas existentes + nuevos ───────────────
$datosActuales = [];
if ($tc['datos_respuestas']) {
$d = json_decode($tc['datos_respuestas'], true);
if (is_array($d)) $datosActuales = $d;
}
// Guardar svg en el campo firmado
$datosInput[$campoId] = $svg;
$datosMerge = array_merge($datosActuales, $datosInput);
// ── Analizar esquema: campos firma_profesional en orden ──────
// Construir mapa: campo_id → minuto (o hora fija) leyendo el separador previo
$firmasCampos = []; // [ [id, minutos|null, hora_fija|null], ... ] en orden
$separadorLabel = null;
foreach ($esquema as $campo) {
$tipo = $campo['tipo'] ?? '';
if ($tipo === 'separador') {
$separadorLabel = $campo['label'] ?? '';
}
if ($tipo === 'firma_profesional') {
$minutos = null;
$horaFija = null;
if ($separadorLabel !== null) {
// "Taukit · Minuto 10" → 10; "Toma · Minuto 60" → 60
if (preg_match('/Minuto\s+(\d+)/i', $separadorLabel, $m)) {
$minutos = (int)$m[1];
}
// "3:00 p.m." / "4:00 p.m." → hora fija
elseif (preg_match('/(\d+):(\d+)\s*(a\.?m\.?|p\.?m\.?)/i', $separadorLabel, $m)) {
$h = (int)$m[1];
$min = (int)$m[2];
$pm = strtolower(preg_replace('/[^apm]/i', '', $m[3])) === 'pm';
if ($pm && $h < 12) $h += 12;
$horaFija = sprintf('%02d:%02d:00', $h, $min);
}
}
$firmasCampos[] = ['id' => $campo['id'], 'minutos' => $minutos, 'hora_fija' => $horaFija];
}
}
// ── Determinar estado y siguiente_toma_at ────────────────────
$firmados = array_filter($firmasCampos, fn($f) => isset($datosMerge[$f['id']]) && strlen($datosMerge[$f['id']]) > 10);
$pendientes = array_filter($firmasCampos, fn($f) => !isset($datosMerge[$f['id']]) || strlen($datosMerge[$f['id']]) <= 10);
$pendientes = array_values($pendientes);
$todasFirmadas = empty($pendientes);
$siguienteToma = null;
$nuevoEstado = 'en_progreso';
$tomaInicioAt = $tc['toma_inicio_at'];
if (empty($tc['toma_inicio_at'])) {
$tomaInicioAt = date('Y-m-d H:i:s');
}
if ($todasFirmadas) {
$nuevoEstado = 'firmado';
} else {
// Calcular cuándo es la siguiente toma
$next = $pendientes[0];
if ($next['hora_fija'] !== null) {
// Hora fija en el día actual
$siguienteToma = date('Y-m-d') . ' ' . $next['hora_fija'];
} elseif ($next['minutos'] !== null && $tomaInicioAt) {
// Minuto relativo desde el inicio de la toma
$siguienteToma = date('Y-m-d H:i:s', strtotime($tomaInicioAt) + $next['minutos'] * 60);
}
}
// ── Persistir ────────────────────────────────────────────────
$pdo->prepare(
"UPDATE turnero_consentimientos
SET datos_respuestas = ?,
estado = ?,
siguiente_toma_at = ?,
toma_inicio_at = COALESCE(toma_inicio_at, ?),
firmado_at = IF(? = 'firmado', NOW(), firmado_at),
firmado_profesional_at = IF(? = 'firmado', NOW(), firmado_profesional_at)
WHERE turno_id = ? AND formulario_id = ?"
)->execute([
json_encode($datosMerge, JSON_UNESCAPED_UNICODE),
$nuevoEstado,
$siguienteToma,
date('Y-m-d H:i:s'),
$nuevoEstado,
$nuevoEstado,
$turnoId,
$formularioId,
]);
notificarSSE((int)$tc['sesion_id']);
$totalFirmas = count($firmasCampos);
$firmadasCount = count($firmados) + ($todasFirmadas ? 0 : 1); // incluye la recién guardada
jsonOk([
'estado' => $nuevoEstado,
'siguiente_toma_at'=> $siguienteToma,
'tomas_firmadas' => min($firmadasCount, $totalFirmas),
'tomas_total' => $totalFirmas,
'completado' => $todasFirmadas,
], $todasFirmadas ? 'Formulario completado.' : 'Toma guardada. Próxima: ' . ($siguienteToma ?? 'pendiente'));
+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');