Files
whatsapp/form_cliente.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 8bf3614fa0 feat: condición multi-valor en visibilidad condicional de formularios
Builder:
- Picker de valor ahora es <select multiple> — Ctrl+clic para varios
- Al re-editar un separador, los valores guardados quedan preseleccionados
- aplicarCampo() guarda siempre como condicion.valores (array)
- Badge en canvas muestra todos los valores separados por coma

form_cliente.php / iniciarCondiciones():
- Soporta tanto .valor (string legacy) como .valores (array nuevo)
- Evaluación: activo si CUALQUIER valor de la lista está marcado

BD (ya aplicado):
- Formulario id=15 actualizado con condiciones en los separadores
- Taukit → valor único; Heliprobe → valor único
- Glicemia/Prolactina/etc. → valores[] con todos los prolongados
- Cortisol AM/PM → valores[] para sección de tomas hora fija

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 23:35:03 -05:00

1181 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'] ?? '');
?>
<!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=>
({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[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 ? ` &nbsp;·&nbsp; ${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>