feat(turnero): dispositivos por IP, firmas, formularios y muestras prolongadas

- login.php: detectar dispositivo por IP al autenticar y redirigir al lugar asignado
- dashboard.php / lugar.php / recepcion.php: bloquear acceso por dispositivo (IP→lugar)
- lugar.php: modal firma profesional pre-guardada; compact 2-col en iframe; botón WA
  solo cuando el formulario requiere firma del paciente
- ver_formulario_enviado.php: modo compact tablet, auto-save borrador (merge), firma
  profesional en 1 clic, Muestras Prolongadas (countdown + Finalizar), auto_profesional
  pre-fill desde sesión, fecha→fecha_hoy en campos de toma/recepción
- get_consentimientos.php: añadir requiere_firma_paciente para controlar botón WA
- save_firma_profesional.php: API para guardar firma pre-configurada del profesional
- migrations: turnero_dispositivos, admin_users.firma_svg, clon VIH F-LAB-05 para turnero

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-07 21:58:52 -05:00
co-authored by Claude Sonnet 4.6
parent 4de9c2f9b4
commit 8760b685ad
10 changed files with 706 additions and 91 deletions
+355 -79
View File
@@ -55,6 +55,25 @@ if ($modoTurnero) {
echo json_encode(['ok' => false, 'error' => 'Este consentimiento ya fue firmado']); exit;
}
$input = json_decode(file_get_contents('php://input'), true) ?? [];
// Borrador: guardar campos sin firma (auto-save desde el formulario)
// Muestras Prolongadas: marcar completo sin SVG global
if (isset($input['mp_completar'])) {
$dr = is_array($input['datos_respuestas'] ?? null) ? $input['datos_respuestas'] : [];
$existing = json_decode($tcRow['datos_respuestas'] ?? '{}', true) ?: [];
$db->getConnection()
->prepare("UPDATE turnero_consentimientos SET estado='firmado', firmado_at=NOW(), datos_respuestas=? WHERE token=?")
->execute([json_encode(array_merge($existing, $dr), JSON_UNESCAPED_UNICODE), $tokenTurnero]);
require_once __DIR__ . '/modules/turnero/api/_helpers.php';
notificarSSE((int)$tcRow['sesion_id']);
echo json_encode(['ok' => true, 'mp_completado' => true]); exit;
}
if (!array_key_exists('firma_svg', $input) && isset($input['datos_respuestas']) && is_array($input['datos_respuestas'])) {
$existing = json_decode($tcRow['datos_respuestas'] ?? '{}', true) ?: [];
$db->getConnection()
->prepare("UPDATE turnero_consentimientos SET datos_respuestas=? WHERE token=? AND estado='pendiente'")
->execute([json_encode(array_merge($existing, $input['datos_respuestas']), JSON_UNESCAPED_UNICODE), $tokenTurnero]);
echo json_encode(['ok' => true, 'draft' => true]); exit;
}
$firmaSvg = trim($input['firma_svg'] ?? '');
if (strlen($firmaSvg) < 100) {
echo json_encode(['ok' => false, 'error' => 'Firma requerida']); exit;
@@ -199,10 +218,59 @@ $datosPrefilled = json_decode($envio['datos_prefilled'] ?? '{}', true) ?? [];
$todos = array_merge($datosPrefilled, $datosCliente);
$modoEditar = $modoTurnero && $envio['estado'] !== 'firmado';
$embebido = isset($_GET['embed']) && $_GET['embed'] === '1';
$compact = $embebido && isset($_GET['compact']);
// Firma pre-guardada del profesional logueado (para botón de 1 clic)
$firmaProfPreguardada = null;
if ($modoTurnero && $embebido && isUserLoggedIn()) {
$uid = (int)($_SESSION['admin_user']['id'] ?? 0);
if ($uid) {
try {
$stmt = $db->getConnection()->prepare("SELECT firma_svg FROM admin_users WHERE id = ? LIMIT 1");
$stmt->execute([$uid]);
$firmaProfPreguardada = $stmt->fetchColumn() ?: null;
} catch (\Throwable $_) {}
}
}
// Pre-scan: formulario que solo requiere firma del profesional (sin firma paciente)
$_soloFirmaPro = !empty(array_filter($esquema, fn($c) => ($c['tipo'] ?? '') === 'firma_profesional'))
&& empty(array_filter($esquema, fn($c) => ($c['tipo'] ?? '') === 'firma'));
// Muestras Prolongadas: detectar secciones hora+firma_profesional con intervalos de minutos
// Agrupa por prefijo de sección (ej. "Taukit", "Heliprobe") para calcular esperas correctas
$_mpMap = [];
if ($modoTurnero && $embebido && $_soloFirmaPro) {
$_mpSecs = []; $_mpLabel = null; $_mpMin = null; $_mpHora = null;
foreach ($esquema as $_c) {
$_t = $_c['tipo'] ?? '';
if ($_t === 'separador') {
$_mpLabel = $_c['label'] ?? ''; $_mpMin = null; $_mpHora = null;
if (preg_match('/[Mm]inuto\s+(\d+)/u', $_mpLabel, $_mx)) $_mpMin = (int)$_mx[1];
} elseif ($_t === 'hora' && $_mpMin !== null) {
$_mpHora = $_c['id'] ?? null;
} elseif ($_t === 'firma_profesional' && $_mpHora !== null && $_mpMin !== null) {
$_prefix = trim(explode('·', $_mpLabel)[0] ?? '');
$_mpSecs[] = ['label' => $_mpLabel, 'min' => $_mpMin, 'hora' => $_mpHora, 'firma' => $_c['id'], 'tipo' => $_prefix];
$_mpHora = null;
}
}
$_mpGroups = [];
foreach ($_mpSecs as $_s) { $_mpGroups[$_s['tipo']][] = $_s; }
foreach ($_mpGroups as $_tipo => $_secs) {
for ($i = 0, $n = count($_secs); $i < $n; $i++) {
$_s = $_secs[$i]; $_nx = $_secs[$i + 1] ?? null;
$_mpMap[$_s['firma']] = [
'hora_campo' => $_s['hora'],
'esperar_min' => $_nx ? ($_nx['min'] - $_s['min']) : 0,
'next_label' => $_nx ? $_nx['label'] : null,
'next_firma_id' => $_nx ? $_nx['firma'] : null,
'is_last' => !$_nx,
'exam_type' => $_tipo,
];
}
}
}
// Mapa id → label
$labelMap = [];
foreach ($esquema as $c) {
@@ -337,6 +405,14 @@ function esc2(mixed $v): string {
.alert { font-size: 12px; padding: 6px 10px; }
<?php endif; ?>
<?php if ($compact): ?>
/* ── Modo compact (tablet, 2 columnas) ── */
@media (min-width: 480px) {
.campos-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 14px; align-items: start; }
.campos-grid .campo-full { grid-column: span 2; }
}
<?php endif; ?>
/* ── Canvas firma profesional ───────────────────── */
.firma-pro-widget { max-width: 520px; margin-top: 8px; }
.fpw-canvas { border: 2px solid #198754; border-radius: 8px; background: #f8fff9;
@@ -391,6 +467,7 @@ function esc2(mixed $v): string {
background:#fff; color:#0288d1; cursor:pointer; font-weight:600;
display:inline-flex; align-items:center; gap:5px; transition:all .15s; }
.btn-topaz:hover { background:#e0f2fe; }
@keyframes mp-pulse { 0%,100%{opacity:1} 50%{opacity:.65} }
</style>
</head>
<body>
@@ -534,10 +611,11 @@ function esc2(mixed $v): string {
$_esquemaTieneFirmaAlguna = false; // true si hay cualquier campo firma/firma_profesional
$_firmaGlobalPacienteUsada = false; // la firma global solo va al primer campo firma
if ($compact): ?><div class="campos-grid"><?php endif;
foreach ($esquema as $campo):
$tipo = $campo['tipo'] ?? '';
if ($tipo === 'separador'): ?>
<div class="esquema-sep"><?= esc2($campo['label'] ?? '') ?></div>
<div class="esquema-sep<?= $compact ? ' campo-full' : '' ?>"><?= esc2($campo['label'] ?? '') ?></div>
<?php continue; endif;
// ── Firmas inline: cada campo muestra su propia firma ──
@@ -555,7 +633,9 @@ function esc2(mixed $v): string {
// La firma profesional global solo se muestra en el PRIMER campo firma_profesional.
// Campos posteriores (ej. desistimiento) solo muestran su propia firma por campo.
$fSvg = $isPro
? (!$_renderedFirmaProfesional ? ($firmaSharedProfesional ?? $datosCliente[$cid . '_svg'] ?? null) : ($datosCliente[$cid . '_svg'] ?? null))
? (empty($_mpMap) && !$_renderedFirmaProfesional
? ($firmaSharedProfesional ?? $datosCliente[$cid . '_svg'] ?? null)
: ($datosCliente[$cid . '_svg'] ?? null))
: ($datosCliente[$cid . '_svg'] ?? (!$_firmaGlobalPacienteUsada ? $firmaSharedPaciente : null) ?? null);
$fFoto = $datosCliente[$cid . '_foto'] ?? null;
$fIcon = $isPro ? 'fa-user-md' : 'fa-signature';
@@ -571,13 +651,14 @@ function esc2(mixed $v): string {
}
}
$yaHayCanvasPro = $isPro && $_renderedFirmaProfesional;
$yaHayCanvasPro = $isPro && $_renderedFirmaProfesional && empty($_mpMap);
if ($isPro) $_renderedFirmaProfesional = true;
if (!$isPro && $fSvg) {
$_renderedFirmaPaciente = true;
$_firmaGlobalPacienteUsada = true;
}
?>
<?php if ($compact): ?><div class="campo-full"><?php endif; ?>
<div class="section-title mt-3" style="color:<?= $fColor ?>">
<i class="fas <?= $fIcon ?> me-1"></i><?= $fLabel ?>
</div>
@@ -616,12 +697,26 @@ function esc2(mixed $v): string {
($modoTurnero && $embebido && isUserLoggedIn() && $_soloFirmaPro && $modoEditar)
);
if ($_mostrarCanvasPro): ?>
<?php if ($firmaProfPreguardada): ?>
<script>window._fpwPreFirma = <?= json_encode($firmaProfPreguardada) ?>;</script>
<?php endif; ?>
<!-- Canvas del profesional -->
<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'] : '' ?>">
<?php if ($firmaProfPreguardada): ?>
<div class="fpw-oneclic">
<button class="btn btn-success fpw-oneclic-btn w-100" style="font-size:1rem;padding:.55rem 1rem">
<i class="fas fa-check-circle me-2"></i>Firmar consentimiento
</button>
<div class="text-center mt-1">
<a href="#" class="small text-muted fpw-usar-otra">✎ Usar otra firma</a>
</div>
</div>
<div class="fpw-canvas-wrap" style="display:none">
<?php endif; ?>
<p class="text-muted small mb-2"><i class="fas fa-pen me-1"></i>Dibuje su firma en el recuadro:</p>
<canvas class="fpw-canvas" width="500" height="150"></canvas>
<div class="mt-2 d-flex gap-2 flex-wrap">
@@ -631,6 +726,7 @@ function esc2(mixed $v): string {
</button>
<button class="btn btn-success btn-sm fpw-save"><i class="fas fa-check me-1"></i>Guardar firma</button>
</div>
<?php if ($firmaProfPreguardada): ?></div><?php endif; ?>
<div class="fpw-msg mt-2 small"></div>
</div>
<?php elseif ($modoPublico): ?>
@@ -640,13 +736,14 @@ function esc2(mixed $v): string {
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($compact): ?></div><?php endif; ?>
<?php continue; endif;
// ── Parrafo estático ──────────────────────────────────
if ($tipo === 'parrafo'):
$ws = !empty($campo['flujoLibre']) ? 'normal' : 'pre-wrap';
?>
<div class="mb-3" style="font-size:.88rem;line-height:1.75;color:#444;text-align:justify;white-space:<?= $ws ?>"><?= htmlspecialchars($campo['contenido'] ?? '', ENT_QUOTES) ?></div>
<div class="mb-3<?= $compact ? ' campo-full' : '' ?>" style="font-size:.88rem;line-height:1.75;color:#444;text-align:justify;white-space:<?= $ws ?>"><?= htmlspecialchars($campo['contenido'] ?? '', ENT_QUOTES) ?></div>
<?php continue; endif;
// ── Parrafo inline (texto con marcadores {key}) ───────
@@ -659,7 +756,7 @@ function esc2(mixed $v): string {
// Convertir saltos de línea a <br>
$rendered = nl2br(htmlspecialchars($rendered, ENT_QUOTES));
?>
<div class="mb-3" style="font-size:.88rem;line-height:2;color:#444;text-align:justify"><?= $rendered ?></div>
<div class="mb-3<?= $compact ? ' campo-full' : '' ?>" style="font-size:.88rem;line-height:2;color:#444;text-align:justify"><?= $rendered ?></div>
<?php continue; endif;
$cid = $campo['id'] ?? null;
@@ -668,6 +765,14 @@ function esc2(mixed $v): string {
// ── Modo editar (turnero o envío normal): campos interactivos ──
if ($modoEditar):
$prefill = $todos[$cid] ?? '';
// Auto-llenar campos del profesional desde la sesión
if ($prefill === '' && !empty($campo['auto_profesional']) && isUserLoggedIn()) {
$prefill = match($campo['auto_profesional']) {
'nombre' => $_SESSION['admin_user']['full_name'] ?? '',
'cargo' => $_SESSION['admin_user']['cargo'] ?? '',
default => ''
};
}
$label = esc2($campo['label'] ?? $cid);
$req = !empty($campo['required']) ? ' required' : '';
if ($tipo === 'linked'):
@@ -682,7 +787,7 @@ function esc2(mixed $v): string {
<?php endif; continue; endif; // linked
if ($tipo === 'textarea'):
?>
<div class="campo-edit">
<div class="campo-edit<?= $compact ? ' campo-full' : '' ?>">
<label><?= $label ?></label>
<textarea class="form-control form-control-sm" name="<?= esc2($cid) ?>"
rows="3"<?= $req ?>><?= esc2($prefill) ?></textarea>
@@ -691,7 +796,7 @@ function esc2(mixed $v): string {
if ($tipo === 'radio'):
$opts = $campo['opciones'] ?? $campo['options'] ?? [];
?>
<div class="campo-edit">
<div class="campo-edit<?= ($compact && count($opts) > 3) ? ' campo-full' : '' ?>">
<label><?= $label ?></label>
<?php foreach ($opts as $opt): ?>
<div class="form-check">
@@ -708,7 +813,7 @@ function esc2(mixed $v): string {
$checkedArr = is_array($prefill) ? $prefill
: (is_string($prefill) && $prefill !== '' ? (json_decode($prefill, true) ?: [$prefill]) : []);
?>
<div class="campo-edit">
<div class="campo-edit<?= $compact ? ' campo-full' : '' ?>">
<label><?= $label ?></label>
<?php foreach ($opts as $opt): ?>
<div class="form-check">
@@ -788,6 +893,7 @@ function esc2(mixed $v): string {
<div class="campo-valor"><?= esc2($display) ?></div>
</div>
<?php endforeach; ?>
<?php if ($compact): ?></div><?php endif; ?>
<!-- Firma digital global (solo si el esquema no tiene campo firma propio) -->
<?php if ($envio['firma_svg'] && !$_renderedFirmaPaciente): ?>
@@ -913,6 +1019,196 @@ function esc2(mixed $v): string {
</div><!-- /doc-wrap -->
<script>
<?php if (!empty($_mpMap)): ?>
window._muestrasMap = <?= json_encode($_mpMap, JSON_UNESCAPED_UNICODE) ?>;
window._mpSavedFirmas = {};
<?php endif; ?>
/* ── Firma del profesional: función compartida canvas/1-clic ────── */
function _guardarFirmaPro(widget, svg, msgEl) {
const turnoId = widget.dataset.turno;
const formularioId = widget.dataset.formulario;
const envioId = parseInt(widget.dataset.envio, 10);
const campoId = widget.dataset.campo;
const _mpEntry = window._muestrasMap && window._muestrasMap[campoId];
if (_mpEntry) { _guardarFirmaMP(widget, svg, msgEl, _mpEntry, campoId); return; }
const soloPro = widget.dataset.soloPro === '1';
const isTurnero = !!(turnoId && formularioId);
const saveUrl = isTurnero
? 'modules/turnero/api/firmar_profesional_consentimiento.php'
: 'api/lab/firmar_profesional.php';
var datosRespuestas = {};
if (soloPro) {
document.querySelectorAll('[name]').forEach(function(el) {
var raw = el.name, isArr = raw.slice(-2) === '[]', name = isArr ? raw.slice(0,-2) : raw;
if (el.type==='checkbox') { if(el.checked){ if(!Array.isArray(datosRespuestas[name])) datosRespuestas[name]=[]; datosRespuestas[name].push(el.value); } }
else if (el.type==='radio') { if(el.checked) datosRespuestas[name]=el.value; }
else if (el.value!=='') datosRespuestas[name]=el.value;
});
}
const payload = isTurnero
? { turno_id: parseInt(turnoId), formulario_id: parseInt(formularioId), svg,
...(soloPro ? { solo_profesional: true, datos_respuestas: datosRespuestas } : {}) }
: { envio_id: envioId, campo_id: campoId, svg };
fetch(saveUrl, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) })
.then(r => r.json())
.then(function(data) {
if (data.ok) {
if (soloPro) {
document.querySelectorAll('.firma-pro-widget, .campo-edit, .section-title').forEach(el => el.style.display='none');
var ok = document.createElement('div');
ok.className = 'alert alert-success mt-4 d-flex align-items-center gap-2 no-print';
ok.innerHTML = '<i class="fas fa-check-circle fs-4"></i><div><strong>Firmado correctamente.</strong><br>Puede cerrar esta ventana.</div>';
widget.parentNode.insertBefore(ok, widget.nextSibling);
widget.style.display = 'none';
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
} else {
var img = document.createElement('img');
img.src = svg; img.alt = 'Firma profesional';
img.style.cssText = 'max-height:140px;max-width:340px;display:block';
var box = document.createElement('div');
box.className = 'firma-box'; box.style.borderColor = '#198754';
box.appendChild(img);
widget.replaceWith(box);
}
} else {
if (msgEl) msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error||'Error al guardar') + '</span>';
widget.querySelectorAll('button').forEach(b => { b.disabled=false; });
var btnOC = widget.querySelector('.fpw-oneclic-btn');
if (btnOC) btnOC.innerHTML = '<i class="fas fa-check-circle me-2"></i>Firmar consentimiento';
}
})
.catch(function() {
if (msgEl) msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>Error de conexión.</span>';
widget.querySelectorAll('button').forEach(b => { b.disabled=false; });
});
}
/* ── Muestras Prolongadas: guardar firma por campo + countdown ───── */
function _guardarFirmaMP(widget, svg, msgEl, entry, campoId) {
var dr = {};
document.querySelectorAll('[name]').forEach(function(el) {
var n = el.name.endsWith('[]') ? el.name.slice(0,-2) : el.name;
if (el.type==='checkbox'){ if(el.checked){ if(!Array.isArray(dr[n]))dr[n]=[]; dr[n].push(el.value); } }
else if (el.type==='radio'){ if(el.checked) dr[n]=el.value; }
else if (el.value!=='') dr[n]=el.value;
});
Object.assign(dr, window._mpSavedFirmas || {});
dr[campoId + '_svg'] = svg;
(window._mpSavedFirmas = window._mpSavedFirmas||{})[campoId + '_svg'] = svg;
fetch(window.location.href, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ datos_respuestas: dr })
})
.then(function(r){ return r.json(); })
.then(function(data) {
if (!data.ok) {
if (msgEl) msgEl.innerHTML = '<span class="text-danger">Error al guardar</span>';
widget.querySelectorAll('button').forEach(function(b){ b.disabled=false; });
var bc = widget.querySelector('.fpw-oneclic-btn');
if (bc) bc.innerHTML = '<i class="fas fa-check-circle me-2"></i>Firmar consentimiento';
return;
}
// Mostrar firma guardada en lugar del widget
var img = document.createElement('img');
img.src = svg; img.style.cssText = 'max-height:80px;max-width:240px;display:block';
var box = document.createElement('div');
box.className = 'firma-box'; box.style.borderColor = '#198754';
box.appendChild(img);
widget.replaceWith(box);
if (!entry.is_last && entry.esperar_min > 0) {
_mpIniciarCountdown(entry.esperar_min, entry.next_label, entry.next_firma_id);
} else if (!entry.is_last) {
var nw = entry.next_firma_id ? document.getElementById('fpw-' + entry.next_firma_id) : document.querySelector('.firma-pro-widget');
if (nw) nw.scrollIntoView({ behavior:'smooth', block:'center' });
} else {
_mpMostrarFinalizar(entry.exam_type);
}
})
.catch(function() {
if (msgEl) msgEl.innerHTML = '<span class="text-danger">Error de conexión.</span>';
});
}
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';
document.querySelector('.doc-body').appendChild(box);
}
if (box._mpTick) clearInterval(box._mpTick);
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');
if (nw) {
nw.scrollIntoView({ behavior:'smooth', block:'center' });
nw.style.outline = '3px solid #dc3545';
nw.style.borderRadius = '6px';
}
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>';
}, 1000);
}
function _mpMostrarFinalizar(examType) {
if (document.getElementById('mp-finalizar')) return;
var cd = document.getElementById('mp-countdown');
if (cd) { clearInterval(cd._mpTick); cd.remove(); }
var wrap = document.createElement('div');
wrap.id = 'mp-finalizar';
wrap.style.cssText = 'position:sticky;bottom:12px;margin:12px 0;text-align:center;z-index:50';
var btn = document.createElement('button');
btn.className = 'btn btn-success fw-bold px-4 py-2';
btn.style.fontSize = '1rem';
btn.innerHTML = '<i class="fas fa-check-double me-2"></i>Finalizar toma · ' + (examType || 'Muestras');
btn.addEventListener('click', function() {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Guardando...';
var dr = {};
document.querySelectorAll('[name]').forEach(function(el) {
var n = el.name.endsWith('[]') ? el.name.slice(0,-2) : el.name;
if (el.type==='checkbox'){ if(el.checked){ if(!Array.isArray(dr[n]))dr[n]=[]; dr[n].push(el.value); } }
else if (el.type==='radio'){ if(el.checked) dr[n]=el.value; }
else if (el.value!=='') dr[n]=el.value;
});
Object.assign(dr, window._mpSavedFirmas || {});
fetch(window.location.href, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ mp_completar: true, datos_respuestas: dr })
})
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.ok) {
wrap.innerHTML = '<div class="alert alert-success py-2 mb-0"><i class="fas fa-check-circle me-2"></i>Toma completada correctamente.</div>';
try { window.parent.postMessage({ type:'turneroFirmado' }, '*'); } catch(e) {}
}
})
.catch(function(){});
});
wrap.appendChild(btn);
document.querySelector('.doc-body').appendChild(wrap);
}
/* ── Canvas firma del profesional ───────────────────────────────── */
(function () {
document.querySelectorAll('.firma-pro-widget').forEach(function (widget) {
@@ -965,6 +1261,26 @@ function esc2(mixed $v): string {
topaz.activar({ canvas: canvas, onAccept: function() {} });
});
// ── Botón 1 clic: firma pre-guardada ──────────────────────
const btnOneClic = widget.querySelector('.fpw-oneclic-btn');
const btnUsarOtra = widget.querySelector('.fpw-usar-otra');
if (btnOneClic) {
btnOneClic.addEventListener('click', function() {
const preFirma = window._fpwPreFirma;
if (!preFirma) return;
btnOneClic.disabled = true;
btnOneClic.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Firmando...';
_guardarFirmaPro(widget, preFirma, msg);
});
}
if (btnUsarOtra) {
btnUsarOtra.addEventListener('click', function(e) {
e.preventDefault();
widget.querySelector('.fpw-oneclic').style.display = 'none';
widget.querySelector('.fpw-canvas-wrap').style.display = '';
});
}
btnSave.addEventListener('click', function() {
const svg = canvas.toDataURL('image/png');
if (svg.length < 1000) {
@@ -974,77 +1290,7 @@ function esc2(mixed $v): string {
btnSave.disabled = true;
btnSave.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando...';
msg.textContent = '';
const turnoId = widget.dataset.turno;
const formularioId = widget.dataset.formulario;
const soloPro = widget.dataset.soloPro === '1';
const isTurnero = !!(turnoId && formularioId);
const saveUrl = isTurnero
? 'modules/turnero/api/firmar_profesional_consentimiento.php'
: 'api/lab/firmar_profesional.php';
// Recopilar campos del formulario cuando el pro es el firmante final
var datosRespuestas = {};
if (soloPro) {
document.querySelectorAll('[name]').forEach(function(el) {
var raw = el.name;
var isArr = raw.slice(-2) === '[]';
var name = isArr ? raw.slice(0, -2) : raw;
if (el.type === 'checkbox') {
if (el.checked) { if (!Array.isArray(datosRespuestas[name])) datosRespuestas[name] = []; datosRespuestas[name].push(el.value); }
} else if (el.type === 'radio') {
if (el.checked) datosRespuestas[name] = el.value;
} else if (el.value !== '') {
datosRespuestas[name] = el.value;
}
});
}
const savePayload = isTurnero
? { turno_id: parseInt(turnoId), formulario_id: parseInt(formularioId), svg: svg,
...(soloPro ? { solo_profesional: true, datos_respuestas: datosRespuestas } : {}) }
: { envio_id: envioId, campo_id: campoId, svg: svg };
fetch(saveUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(savePayload)
})
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.ok) {
if (soloPro) {
// Modo solo-profesional: mostrar éxito y notificar al padre
document.querySelectorAll('.firma-pro-widget, .campo-edit, .section-title').forEach(function(el) {
el.style.display = 'none';
});
var ok = document.createElement('div');
ok.className = 'alert alert-success mt-4 d-flex align-items-center gap-2 no-print';
ok.innerHTML = '<i class="fas fa-check-circle fs-4"></i><div><strong>Firmado correctamente.</strong><br>Puede cerrar esta ventana.</div>';
widget.parentNode.insertBefore(ok, widget.nextSibling);
widget.style.display = 'none';
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
} else {
// Reemplazar canvas con imagen firmada
var img = document.createElement('img');
img.src = svg; img.alt = 'Firma profesional';
img.style.maxHeight = '140px'; img.style.maxWidth = '340px'; img.style.display = 'block';
var box = document.createElement('div');
box.className = 'firma-box'; box.style.borderColor = '#198754';
box.appendChild(img);
widget.replaceWith(box);
}
} else {
msg.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error||'Error al guardar') + '</span>';
btnSave.disabled = false;
btnSave.innerHTML = '<i class="fas fa-check me-1"></i>Guardar firma';
}
})
.catch(function() {
msg.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>Error de conexión.</span>';
btnSave.disabled = false;
btnSave.innerHTML = '<i class="fas fa-check me-1"></i>Guardar firma';
});
_guardarFirmaPro(widget, svg, msg);
});
});
})();
@@ -1302,6 +1548,36 @@ const topaz = (() => {
return { activar, cancelar, limpiarPad, aceptar };
})();
<?php if ($modoTurnero && $modoEditar): ?>
// ── Auto-save borrador (campo a campo, sin firma) ─────────────
(function() {
var _t;
function leerCampos() {
var c = {};
document.querySelectorAll('[name]').forEach(function(el) {
var n = el.name.endsWith('[]') ? el.name.slice(0, -2) : el.name;
if (el.type === 'checkbox') {
if (el.checked) { if (!Array.isArray(c[n])) c[n] = []; c[n].push(el.value); }
} else if (el.type === 'radio') {
if (el.checked) c[n] = el.value;
} else if (el.value !== '') {
c[n] = el.value;
}
});
return c;
}
function guardar() {
fetch(window.location.href, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ datos_respuestas: leerCampos() })
}).catch(function() {});
}
document.addEventListener('input', function() { clearTimeout(_t); _t = setTimeout(guardar, 1200); });
document.addEventListener('change', function() { clearTimeout(_t); _t = setTimeout(guardar, 400); });
})();
<?php endif; ?>
</script>
</body>
</html>