feat: cierre anticipado de tomas prolongadas con justificación

- DB: añade estado 'cerrado_anticipado' al enum de turnero_consentimientos
- ver_formulario_enviado.php: POST handler mp_cerrar_anticipado guarda motivo
  en _cierre_anticipado y cambia estado; botón "Cerrar anticipadamente" con modal
  textarea; bloque display para estado cerrado_anticipado mostrando el motivo;
  modoEditar y check de estado actualizados para excluir cerrado_anticipado
- get_consentimientos.php: expone cierre_anticipado en respuesta; mueve parse
  de datos_respuestas fuera del if(esTomaProg) para ambos loops
- lugar.php: CONSENT_IC/LBL incluye cerrado_anticipado; ya[] lo incluye;
  hayPendientes lo excluye; fila muestra motivo en banner amarillo
- recepcion.php: CONSENT_META incluye cerrado_anticipado; ya[] lo incluye;
  motivo visible en la tarjeta del consentimiento

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-28 00:29:32 -05:00
co-authored by Claude Sonnet 4.6
parent 915fa3ba5c
commit c9ea76f782
4 changed files with 139 additions and 22 deletions
+4 -2
View File
@@ -81,10 +81,10 @@ foreach ($consentimientos as &$c) {
// Toma progresiva: override explícito en DB gana; si es NULL, usar heurística (>1 campo firma_profesional)
$overrideProg = $c['formulario_es_toma_prog'] ?? null;
$esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1;
$dr = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
$tomasTotal = 0;
$tomasFirm = 0;
if ($esTomaProg) {
$dr = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
// Mapear cada firma_profesional a los valores de examen de su sección condicional
$firmaCondMap = [];
@@ -147,6 +147,7 @@ foreach ($consentimientos as &$c) {
$c['es_toma_progresiva'] = $esTomaProg;
$c['tomas_total'] = $tomasTotal;
$c['tomas_firmadas'] = $tomasFirm;
$c['cierre_anticipado'] = $dr['_cierre_anticipado'] ?? null;
// Exponer campos firma_profesional (ids) para el frontend
$c['campos_firma_pro'] = array_column($camposFirmaPro, 'id');
unset($c['formulario_esquema'], $c['formulario_es_toma_prog'], $c['formulario_solo_profesional'], $c['datos_respuestas']);
@@ -333,10 +334,10 @@ if ($incluirSolicitud) {
$tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma'));
$overrideProg = $c['formulario_es_toma_prog'] ?? null;
$esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1;
$dr2 = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
$tomasTotal2 = 0;
$tomasFirm = 0;
if ($esTomaProg) {
$dr2 = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
$firmaCondMap2 = [];
$curC2 = null; $curV2 = [];
foreach ($campos as $campo2) {
@@ -384,6 +385,7 @@ if ($incluirSolicitud) {
$c['es_toma_progresiva'] = $esTomaProg;
$c['tomas_total'] = $tomasTotal2;
$c['tomas_firmadas'] = $tomasFirm;
$c['cierre_anticipado'] = $dr2['_cierre_anticipado'] ?? null;
$c['campos_firma_pro'] = array_column($camposFirmaPro, 'id');
unset($c['formulario_esquema'], $c['formulario_es_toma_prog'], $c['formulario_solo_profesional'], $c['datos_respuestas']);
}
+20 -11
View File
@@ -386,7 +386,8 @@ foreach ($lugares as $_el) {
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.firmado { background: #f0fdf4; color: #166534; border-color: #bbf7d0; }
.consent-row.cerrado_anticipado { background: #fef9c3; color: #78350f; border-color: #fde68a; }
.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; }
@@ -1394,10 +1395,11 @@ const CONSENT_IC = {
firmado:'fa-check-circle', rechazado:'fa-ban',
enviado:'fa-envelope', visto:'fa-eye', pendiente:'fa-clock',
en_progreso:'fa-hourglass-half',
cerrado_anticipado:'fa-exclamation-triangle',
};
const CONSENT_LBL = {
firmado:'Firmado', rechazado:'Rechazado', enviado:'Enviado', visto:'Visto', pendiente:'Pendiente',
en_progreso:'En progreso',
en_progreso:'En progreso', cerrado_anticipado:'Cerrado anticipadamente',
};
// ── Countdown en tarjetas de cola (toma progresiva) ──────────
@@ -1530,7 +1532,7 @@ 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));
hayPendientes = lista.some(c => !['firmado','rechazado','cerrado_anticipado'].includes(c.estado));
// 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;
@@ -1574,7 +1576,7 @@ function renderConsentimientos(lista) {
}
listEl.innerHTML = lista.map(c => {
const ya = ['firmado','rechazado'].includes(c.estado);
const ya = ['firmado','rechazado','cerrado_anticipado'].includes(c.estado);
const ico = CONSENT_IC[c.estado] || 'fa-clock';
const label = CONSENT_LBL[c.estado] || c.estado;
const token = escHtml(c.token || '');
@@ -1631,13 +1633,20 @@ function renderConsentimientos(lista) {
<i class="fas fa-trash"></i>
</button>`;
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}${btnQuitar}</div>
const motivoRow = (c.estado === 'cerrado_anticipado' && c.cierre_anticipado?.motivo)
? `<div style="font-size:.75rem;color:#92400e;background:#fef3c7;border-radius:4px;padding:3px 8px;margin-top:3px;width:100%">
<i class="fas fa-comment-alt me-1"></i>${escHtml(c.cierre_anticipado.motivo)}
</div>` : '';
return `<div class="consent-row ${c.estado}" data-consent-id="${c.id}" style="flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:.4rem;width:100%">
<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}${btnQuitar}</div>
</div>
${motivoRow}
</div>`;
}).join('');
}
+12 -6
View File
@@ -1960,11 +1960,12 @@ async function guardarSolicitud() {
// ── Labels / iconos para estados ─────────────────────────────
const CONSENT_META = {
firmado : { cls:'firmado', ico:'fa-check-circle', lbl:'Firmado' },
enviado : { cls:'enviado', ico:'fa-envelope', lbl:'Enviado' },
visto : { cls:'visto', ico:'fa-eye', lbl:'Visto' },
rechazado : { cls:'rechazado', ico:'fa-ban', lbl:'Rechazado' },
pendiente : { cls:'pendiente', ico:'fa-clock', lbl:'Pendiente' },
firmado : { cls:'firmado', ico:'fa-check-circle', lbl:'Firmado' },
enviado : { cls:'enviado', ico:'fa-envelope', lbl:'Enviado' },
visto : { cls:'visto', ico:'fa-eye', lbl:'Visto' },
rechazado : { cls:'rechazado', ico:'fa-ban', lbl:'Rechazado' },
pendiente : { cls:'pendiente', ico:'fa-clock', lbl:'Pendiente' },
cerrado_anticipado : { cls:'rechazado', ico:'fa-exclamation-triangle', lbl:'Cerrado anticipadamente' },
};
function renderConsentimientos(lista) {
@@ -1977,7 +1978,7 @@ function renderConsentimientos(lista) {
}
el.innerHTML = lista.map(c => {
const m = CONSENT_META[c.estado] || CONSENT_META.pendiente;
const ya = ['firmado','rechazado'].includes(c.estado);
const ya = ['firmado','rechazado','cerrado_anticipado'].includes(c.estado);
const token = escHtml(c.token || '');
const nom = escHtml(c.formulario_nombre || 'Consentimiento');
const fId = c.formulario_id;
@@ -2037,6 +2038,10 @@ function renderConsentimientos(lista) {
}
}
const motivoRec = (c.estado === 'cerrado_anticipado' && c.cierre_anticipado?.motivo)
? `<div style="font-size:.75rem;color:#92400e;background:#fef3c7;border-radius:4px;padding:3px 8px;margin-top:3px;width:100%">
<i class="fas fa-comment-alt me-1"></i>${escHtml(c.cierre_anticipado.motivo)}
</div>` : '';
return `<div class="consent-item ${m.cls}" style="flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:.5rem;width:100%">
<i class="fas ${m.ico}"></i>
@@ -2045,6 +2050,7 @@ function renderConsentimientos(lista) {
<div class="consent-acciones">${btnFirmar}${btnWa}</div>
</div>
${profRow}
${motivoRec}
</div>`;
}).join('');
}
+103 -3
View File
@@ -56,10 +56,23 @@ if ($modoTurnero) {
// ── POST: guardar firma y marcar como firmado ──────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json; charset=utf-8');
if ($tcRow['estado'] === 'firmado') {
echo json_encode(['ok' => false, 'error' => 'Este consentimiento ya fue firmado']); exit;
if (in_array($tcRow['estado'], ['firmado', 'cerrado_anticipado'])) {
echo json_encode(['ok' => false, 'error' => 'Este consentimiento ya fue finalizado']); exit;
}
$input = json_decode(file_get_contents('php://input'), true) ?? [];
// Cierre anticipado de toma progresiva con justificación
if (isset($input['mp_cerrar_anticipado'])) {
$motivo = trim($input['motivo'] ?? '');
if (!$motivo) { echo json_encode(['ok' => false, 'error' => 'Motivo requerido']); exit; }
$existing = json_decode($tcRow['datos_respuestas'] ?? '{}', true) ?: [];
$existing['_cierre_anticipado'] = ['motivo' => $motivo, 'cerrado_at' => date('Y-m-d H:i:s')];
$db->getConnection()
->prepare("UPDATE turnero_consentimientos SET estado='cerrado_anticipado', firmado_at=NOW(), datos_respuestas=? WHERE token=?")
->execute([json_encode($existing, JSON_UNESCAPED_UNICODE), $tokenTurnero]);
require_once __DIR__ . '/modules/turnero/api/_helpers.php';
notificarSSE((int)$tcRow['sesion_id']);
echo json_encode(['ok' => true, 'mp_cerrado' => true]); exit;
}
// Borrador: guardar campos sin firma (auto-save desde el formulario)
// Muestras Prolongadas: marcar completo sin SVG global
if (isset($input['mp_completar'])) {
@@ -226,7 +239,7 @@ $esquema = json_decode($envio['esquema'], true) ?? [];
$datosCliente = json_decode($envio['datos_cliente'] ?? '{}', true) ?? [];
$datosPrefilled = json_decode($envio['datos_prefilled'] ?? '{}', true) ?? [];
$todos = array_merge($datosPrefilled, $datosCliente);
$modoEditar = $modoTurnero && $envio['estado'] !== 'firmado';
$modoEditar = $modoTurnero && !in_array($envio['estado'], ['firmado', 'cerrado_anticipado']);
$embebido = isset($_GET['embed']) && $_GET['embed'] === '1';
$compact = $embebido && isset($_GET['compact']);
$autoprint = isset($_GET['autoprint']) && $_GET['autoprint'] === '1';
@@ -1359,6 +1372,22 @@ function _addExamWizardHtml(string $cid): string {
</div>
<div class="turnero-msg mt-2 small"></div>
</div>
<?php elseif ($modoTurnero && $envio['estado'] === 'cerrado_anticipado'): ?>
<?php $_ca = $datosCliente['_cierre_anticipado'] ?? null; ?>
<div class="alert alert-warning mt-4 d-flex align-items-start gap-3 no-print" style="border-left:4px solid #f59e0b">
<i class="fas fa-exclamation-triangle fs-4 flex-shrink-0 mt-1 text-warning"></i>
<div>
<strong>Toma cerrada anticipadamente</strong><br>
<?php if (!empty($_ca['motivo'])): ?>
<div class="mt-1 text-secondary" style="font-size:.9rem">
<i class="fas fa-comment-alt me-1"></i><?= esc2($_ca['motivo']) ?>
</div>
<?php endif; ?>
<button onclick="window.close()" class="btn btn-outline-secondary btn-sm fw-semibold mt-2">
<i class="fas fa-times me-1"></i>Cerrar ventana
</button>
</div>
</div>
<?php elseif ($modoTurnero && $envio['estado'] === 'firmado'): ?>
<div class="alert alert-success mt-4 d-flex align-items-start gap-3 no-print">
<i class="fas fa-check-circle fs-4 flex-shrink-0 mt-1"></i>
@@ -1377,6 +1406,29 @@ function _addExamWizardHtml(string $cid): string {
</div>
<?php endif; ?>
<?php if ($modoTurnero && $embebido && !empty($_mpMap) && !in_array($envio['estado'], ['firmado','cerrado_anticipado'])): ?>
<div class="mt-3 no-print text-end">
<button class="btn btn-outline-danger btn-sm" id="btn-cerrar-anticip">
<i class="fas fa-times-circle me-1"></i>Cerrar anticipadamente
</button>
</div>
<!-- Modal cierre anticipado -->
<div id="modal-cerrar-anticip" class="d-none" style="position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center">
<div style="background:#fff;border-radius:12px;padding:1.5rem;max-width:380px;width:94%;box-shadow:0 8px 32px rgba(0,0,0,.25)">
<h6 class="fw-bold mb-2"><i class="fas fa-exclamation-triangle text-warning me-2"></i>Cerrar anticipadamente</h6>
<p class="text-muted small mb-3">Explique por qué no se completaron todas las tomas. Este mensaje quedará visible en toma de muestras y recepción.</p>
<textarea id="mca-motivo" rows="3" class="form-control mb-2" placeholder="Ej: El paciente no toleró más extracciones…"></textarea>
<div id="mca-error" class="text-danger small mb-2 d-none"></div>
<div class="d-flex gap-2 justify-content-end">
<button class="btn btn-outline-secondary btn-sm" id="mca-cancelar">Cancelar</button>
<button class="btn btn-danger btn-sm fw-semibold" id="mca-confirmar">
<i class="fas fa-times-circle me-1"></i>Confirmar cierre
</button>
</div>
</div>
</div>
<?php endif; ?>
</div><!-- /doc-body -->
<div class="doc-footer">
@@ -2586,6 +2638,54 @@ document.querySelectorAll('.turnero-firma-item').forEach(function(widget) {
});
})();
<?php endif; ?>
<?php if ($modoTurnero && $embebido && !empty($_mpMap) && !in_array($envio['estado'], ['firmado','cerrado_anticipado'])): ?>
(function() {
var btn = document.getElementById('btn-cerrar-anticip');
var modal = document.getElementById('modal-cerrar-anticip');
if (!btn || !modal) return;
btn.addEventListener('click', function() {
document.getElementById('mca-motivo').value = '';
document.getElementById('mca-error').classList.add('d-none');
modal.style.display = 'flex';
setTimeout(function() { document.getElementById('mca-motivo').focus(); }, 50);
});
document.getElementById('mca-cancelar').addEventListener('click', function() {
modal.style.display = 'none';
});
document.getElementById('mca-confirmar').addEventListener('click', function() {
var motivo = document.getElementById('mca-motivo').value.trim();
var errEl = document.getElementById('mca-error');
if (!motivo) { errEl.textContent = 'El motivo es obligatorio.'; errEl.classList.remove('d-none'); return; }
errEl.classList.add('d-none');
var confirmBtn = this;
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando...';
fetch(window.location.href, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mp_cerrar_anticipado: true, motivo: motivo })
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
modal.style.display = 'none';
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
location.reload();
} else {
errEl.textContent = d.error || 'Error al guardar.';
errEl.classList.remove('d-none');
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i class="fas fa-times-circle me-1"></i>Confirmar cierre';
}
})
.catch(function() {
errEl.textContent = 'Error de conexión.';
errEl.classList.remove('d-none');
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i class="fas fa-times-circle me-1"></i>Confirmar cierre';
});
});
})();
<?php endif; ?>
</script>
<!-- ── Topaz SigWeb overlay ───────────────────────────────────────── -->