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:
co-authored by
Claude Sonnet 4.6
parent
4de9c2f9b4
commit
8760b685ad
+355
-79
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user