El botón WA del template apunta a form_cliente.php?t={{1}}; si el token
es UUID (turnero) se redirige al flujo de consentimiento correcto.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1186 lines
57 KiB
PHP
1186 lines
57 KiB
PHP
<?php
|
|
/**
|
|
* form_cliente.php — Página pública para que el cliente llene / firme el formulario
|
|
* NO REQUIERE AUTENTICACIÓN. Acceso via token: ?t=TOKEN
|
|
*/
|
|
$token = trim($_GET['t'] ?? '');
|
|
// Si el token es un UUID (viene del turnero vía plantilla WA), redirigir al flujo de consentimiento
|
|
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $token)) {
|
|
header('Location: ver_formulario_enviado.php?token=' . urlencode($token));
|
|
exit;
|
|
}
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
<title>Formulario</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
|
<style>
|
|
/* ── Base ──────────────────────────────────────── */
|
|
body { background:#f0f4ff; min-height:100vh; font-size:16px; }
|
|
.form-wrap { max-width:540px; margin:0 auto; padding:1rem; }
|
|
|
|
/* ── Header ────────────────────────────────────── */
|
|
.form-header { background:linear-gradient(135deg,#1565c0,#0288d1);
|
|
border-radius:12px 12px 0 0; padding:24px 20px; }
|
|
|
|
/* ── Card ───────────────────────────────────────── */
|
|
.form-card { background:#fff; border-radius:0 0 12px 12px;
|
|
padding:20px; box-shadow:0 4px 20px rgba(0,0,0,.12); }
|
|
|
|
/* ── Linked field (read-only) ───────────────────── */
|
|
.field-linked { background:#eef3ff; border-color:#93b4f7; }
|
|
/* ── Inline linked field dentro de párrafo ──────── */
|
|
.inline-linked {
|
|
display:inline-block;
|
|
border:none;
|
|
border-bottom:1.5px solid #555;
|
|
background:transparent;
|
|
min-width:80px;
|
|
font-size:inherit;
|
|
line-height:inherit;
|
|
color:#1a56db;
|
|
font-weight:600;
|
|
padding:0 3px;
|
|
vertical-align:baseline;
|
|
outline:none;
|
|
}
|
|
.inline-linked:empty { min-width:80px; }
|
|
.inline-linked[data-editable] { border-bottom-color:#1a56db; cursor:text; }
|
|
/* ── Firma canvas ───────────────────────────────── */
|
|
#firma-canvas { border:2px solid #1565c0; border-radius:8px;
|
|
cursor:crosshair; touch-action:none;
|
|
background:#fff; display:block; width:100%; height:150px; }
|
|
#firma-canvas.empty { border-style:dashed; border-color:#adb5bd; }
|
|
/* ── Firma inline por campo ──────────────────────────────── */
|
|
.fw-canvas { cursor:crosshair; touch-action:none;
|
|
background:#fff; display:block; width:100%; height:140px;
|
|
border-radius:8px; }
|
|
.fw-canvas.empty { border-style:dashed !important; }
|
|
/* ── Firma modos ─────────────────────────────── */
|
|
.firma-modo-btns { display:flex; gap:8px; margin-bottom:12px; }
|
|
.firma-modo-btn { flex:1; padding:7px 0; border:2px solid #dee2e6; border-radius:8px;
|
|
background:#f8f9fa; cursor:pointer; font-size:.85rem; font-weight:600;
|
|
color:#6c757d; transition:all .2s; text-align:center; }
|
|
.firma-modo-btn.activo { border-color:#1565c0; background:#e8f0fe; color:#1565c0; }
|
|
.firma-foto-drop { border:2px dashed #adb5bd; border-radius:8px; padding:28px 16px;
|
|
text-align:center; color:#6c757d; cursor:pointer;
|
|
transition:border-color .2s; background:#fff; display:block; }
|
|
.firma-foto-drop:hover, .firma-foto-drop.dragover { border-color:#1565c0; color:#1565c0; }
|
|
#firma-foto-preview { max-width:100%; max-height:160px; border-radius:8px;
|
|
border:2px solid #1565c0; display:none; margin-top:8px; object-fit:contain; }
|
|
|
|
/* ── Estado chips ────────────────────────────────── */
|
|
.chip-estado { display:inline-flex; align-items:center; gap:6px;
|
|
padding:6px 14px; border-radius:100px; font-size:.85rem;
|
|
font-weight:600; }
|
|
|
|
/* ── Success overlay ─────────────────────────────── */
|
|
.check-circle { width:80px; height:80px; border-radius:50%;
|
|
background:#e8f5e9; display:flex; align-items:center;
|
|
justify-content:center; margin:0 auto; }
|
|
@keyframes pop { 0%{transform:scale(0)} 80%{transform:scale(1.15)} 100%{transform:scale(1)} }
|
|
.check-circle svg { animation:pop .4s ease both; }
|
|
|
|
/* ── Error screen ─────────────────────────────────── */
|
|
|
|
/* ── Loading screen ──────────────────────────────── */
|
|
#loading-screen { min-height:50vh; display:flex; flex-direction:column;
|
|
align-items:center; justify-content:center; gap:12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="form-wrap">
|
|
|
|
<!-- ── LOADING ──────────────────────────────────────── -->
|
|
<div id="loading-screen">
|
|
<div class="spinner-border text-primary" style="width:3rem;height:3rem;"></div>
|
|
<p class="text-muted">Cargando formulario…</p>
|
|
</div>
|
|
|
|
<!-- ── ERROR ─────────────────────────────────────────── -->
|
|
<div id="error-screen" style="display:none">
|
|
<div class="text-center py-5">
|
|
<div style="font-size:4rem">😔</div>
|
|
<h5 id="err-title" class="mt-3 fw-bold text-danger">Formulario no disponible</h5>
|
|
<p id="err-msg" class="text-muted"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── YA FIRMADO (bloqueado) ───────────────────── -->
|
|
<div id="ya-firmado-screen" style="display:none">
|
|
<div class="form-header text-white" style="border-radius:12px 12px 0 0">
|
|
<!-- Encabezado empresa -->
|
|
<div id="yf-empresa-header" style="display:none;border-bottom:1px solid rgba(255,255,255,.3);margin-bottom:10px;padding-bottom:10px" class="d-flex align-items-center gap-2">
|
|
<img id="yf-empresa-logo" src="" alt="" style="max-height:40px;border-radius:5px;background:rgba(255,255,255,.2);padding:3px;display:none">
|
|
<div>
|
|
<div id="yf-empresa-nombre" class="fw-bold" style="font-size:.9rem"></div>
|
|
<div id="yf-empresa-subtitulo" class="opacity-75" style="font-size:.75rem"></div>
|
|
</div>
|
|
</div>
|
|
<h5 class="fw-bold mb-0 text-center">
|
|
<i class="fas fa-lock me-2"></i>Formulario ya completado
|
|
</h5>
|
|
</div>
|
|
<div class="form-card text-center">
|
|
<div class="signed-icon mx-auto mb-3">
|
|
<i class="fas fa-file-signature" style="font-size:2.5rem;color:#1565c0"></i>
|
|
</div>
|
|
<h5 class="fw-bold mb-1" id="yf-titulo">Formulario</h5>
|
|
<p class="text-muted small mb-3" id="yf-paciente"></p>
|
|
<span id="yf-estado-badge" class="badge mb-3" style="font-size:.85rem"></span>
|
|
|
|
<div id="yf-firma-thumb" style="display:none" class="mb-3">
|
|
<p class="small text-muted fw-semibold mb-1">Firma registrada:</p>
|
|
<img id="yf-firma-img" src="" alt="Firma"
|
|
class="border rounded p-2" style="max-height:120px;max-width:100%">
|
|
</div>
|
|
|
|
<a id="yf-pdf-btn" href="#" target="_blank"
|
|
class="btn btn-danger w-100 mb-2 fw-semibold">
|
|
<i class="fas fa-file-pdf me-2"></i>Ver documento firmado (PDF)
|
|
</a>
|
|
|
|
<div id="yf-hash-box" style="display:none" class="mt-3 text-start">
|
|
<p class="small text-muted mb-1"><i class="fas fa-shield-alt me-1"></i>Sello de integridad SHA-256:</p>
|
|
<code id="yf-hash" class="d-block" style="font-size:10px;word-break:break-all;
|
|
background:#f0f5ff;border:1px solid #c3d3f7;border-radius:4px;padding:6px 8px;
|
|
color:#1e3a6e">—</code>
|
|
</div>
|
|
|
|
<div class="alert alert-warning border mt-3 text-start small">
|
|
<i class="fas fa-lock me-1"></i>
|
|
Este formulario ya fue firmado y <strong>no puede editarse</strong>.
|
|
Una copia del documento fue registrada con sello digital.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── FORMULARIO ────────────────────────────────────── -->
|
|
<div id="form-screen" style="display:none">
|
|
<div class="form-header text-white mb-0" id="form-main-header">
|
|
<!-- Encabezado empresa (se muestra con config) -->
|
|
<div id="f-empresa-header" style="display:none;border-bottom:1px solid rgba(255,255,255,.3);margin-bottom:10px;padding-bottom:10px" class="d-flex align-items-center gap-2">
|
|
<img id="f-empresa-logo" src="" alt="" style="max-height:48px;border-radius:5px;background:rgba(255,255,255,.2);padding:3px;display:none">
|
|
<div>
|
|
<div id="f-empresa-nombre" class="fw-bold" style="font-size:.95rem"></div>
|
|
<div id="f-empresa-subtitulo" class="opacity-75" style="font-size:.78rem"></div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex align-items-start justify-content-between">
|
|
<div>
|
|
<h5 class="fw-bold mb-1" id="f-titulo">Formulario</h5>
|
|
<p class="mb-0 opacity-75 small" id="f-desc"></p>
|
|
</div>
|
|
<div id="f-badge"></div>
|
|
</div>
|
|
<div class="mt-3 pt-2 border-top border-white border-opacity-25 small opacity-75" id="f-paciente-info"></div>
|
|
</div>
|
|
|
|
<div class="form-card">
|
|
<!-- Nota para el paciente -->
|
|
<div class="alert alert-info py-2 small mb-3" id="instruccion-nota">
|
|
<i class="fas fa-info-circle me-1"></i>
|
|
Por favor, completa los campos y firma al final.
|
|
</div>
|
|
|
|
<!-- Campos del formulario -->
|
|
<form id="form-main" novalidate>
|
|
<div id="campos-container"></div>
|
|
|
|
<!-- ── Firma ─────────────────────────────── -->
|
|
<div id="firma-container" style="display:none" class="mt-4">
|
|
<hr>
|
|
<label class="form-label fw-semibold">
|
|
<i class="fas fa-signature me-1 text-primary"></i>
|
|
Firma digital
|
|
<span id="firma-req-badge" class="badge bg-danger ms-1 small" style="display:none">Requerida</span>
|
|
</label>
|
|
|
|
<!-- área canvas (dibujo) -->
|
|
<div id="firma-canvas-area">
|
|
<p class="text-muted small mb-2">✍️ Dibuja tu firma con el dedo o el mouse.</p>
|
|
<canvas id="firma-canvas" class="empty"></canvas>
|
|
<div class="d-flex gap-2 mt-2">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
|
onclick="firma.limpiarCanvas()">
|
|
<i class="fas fa-eraser me-1"></i>Limpiar firma
|
|
</button>
|
|
<span id="firma-status" class="small text-muted align-self-center">Sin firma</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- área foto -->
|
|
<div id="firma-foto-area" class="mt-3">
|
|
<p class="text-muted small mb-2">📷 Adjunta una foto de tu firma o documento.</p>
|
|
<label class="firma-foto-drop" id="firma-foto-drop" for="firma-foto-input">
|
|
<i class="fas fa-camera fa-2x d-block mb-2"></i>
|
|
Toca para abrir cámara o galería
|
|
</label>
|
|
<input type="file" id="firma-foto-input" accept="image/*" capture="environment"
|
|
class="d-none" onchange="firma._onFotoChange(this)">
|
|
<img id="firma-foto-preview" src="" alt="Vista previa de firma">
|
|
<div class="d-flex gap-2 mt-2">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
|
onclick="firma.limpiarFoto()">
|
|
<i class="fas fa-eraser me-1"></i>Quitar foto
|
|
</button>
|
|
<span id="firma-foto-status" class="small text-muted align-self-center">Sin foto</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Submit ─────────────────────────────── -->
|
|
<div class="d-grid mt-4">
|
|
<button type="button" class="btn btn-primary btn-lg fw-bold"
|
|
id="btn-enviar" onclick="formCliente.enviar()">
|
|
<i class="fas fa-check-circle me-2"></i>Enviar formulario
|
|
</button>
|
|
</div>
|
|
<p class="text-muted text-center small mt-2">
|
|
<i class="fas fa-lock me-1"></i>Tus datos están protegidos
|
|
</p>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── ÉXITO ─────────────────────────────────────────── -->
|
|
<div id="success-screen" style="display:none">
|
|
<div class="form-header text-white" style="border-radius:12px 12px 0 0">
|
|
<h5 class="fw-bold mb-0 text-center"><i class="fas fa-check-circle me-2"></i>¡Formulario enviado!</h5>
|
|
</div>
|
|
<div class="form-card text-center">
|
|
<div class="check-circle mt-2 mb-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 52 52">
|
|
<circle cx="26" cy="26" r="25" fill="none" stroke="#4caf50" stroke-width="2"/>
|
|
<path fill="none" stroke="#4caf50" stroke-width="4" stroke-linecap="round"
|
|
stroke-linejoin="round" d="M14 27 l7 7 l17-17"/>
|
|
</svg>
|
|
</div>
|
|
<h5 class="fw-bold">¡Gracias! 🎉</h5>
|
|
<p class="text-muted" id="success-msg">Tu respuesta ha sido registrada correctamente.</p>
|
|
<div id="success-firma-thumb" class="mt-3" style="display:none">
|
|
<p class="small text-muted fw-semibold">Firma registrada:</p>
|
|
<img id="success-firma-img" src="" alt="Firma"
|
|
class="border rounded p-2" style="max-height:120px;max-width:100%">
|
|
</div>
|
|
|
|
<!-- Link PDF -->
|
|
<div id="success-pdf-row" style="display:none" class="mt-3">
|
|
<a id="success-pdf-btn" href="#" target="_blank"
|
|
class="btn btn-danger w-100 fw-semibold">
|
|
<i class="fas fa-file-pdf me-2"></i>Descargar documento firmado (PDF)
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Hash SHA-256 -->
|
|
<div id="success-hash-box" style="display:none" class="mt-3 text-start">
|
|
<p class="small text-muted mb-1 fw-semibold">
|
|
<i class="fas fa-shield-alt me-1 text-success"></i>Sello de integridad del documento:
|
|
</p>
|
|
<code id="success-hash" class="d-block" style="font-size:10px;word-break:break-all;
|
|
background:#f0f5ff;border:1px solid #c3d3f7;border-radius:4px;padding:6px 8px;
|
|
color:#1e3a6e"></code>
|
|
</div>
|
|
|
|
<div class="alert alert-light border mt-4 text-start small">
|
|
<i class="fas fa-clock me-1 text-muted"></i>
|
|
Tu información ha sido recibida y está en manos del equipo médico.
|
|
Puedes cerrar esta página.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// CONFIG
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
const TOKEN = <?= json_encode($token) ?>;
|
|
const API = 'api/lab/submit_formulario.php';
|
|
|
|
let _data = null; // respuesta de verificar token
|
|
let _cfg = {}; // config de diseño del laboratorio
|
|
let _firmaDibujada = false;
|
|
let _firmaModosGlobal = null; // modos del topbar (columna firma_modos en DB)
|
|
let _hasInlineFirma = false; // true si el esquema tiene campos tipo firma/firma_profesional
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// HELPERS
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
const $ = id => document.getElementById(id);
|
|
function esc(s) {
|
|
return String(s||'').replace(/[<>&"']/g, c=>
|
|
({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c])
|
|
);
|
|
}
|
|
function show(id) { $(id).style.display = ''; }
|
|
function hide(id) { $(id).style.display = 'none'; }
|
|
function showBlock(id) { $(id).style.display = 'block'; }
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// FIRMA DIGITAL
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
const firma = (() => {
|
|
let canvas, ctx, drawing = false, lastX = 0, lastY = 0;
|
|
|
|
function init() {
|
|
canvas = $('firma-canvas');
|
|
ctx = canvas.getContext('2d');
|
|
// Escalar para retina
|
|
const ratio = window.devicePixelRatio || 1;
|
|
canvas.width = canvas.offsetWidth * ratio;
|
|
canvas.height = canvas.offsetHeight * ratio;
|
|
ctx.scale(ratio, ratio);
|
|
ctx.strokeStyle = '#1a1a2e';
|
|
ctx.lineWidth = 2.5;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
|
|
const getPos = e => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
if (e.touches) {
|
|
const scX = canvas.offsetWidth / rect.width;
|
|
const scY = canvas.offsetHeight / rect.height;
|
|
return {
|
|
x: (e.touches[0].clientX - rect.left) * scX,
|
|
y: (e.touches[0].clientY - rect.top) * scY,
|
|
};
|
|
}
|
|
return { x: e.offsetX, y: e.offsetY };
|
|
};
|
|
|
|
// Mouse
|
|
canvas.addEventListener('mousedown', e => { drawing = true; const p=getPos(e); lastX=p.x; lastY=p.y; });
|
|
canvas.addEventListener('mousemove', e => { if (!drawing) return; trazo(getPos(e)); });
|
|
canvas.addEventListener('mouseup', ()=> { drawing = false; ctx.beginPath(); });
|
|
canvas.addEventListener('mouseleave', ()=> { drawing = false; ctx.beginPath(); });
|
|
|
|
// Touch
|
|
canvas.addEventListener('touchstart', e => { e.preventDefault(); drawing=true; const p=getPos(e); lastX=p.x; lastY=p.y; }, {passive:false});
|
|
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (!drawing) return; trazo(getPos(e)); }, {passive:false});
|
|
canvas.addEventListener('touchend', ()=> { drawing=false; ctx.beginPath(); });
|
|
|
|
// Drag & drop en área de foto global
|
|
const fotoDrop = $('firma-foto-drop');
|
|
if (fotoDrop) {
|
|
fotoDrop.addEventListener('dragover', e => { e.preventDefault(); fotoDrop.classList.add('dragover'); });
|
|
fotoDrop.addEventListener('dragleave', () => fotoDrop.classList.remove('dragover'));
|
|
fotoDrop.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
fotoDrop.classList.remove('dragover');
|
|
const file = e.dataTransfer.files?.[0];
|
|
if (!file || !file.type.startsWith('image/')) return;
|
|
_onFotoChange({ files: [file] });
|
|
});
|
|
}
|
|
}
|
|
|
|
function trazo(p) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(lastX, lastY);
|
|
ctx.lineTo(p.x, p.y);
|
|
ctx.stroke();
|
|
lastX = p.x; lastY = p.y;
|
|
_firmaDibujada = true;
|
|
canvas.classList.remove('empty');
|
|
$('firma-status').textContent = '✅ Firma lista';
|
|
$('firma-status').className = 'small text-success align-self-center fw-semibold';
|
|
}
|
|
|
|
let _fotoDato = null;
|
|
|
|
// setModos: controla qué secciones se muestran
|
|
// modos puede ser un array ['canvas','foto'] o null
|
|
function setModos(modos) {
|
|
const m = Array.isArray(modos) && modos.length ? modos : ['canvas'];
|
|
$('firma-canvas-area').style.display = m.includes('canvas') ? '' : 'none';
|
|
$('firma-foto-area').style.display = m.includes('foto') ? '' : 'none';
|
|
}
|
|
|
|
function _onFotoChange(input) {
|
|
const file = input.files && input.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
_fotoDato = e.target.result;
|
|
const preview = $('firma-foto-preview');
|
|
preview.src = _fotoDato;
|
|
preview.style.display = 'block';
|
|
$('firma-foto-drop').style.display = 'none';
|
|
$('firma-foto-status').textContent = '✅ Foto lista';
|
|
$('firma-foto-status').className = 'small text-success align-self-center fw-semibold';
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function limpiarCanvas() {
|
|
ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight);
|
|
_firmaDibujada = false;
|
|
canvas.classList.add('empty');
|
|
$('firma-status').textContent = 'Sin firma';
|
|
$('firma-status').className = 'small text-muted align-self-center';
|
|
}
|
|
|
|
function limpiarFoto() {
|
|
_fotoDato = null;
|
|
$('firma-foto-preview').src = '';
|
|
$('firma-foto-preview').style.display = 'none';
|
|
$('firma-foto-drop').style.display = 'block';
|
|
$('firma-foto-status').textContent = 'Sin foto';
|
|
$('firma-foto-status').className = 'small text-muted align-self-center';
|
|
$('firma-foto-input').value = '';
|
|
}
|
|
|
|
function obtenerSVG() { if (!_firmaDibujada) return null; return canvas.toDataURL('image/png'); }
|
|
function obtenerFoto() { return _fotoDato || null; }
|
|
|
|
return { init, limpiarCanvas, limpiarFoto, obtenerSVG, obtenerFoto, setModos, _onFotoChange };
|
|
})();
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// RENDER CAMPOS
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
function renderCampos(esquema, prefilled) {
|
|
const cont = $('campos-container');
|
|
let html = '';
|
|
const pad = prefilled || {};
|
|
|
|
esquema.forEach(c => {
|
|
if (c.tipo === 'separador') {
|
|
html += `<div class="mt-4 mb-2" data-campo-id="${esc(c.id)}">
|
|
<p class="fw-bold text-secondary small text-uppercase mb-0">${esc(c.label)}</p>
|
|
<hr class="mt-1 mb-3">
|
|
</div>`;
|
|
return;
|
|
}
|
|
// Wrapper con data-campo-id para lógica de condiciones
|
|
const wrapOpen = `<div data-campo-id="${esc(c.id)}">`;
|
|
const wrapClose = `</div>`;
|
|
|
|
if (c.tipo === 'firma' || c.tipo === 'firma_profesional') {
|
|
const modos = (c.tipo === 'firma' && _firmaModosGlobal)
|
|
? _firmaModosGlobal : (c.modos || ['canvas']);
|
|
html += wrapOpen + renderFirmaInline(c.id, modos, c.label, c.required, c.tipo === 'firma_profesional') + wrapClose;
|
|
return;
|
|
}
|
|
if (c.tipo === 'parrafo') {
|
|
const ws = c.flujoLibre ? 'normal' : 'pre-wrap';
|
|
html += wrapOpen + `<div class="mb-3" style="font-size:.88rem;line-height:1.75;color:#222;text-align:justify;white-space:${ws}">${esc(c.contenido||'')}</div>` + wrapClose;
|
|
return;
|
|
}
|
|
if (c.tipo === 'parrafo_inline') {
|
|
const renderLine = line => line.split(/(\{[a-z_]+\})/g).map((p, i) => {
|
|
if (i % 2 === 1) {
|
|
const key = p.slice(1,-1);
|
|
const val = pad['__paciente']?.[key] || pad[key] || '';
|
|
const hasVal = val.trim() !== '';
|
|
const w = Math.max(80, val.length * 9 + 24);
|
|
if (hasVal) {
|
|
return `<input type="text" class="inline-linked" readonly name="${c.id}_${key}" value="${esc(val)}" style="min-width:${w}px">`;
|
|
} else {
|
|
return `<input type="text" class="inline-linked" data-editable name="${c.id}_${key}" placeholder="${esc(key.replace(/_/g,' '))}" style="min-width:${w}px">`;
|
|
}
|
|
}
|
|
return esc(p);
|
|
}).join('');
|
|
const inlineHtml = (c.contenido||'').split('\n').map(renderLine).join('<br>');
|
|
html += wrapOpen + `<div class="mb-3" style="font-size:.88rem;line-height:2.4;color:#222;text-align:justify">${inlineHtml}</div>` + wrapClose;
|
|
return;
|
|
}
|
|
if (c.tipo === 'lista_marcable') {
|
|
const star = c.required ? '<span class="text-danger ms-1">*</span>' : '';
|
|
const lbl = `<label class="form-label small fw-semibold mb-1">${esc(c.label)}${star}</label>`;
|
|
const opts = (c.items||[]).map((it, i) => `
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" name="${c.id}" value="${esc(it)}">
|
|
<label class="form-check-label">${i+1}. ${esc(it)}</label>
|
|
</div>`).join('');
|
|
html += wrapOpen + `<div class="mb-3">${lbl}${opts}</div>` + wrapClose;
|
|
return;
|
|
}
|
|
|
|
const req = c.required ? 'required' : '';
|
|
const star = c.required ? '<span class="text-danger ms-1">*</span>' : '';
|
|
|
|
let val = '';
|
|
if (c.tipo === 'linked') {
|
|
val = pad['__paciente']?.[c.linked_key] || pad[c.linked_key] || '';
|
|
} else {
|
|
val = pad[c.id] || '';
|
|
}
|
|
|
|
const isLinked = c.tipo === 'linked';
|
|
const isReadOnly = isLinked && !!val;
|
|
const linkedNote = isLinked && val
|
|
? '<small class="text-info d-block mt-1"><i class="fas fa-link me-1"></i>Campo autocompletado</small>'
|
|
: (isLinked ? '<small class="text-warning d-block mt-1"><i class="fas fa-edit me-1"></i>Por favor completa este campo</small>' : '');
|
|
|
|
const lbl = `<label class="form-label small fw-semibold mb-1">${esc(c.label)}${star}</label>`;
|
|
|
|
if (c.tipo === 'textarea') {
|
|
html += wrapOpen + `<div class="mb-3">${lbl}
|
|
<textarea class="form-control ${isReadOnly?'field-linked':''}"
|
|
name="${c.id}" rows="3" ${req} ${isReadOnly?'readonly':''}
|
|
placeholder="${esc(c.placeholder||'')}">${esc(val)}</textarea>${linkedNote}
|
|
</div>` + wrapClose;
|
|
} else if (c.tipo === 'select') {
|
|
const opts = (c.options||[]).map(o =>
|
|
`<option value="${esc(o)}" ${o===val?'selected':''}>${esc(o)}</option>`
|
|
).join('');
|
|
html += wrapOpen + `<div class="mb-3">${lbl}
|
|
<select class="form-select ${isReadOnly?'field-linked':''}"
|
|
name="${c.id}" ${req} ${isReadOnly?'disabled':''}>
|
|
<option value="">— seleccionar —</option>${opts}
|
|
</select>${linkedNote}
|
|
</div>` + wrapClose;
|
|
} else if (c.tipo === 'radio') {
|
|
const opts = (c.options||[]).map(o => `
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="${c.id}" value="${esc(o)}"
|
|
${o===val?'checked':''} ${isReadOnly?'disabled':''}>
|
|
<label class="form-check-label">${esc(o)}</label>
|
|
</div>`).join('');
|
|
html += wrapOpen + `<div class="mb-3">${lbl}${opts}${linkedNote}</div>` + wrapClose;
|
|
} else if (c.tipo === 'checkbox') {
|
|
const vals = Array.isArray(val) ? val : [];
|
|
const opts = (c.options||[]).map(o => `
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" name="${c.id}" value="${esc(o)}"
|
|
${vals.includes(o)?'checked':''} ${isReadOnly?'disabled':''}>
|
|
<label class="form-check-label">${esc(o)}</label>
|
|
</div>`).join('');
|
|
html += wrapOpen + `<div class="mb-3">${lbl}${opts}${linkedNote}</div>` + wrapClose;
|
|
} else {
|
|
const t = c.tipo === 'numero' ? 'number'
|
|
: (c.tipo === 'fecha' || c.linked_key === 'fecha_nacimiento') ? 'date'
|
|
: c.tipo === 'hora' ? 'time' : 'text';
|
|
html += wrapOpen + `<div class="mb-3">${lbl}
|
|
<input type="${t}" class="form-control ${isReadOnly?'field-linked':''}"
|
|
name="${c.id}" value="${esc(val)}" ${req} ${isReadOnly?'readonly':''}
|
|
placeholder="${esc(c.placeholder||'')}">
|
|
${linkedNote}
|
|
</div>` + wrapClose;
|
|
}
|
|
});
|
|
|
|
cont.innerHTML = html;
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// FIRMAS INLINE (por campo)
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
const _fw = {}; // { fieldId: { canvas, ctx, hasFirma, foto, drawing, lastX, lastY } }
|
|
|
|
function renderFirmaInline(fid, modos, label, required, isPro) {
|
|
const hasCanvas = (modos||[]).includes('canvas');
|
|
const hasFoto = (modos||[]).includes('foto');
|
|
const hasBoth = hasCanvas && hasFoto;
|
|
const borderC = isPro ? '#198754' : '#1565c0';
|
|
const bgC = isPro ? '#f0fff4' : '#fff';
|
|
const iconC = isPro ? 'fa-user-md' : 'fa-signature';
|
|
const badge = isPro
|
|
? `<span class="badge ms-1" style="background:#198754;font-size:.65rem">Profesional</span>`
|
|
: `<span class="badge ms-1" style="background:#1565c0;font-size:.65rem">Paciente</span>`;
|
|
const star = required ? '<span class="text-danger ms-1">*</span>' : '';
|
|
const lbl = `<label class="form-label fw-semibold">
|
|
<i class="fas ${iconC} me-1" style="color:${borderC}"></i>${esc(label||'')}${star}${badge}
|
|
</label>`;
|
|
|
|
// Firma profesional: el paciente NO puede firmar — se muestra solo como aviso
|
|
if (isPro) {
|
|
return `<div class="mb-4" id="fw-${fid}"><hr class="mb-3">${lbl}
|
|
<div class="alert py-2 px-3" style="background:#f0fff4;border:2px dashed #198754;border-radius:8px;color:#155724;font-size:.85rem">
|
|
<i class="fas fa-lock me-2"></i>
|
|
<strong>Uso exclusivo del profesional.</strong>
|
|
Esta firma será completada por el profesional de salud encargado.
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
let html = `<div class="mb-4" id="fw-${fid}"><hr class="mb-3">${lbl}`;
|
|
|
|
// Tabs cuando ambos modos están activos
|
|
if (hasBoth) {
|
|
html += `<div class="firma-modo-btns mb-2">
|
|
<button type="button" class="firma-modo-btn activo" id="fw-${fid}-tab-canvas"
|
|
onclick="firmaWidgetTab('${fid}','canvas')">✍️ Dibujar</button>
|
|
<button type="button" class="firma-modo-btn" id="fw-${fid}-tab-foto"
|
|
onclick="firmaWidgetTab('${fid}','foto')">📷 Foto</button>
|
|
</div>`;
|
|
}
|
|
|
|
if (hasCanvas) {
|
|
html += `<div id="fw-${fid}-canvas-area">
|
|
<p class="text-muted small mb-2">✍️ Dibuja la firma con el dedo o el mouse.</p>
|
|
<canvas id="fw-${fid}-canvas" class="fw-canvas empty"
|
|
style="border:2px dashed ${borderC};background:${bgC};"></canvas>
|
|
<div class="d-flex gap-2 mt-2">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
|
onclick="firmaWidgetLimpiar('${fid}')">
|
|
<i class="fas fa-eraser me-1"></i>Limpiar
|
|
</button>
|
|
<span id="fw-${fid}-status" class="small text-muted align-self-center">Sin firma</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
if (hasFoto) {
|
|
html += `<div id="fw-${fid}-foto-area"${hasBoth ? ' style="display:none"' : (hasCanvas ? ' class="mt-3"' : '')}>
|
|
<p class="text-muted small mb-2">📷 Adjunta una foto de la firma o arrastra la imagen aquí.</p>
|
|
<label class="firma-foto-drop" id="fw-${fid}-foto-drop" for="fw-${fid}-foto-input">
|
|
<i class="fas fa-camera fa-2x d-block mb-2"></i>
|
|
Toca para abrir cámara / galería, o arrastra aquí
|
|
</label>
|
|
<input type="file" id="fw-${fid}-foto-input" accept="image/*" capture="environment"
|
|
class="d-none" onchange="firmaWidgetFoto('${fid}', this)">
|
|
<img id="fw-${fid}-foto-preview" src="" alt="Firma"
|
|
style="max-width:100%;max-height:160px;border-radius:8px;border:2px solid ${borderC};display:none;margin-top:8px;object-fit:contain;">
|
|
<div class="d-flex gap-2 mt-2">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
|
onclick="firmaWidgetLimpiarFoto('${fid}')">
|
|
<i class="fas fa-eraser me-1"></i>Quitar foto
|
|
</button>
|
|
<span id="fw-${fid}-foto-status" class="small text-muted align-self-center">Sin foto</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
return html;
|
|
}
|
|
|
|
function firmaWidgetInit(fid) {
|
|
const canvas = document.getElementById('fw-' + fid + '-canvas');
|
|
if (!canvas) return;
|
|
const ratio = window.devicePixelRatio || 1;
|
|
canvas.width = canvas.offsetWidth * ratio;
|
|
canvas.height = canvas.offsetHeight * ratio;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.scale(ratio, ratio);
|
|
ctx.strokeStyle = '#1a1a2e';
|
|
ctx.lineWidth = 2.5;
|
|
ctx.lineCap = 'round';
|
|
_fw[fid] = { canvas, ctx, drawing: false, hasFirma: false, foto: null, lastX: 0, lastY: 0 };
|
|
|
|
function pos(e) {
|
|
const r = canvas.getBoundingClientRect();
|
|
const src = e.touches?.[0] ?? e;
|
|
return { x: src.clientX - r.left, y: src.clientY - r.top };
|
|
}
|
|
function start(e) {
|
|
e.preventDefault();
|
|
const s = _fw[fid]; s.drawing = true;
|
|
const p = pos(e); s.lastX = p.x; s.lastY = p.y;
|
|
}
|
|
function move(e) {
|
|
e.preventDefault();
|
|
const s = _fw[fid];
|
|
if (!s.drawing) return;
|
|
const p = pos(e);
|
|
s.ctx.beginPath(); s.ctx.moveTo(s.lastX, s.lastY);
|
|
s.ctx.lineTo(p.x, p.y); s.ctx.stroke();
|
|
s.lastX = p.x; s.lastY = p.y;
|
|
if (!s.hasFirma) {
|
|
s.hasFirma = true;
|
|
canvas.classList.remove('empty');
|
|
canvas.style.borderStyle = 'solid';
|
|
const st = document.getElementById('fw-' + fid + '-status');
|
|
if (st) { st.textContent = '✅ Firmado'; st.className = 'small text-success align-self-center fw-semibold'; }
|
|
}
|
|
}
|
|
function end() { if (_fw[fid]) _fw[fid].drawing = false; }
|
|
canvas.addEventListener('mousedown', start);
|
|
canvas.addEventListener('mousemove', move);
|
|
canvas.addEventListener('mouseup', end);
|
|
canvas.addEventListener('touchstart', start, { passive: false });
|
|
canvas.addEventListener('touchmove', move, { passive: false });
|
|
canvas.addEventListener('touchend', end);
|
|
|
|
// Drag & drop en área de foto del widget
|
|
const fotoDrop = document.getElementById('fw-' + fid + '-foto-drop');
|
|
if (fotoDrop) {
|
|
fotoDrop.addEventListener('dragover', e => { e.preventDefault(); fotoDrop.classList.add('dragover'); });
|
|
fotoDrop.addEventListener('dragleave', () => fotoDrop.classList.remove('dragover'));
|
|
fotoDrop.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
fotoDrop.classList.remove('dragover');
|
|
const file = e.dataTransfer.files?.[0];
|
|
if (!file || !file.type.startsWith('image/')) return;
|
|
firmaWidgetFoto(fid, { files: [file] });
|
|
});
|
|
}
|
|
}
|
|
|
|
function firmaWidgetTab(fid, tab) {
|
|
const ca = document.getElementById('fw-' + fid + '-canvas-area');
|
|
const fa = document.getElementById('fw-' + fid + '-foto-area');
|
|
const bt = document.getElementById('fw-' + fid + '-tab-canvas');
|
|
const bf = document.getElementById('fw-' + fid + '-tab-foto');
|
|
if (ca) ca.style.display = tab === 'canvas' ? '' : 'none';
|
|
if (fa) fa.style.display = tab === 'foto' ? '' : 'none';
|
|
if (bt) bt.classList.toggle('activo', tab === 'canvas');
|
|
if (bf) bf.classList.toggle('activo', tab === 'foto');
|
|
}
|
|
|
|
function firmaWidgetLimpiar(fid) {
|
|
const s = _fw[fid];
|
|
if (!s?.canvas) return;
|
|
const r = window.devicePixelRatio || 1;
|
|
s.ctx.clearRect(0, 0, s.canvas.width / r, s.canvas.height / r);
|
|
s.hasFirma = false;
|
|
s.canvas.classList.add('empty');
|
|
s.canvas.style.borderStyle = 'dashed';
|
|
const st = document.getElementById('fw-' + fid + '-status');
|
|
if (st) { st.textContent = 'Sin firma'; st.className = 'small text-muted align-self-center'; }
|
|
}
|
|
|
|
function firmaWidgetFoto(fid, input) {
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
if (!_fw[fid]) _fw[fid] = {};
|
|
_fw[fid].foto = e.target.result;
|
|
const p = document.getElementById('fw-' + fid + '-foto-preview');
|
|
const d = document.getElementById('fw-' + fid + '-foto-drop');
|
|
const s = document.getElementById('fw-' + fid + '-foto-status');
|
|
if (p) { p.src = e.target.result; p.style.display = 'block'; }
|
|
if (d) d.style.display = 'none';
|
|
if (s) { s.textContent = '✅ Foto lista'; s.className = 'small text-success align-self-center fw-semibold'; }
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
function firmaWidgetLimpiarFoto(fid) {
|
|
if (_fw[fid]) _fw[fid].foto = null;
|
|
const p = document.getElementById('fw-' + fid + '-foto-preview');
|
|
const d = document.getElementById('fw-' + fid + '-foto-drop');
|
|
const s = document.getElementById('fw-' + fid + '-foto-status');
|
|
const i = document.getElementById('fw-' + fid + '-foto-input');
|
|
if (p) { p.src = ''; p.style.display = 'none'; }
|
|
if (d) d.style.display = '';
|
|
if (s) { s.textContent = 'Sin foto'; s.className = 'small text-muted align-self-center'; }
|
|
if (i) i.value = '';
|
|
}
|
|
|
|
function firmaWidgetObtenerSVG(fid) { return _fw[fid]?.hasFirma ? _fw[fid].canvas.toDataURL('image/png') : null; }
|
|
function firmaWidgetObtenerFoto(fid) { return _fw[fid]?.foto || null; }
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// formCliente
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
const formCliente = {
|
|
|
|
async cargar() {
|
|
if (!TOKEN) {
|
|
$('err-title').textContent = 'Token inválido';
|
|
$('err-msg').textContent = 'El enlace no es válido o ha expirado.';
|
|
hide('loading-screen'); show('error-screen'); return;
|
|
}
|
|
try {
|
|
const r = await fetch(`${API}?t=${encodeURIComponent(TOKEN)}`);
|
|
const d = await r.json();
|
|
if (!d.success) {
|
|
$('err-title').textContent = d.error || 'No disponible';
|
|
$('err-msg').textContent = d.detalle || 'El formulario no está disponible.';
|
|
hide('loading-screen'); show('error-screen'); return;
|
|
}
|
|
_data = d;
|
|
_cfg = d.config || {};
|
|
|
|
// Si ya fue firmado/completado: mostrar pantalla bloqueada
|
|
if (['firmado','completado'].includes(d.envio.estado)) {
|
|
this._mostrarYaFirmado(d);
|
|
return;
|
|
}
|
|
|
|
this._renderForm(d);
|
|
} catch(e) {
|
|
$('err-title').textContent = 'Error de conexión';
|
|
$('err-msg').textContent = 'No pudimos cargar el formulario. Intenta de nuevo.';
|
|
hide('loading-screen'); show('error-screen');
|
|
}
|
|
},
|
|
|
|
_aplicarDiseño(prefixId) {
|
|
const cfg = _cfg;
|
|
// Color del header
|
|
if (cfg.doc_color) {
|
|
document.querySelectorAll('.form-header').forEach(h => h.style.background = cfg.doc_color);
|
|
const canvas = document.getElementById('firma-canvas');
|
|
if (canvas) canvas.style.borderColor = cfg.doc_color;
|
|
}
|
|
// Nombr empresa, logo
|
|
const hdr = $(prefixId + '-empresa-header');
|
|
if (hdr && cfg.doc_encabezado) {
|
|
$(prefixId + '-empresa-nombre').textContent = cfg.doc_encabezado;
|
|
$(prefixId + '-empresa-subtitulo').textContent = cfg.doc_subtitulo || '';
|
|
if (cfg.doc_logo_base64) {
|
|
const img = $(prefixId + '-empresa-logo');
|
|
img.src = cfg.doc_logo_base64;
|
|
img.style.display = '';
|
|
}
|
|
hdr.style.display = '';
|
|
}
|
|
},
|
|
|
|
_mostrarYaFirmado(d) {
|
|
this._aplicarDiseño('yf');
|
|
const env = d.envio;
|
|
const form = d.formulario;
|
|
$('yf-titulo').textContent = form.nombre || 'Formulario';
|
|
|
|
const pac = (d.prefilled || {})['__paciente'];
|
|
if (pac?.nombre_completo) {
|
|
$('yf-paciente').textContent = pac.nombre_completo +
|
|
(pac.numero_documento ? ' · ' + pac.numero_documento : '');
|
|
}
|
|
|
|
const badge = $('yf-estado-badge');
|
|
if (env.estado === 'firmado') {
|
|
badge.textContent = '✍️ Firmado digitalmente';
|
|
badge.style.background = '#198754';
|
|
badge.style.color = '#fff';
|
|
} else {
|
|
badge.textContent = '✅ Completado';
|
|
badge.style.background = '#0d6efd';
|
|
badge.style.color = '#fff';
|
|
}
|
|
|
|
if (env.firma_svg) {
|
|
$('yf-firma-img').src = env.firma_svg;
|
|
show('yf-firma-thumb');
|
|
}
|
|
|
|
// Link al PDF (acceso público vía token, no requiere sesión)
|
|
$('yf-pdf-btn').href = 'ver_formulario_enviado.php?t=' + encodeURIComponent(TOKEN);
|
|
|
|
if (env.hash_verificacion) {
|
|
$('yf-hash').textContent = env.hash_verificacion;
|
|
show('yf-hash-box');
|
|
}
|
|
|
|
hide('loading-screen');
|
|
show('ya-firmado-screen');
|
|
},
|
|
|
|
_renderForm(d) {
|
|
const form = d.formulario;
|
|
const env = d.envio;
|
|
const prefilled = d.prefilled || {};
|
|
|
|
// Diseño doc (color, logo, empresa)
|
|
this._aplicarDiseño('f');
|
|
|
|
// Header
|
|
$('f-titulo').textContent = form.nombre;
|
|
$('f-desc').textContent = form.descripcion || '';
|
|
|
|
// Info paciente
|
|
const pac = prefilled['__paciente'];
|
|
if (pac?.nombre_completo) {
|
|
$('f-paciente-info').innerHTML =
|
|
`<i class="fas fa-user me-1"></i>${esc(pac.nombre_completo)}` +
|
|
(pac.numero_documento ? ` · ${esc(pac.numero_documento)}` : '');
|
|
} else {
|
|
hide('f-paciente-info');
|
|
}
|
|
|
|
// Si ya está completado/firmado
|
|
if (['completado','firmado'].includes(env.estado)) {
|
|
$('instruccion-nota').innerHTML =
|
|
`<i class="fas fa-check-circle text-success me-1"></i>
|
|
Este formulario ya fue <strong>completado</strong>. Puedes ver tus respuestas abajo.`;
|
|
$('btn-enviar').disabled = true;
|
|
$('btn-enviar').textContent = 'Ya enviado';
|
|
|
|
// Mostrar firma almacenada si existe
|
|
if (env.firma_svg) {
|
|
show('firma-container');
|
|
const canvas = $('firma-canvas');
|
|
if (canvas) canvas.style.display = 'none';
|
|
// Insertar imagen de la firma almacenada
|
|
const existingImg = document.getElementById('_firma-guardada');
|
|
if (!existingImg) {
|
|
const img = document.createElement('img');
|
|
img.id = '_firma-guardada';
|
|
img.src = env.firma_svg;
|
|
img.alt = 'Firma';
|
|
img.className = 'border rounded p-2 mt-2';
|
|
img.style.cssText = 'max-height:140px;max-width:100%;display:block;margin:0 auto';
|
|
canvas.parentNode.insertBefore(img, canvas.nextSibling);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Campos
|
|
// firma_modos global: autoridad para campos tipo 'firma' (topbar del builder)
|
|
const fModosStr = form.firma_modos || '';
|
|
_firmaModosGlobal = fModosStr
|
|
? fModosStr.split(',').map(m => m.trim()).filter(Boolean)
|
|
: null;
|
|
|
|
const hasInlineFirma = form.esquema_decoded.some(c => c.tipo === 'firma' || c.tipo === 'firma_profesional');
|
|
_hasInlineFirma = hasInlineFirma;
|
|
renderCampos(form.esquema_decoded, prefilled);
|
|
iniciarCondiciones(form.esquema_decoded);
|
|
|
|
// Firma global (solo si NO hay firmas inline y permite_firma está activo)
|
|
if (form.permite_firma && !hasInlineFirma) {
|
|
show('firma-container');
|
|
if (form.requiere_firma) show('firma-req-badge');
|
|
firma.setModos(_firmaModosGlobal);
|
|
}
|
|
|
|
hide('loading-screen');
|
|
show('form-screen');
|
|
|
|
if (form.permite_firma && !hasInlineFirma) {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
firma.init();
|
|
}));
|
|
}
|
|
if (hasInlineFirma) {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
// Solo inicializar el canvas para firma del paciente
|
|
// firma_profesional se muestra bloqueada, sin canvas
|
|
form.esquema_decoded.forEach(c => {
|
|
if (c.tipo === 'firma') firmaWidgetInit(c.id);
|
|
});
|
|
}));
|
|
}
|
|
},
|
|
|
|
_recopilar() {
|
|
const datos = {};
|
|
const form = document.getElementById('form-main');
|
|
const campos = _data.formulario.esquema_decoded;
|
|
|
|
campos.forEach(c => {
|
|
if (c.tipo === 'separador' || c.tipo === 'firma' || c.tipo === 'firma_profesional' || c.tipo === 'parrafo') return;
|
|
// linked: si tenía valor pre-llenado se ignora; si estaba vacío y el paciente lo llenó, recogerlo
|
|
if (c.tipo === 'linked') {
|
|
const inp = form.querySelector(`[name="${c.id}"]`);
|
|
const v = inp ? inp.value.trim() : '';
|
|
if (v) datos[c.id] = v;
|
|
return;
|
|
}
|
|
|
|
// parrafo_inline: recolectar solo los inputs editables (los que el usuario llenó)
|
|
if (c.tipo === 'parrafo_inline') {
|
|
form.querySelectorAll(`input[data-editable][name^="${c.id}_"]`).forEach(inp => {
|
|
const key = inp.name.slice(c.id.length + 1);
|
|
if (inp.value.trim()) datos[key] = inp.value.trim();
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (c.tipo === 'checkbox' || c.tipo === 'lista_marcable') {
|
|
const checks = [...form.querySelectorAll(`input[name="${c.id}"]:checked`)];
|
|
datos[c.id] = checks.map(el => el.value);
|
|
} else if (c.tipo === 'radio') {
|
|
const r = form.querySelector(`input[name="${c.id}"]:checked`);
|
|
datos[c.id] = r ? r.value : '';
|
|
} else {
|
|
const el = form.querySelector(`[name="${c.id}"]`);
|
|
datos[c.id] = el ? el.value : '';
|
|
}
|
|
});
|
|
// Recopilar firmas inline (firma y firma_profesional por campo)
|
|
campos.forEach(c => {
|
|
if (c.tipo !== 'firma' && c.tipo !== 'firma_profesional') return;
|
|
const svg = firmaWidgetObtenerSVG(c.id);
|
|
const foto = firmaWidgetObtenerFoto(c.id);
|
|
if (svg) datos[c.id + '_svg'] = svg;
|
|
if (foto) datos[c.id + '_foto'] = foto;
|
|
});
|
|
return datos;
|
|
},
|
|
|
|
_validar(datos) {
|
|
const campos = _data.formulario.esquema_decoded;
|
|
for (const c of campos) {
|
|
if (!c.required) continue;
|
|
if (c.tipo === 'separador' || c.tipo === 'linked' || c.tipo === 'firma' || c.tipo === 'firma_profesional' || c.tipo === 'parrafo' || c.tipo === 'parrafo_inline') continue;
|
|
const v = datos[c.id];
|
|
const vacio = Array.isArray(v) ? v.length === 0 : !v || !String(v).trim();
|
|
if (vacio) {
|
|
const el = document.querySelector(`[name="${c.id}"]`);
|
|
if (el) { el.focus(); el.scrollIntoView({block:'center', behavior:'smooth'}); }
|
|
return `El campo "${c.label}" es obligatorio.`;
|
|
}
|
|
}
|
|
if (_data.formulario.requiere_firma && !_hasInlineFirma && !firma.obtenerSVG() && !firma.obtenerFoto()) {
|
|
$('firma-container').scrollIntoView({block:'center', behavior:'smooth'});
|
|
return 'La firma es obligatoria en este formulario.';
|
|
}
|
|
// Validar firmas inline requeridas (solo tipo 'firma' del paciente)
|
|
for (const c of campos) {
|
|
if (!c.required || c.tipo !== 'firma') continue;
|
|
if (!firmaWidgetObtenerSVG(c.id) && !firmaWidgetObtenerFoto(c.id)) {
|
|
const el = document.getElementById('fw-' + c.id);
|
|
if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
return `El campo "${c.label}" requiere firma.`;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
async enviar() {
|
|
const datos = this._recopilar();
|
|
const err = this._validar(datos);
|
|
if (err) {
|
|
alert(err); return;
|
|
}
|
|
|
|
const payload = {
|
|
token: TOKEN,
|
|
datos_cliente: datos,
|
|
firma_svg: firma.obtenerSVG() || null,
|
|
firma_foto: firma.obtenerFoto() || null,
|
|
};
|
|
|
|
const btn = $('btn-enviar');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Enviando…';
|
|
|
|
try {
|
|
const r = await fetch(API, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const d = await r.json();
|
|
if (d.success) {
|
|
this._mostrarExito(d, payload.firma_svg);
|
|
} else {
|
|
alert(d.error || 'Error al enviar. Intenta de nuevo.');
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-check-circle me-2"></i>Enviar formulario';
|
|
}
|
|
} catch(e) {
|
|
alert('Error de conexión. Revisa tu internet e intenta de nuevo.');
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="fas fa-check-circle me-2"></i>Enviar formulario';
|
|
}
|
|
},
|
|
|
|
_mostrarExito(d, firmaSvg) {
|
|
hide('form-screen');
|
|
const msg = d.firmado
|
|
? '¡Tu formulario fue firmado y enviado exitosamente!'
|
|
: 'Tus respuestas fueron registradas correctamente.';
|
|
$('success-msg').textContent = msg;
|
|
if (firmaSvg) {
|
|
$('success-firma-img').src = firmaSvg;
|
|
$('success-firma-thumb').style.display = '';
|
|
}
|
|
// Hash de verificación
|
|
if (d.hash) {
|
|
$('success-hash').textContent = d.hash;
|
|
show('success-hash-box');
|
|
}
|
|
// Link al PDF (acceso público vía token, no requiere sesión)
|
|
if (d.envio_id) {
|
|
const pdfBtn = $('success-pdf-btn');
|
|
pdfBtn.href = 'ver_formulario_enviado.php?t=' + encodeURIComponent(TOKEN);
|
|
show('success-pdf-row');
|
|
}
|
|
show('success-screen');
|
|
window.scrollTo({top: 0, behavior: 'smooth'});
|
|
// Notificar al frame padre si estamos en modo presencial del turnero
|
|
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(_) {}
|
|
},
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// LÓGICA DE CONDICIONES — secciones visibles según valor de otro campo
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
function iniciarCondiciones(esquema) {
|
|
// Agrupar campos en secciones delimitadas por separadores
|
|
// Una sección = { separadorId, condicion, elementIds[] }
|
|
const secciones = [];
|
|
let secActual = null;
|
|
|
|
esquema.forEach(c => {
|
|
if (c.tipo === 'separador') {
|
|
secActual = { sepId: c.id, condicion: c.condicion || null, elemIds: [] };
|
|
secciones.push(secActual);
|
|
} else if (secActual) {
|
|
secActual.elemIds.push(c.id);
|
|
}
|
|
});
|
|
|
|
const condicionales = secciones.filter(s => s.condicion);
|
|
if (!condicionales.length) return;
|
|
|
|
// Marcar cada elemento de sección condicional con data-seccion
|
|
condicionales.forEach(sec => {
|
|
const sepEl = document.querySelector(`[data-campo-id="${sec.sepId}"]`);
|
|
if (sepEl) sepEl.dataset.seccionCond = sec.sepId;
|
|
sec.elemIds.forEach(id => {
|
|
const el = document.querySelector(`[data-campo-id="${id}"]`);
|
|
if (el) el.dataset.seccionCond = sec.sepId;
|
|
});
|
|
});
|
|
|
|
function evaluar() {
|
|
condicionales.forEach(sec => {
|
|
const { campo_id, valor, valores } = sec.condicion;
|
|
const valoresCond = valores ? valores : (valor ? [valor] : []);
|
|
const ctrl = document.querySelector(`[data-campo-id="${campo_id}"]`);
|
|
if (!ctrl) return;
|
|
|
|
let activo = false;
|
|
const checks = ctrl.querySelectorAll('input[type="checkbox"]');
|
|
if (checks.length) {
|
|
checks.forEach(cb => { if (valoresCond.includes(cb.value) && cb.checked) activo = true; });
|
|
} else {
|
|
const radio = ctrl.querySelector(`input[type="radio"]:checked`);
|
|
const sel = ctrl.querySelector('select');
|
|
const v = radio ? radio.value : (sel ? sel.value : '');
|
|
activo = valoresCond.includes(v);
|
|
}
|
|
|
|
// Mostrar u ocultar toda la sección
|
|
document.querySelectorAll(`[data-seccion-cond="${sec.sepId}"]`).forEach(el => {
|
|
el.style.transition = 'opacity .2s, max-height .3s';
|
|
if (activo) {
|
|
el.style.maxHeight = '2000px';
|
|
el.style.opacity = '1';
|
|
el.style.overflow = '';
|
|
el.style.pointerEvents = '';
|
|
} else {
|
|
el.style.maxHeight = '0';
|
|
el.style.opacity = '0';
|
|
el.style.overflow = 'hidden';
|
|
el.style.pointerEvents = 'none';
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Escuchar cambios en los campos controladores
|
|
const camposCtrl = [...new Set(condicionales.map(s => s.condicion.campo_id))];
|
|
camposCtrl.forEach(cid => {
|
|
const el = document.querySelector(`[data-campo-id="${cid}"]`);
|
|
if (el) el.addEventListener('change', evaluar);
|
|
});
|
|
|
|
// Evaluar estado inicial
|
|
evaluar();
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// INIT
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
document.addEventListener('DOMContentLoaded', () => formCliente.cargar());
|
|
</script>
|
|
</body>
|
|
</html>
|