feat: visibilidad condicional de secciones en formularios

Builder:
- Separadores tienen nuevo panel "Visible solo si [campo] = [valor]"
- Selector de campo (checkbox/radio/select del mismo form)
- Selector de valor se actualiza dinámicamente al elegir el campo
- Badge visual en el canvas muestra la condición configurada
- Condición se guarda en campo.condicion { campo_id, valor }

form_cliente.php:
- iniciarCondiciones() evalúa condiciones al cargar y al cambiar campos
- Secciones sin la opción marcada se ocultan (max-height:0, pointer-events:none)
- Se muestran con transición suave al marcar la opción correspondiente
- Cada campo tiene data-campo-id para que la lógica pueda localizarlo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-23 23:24:08 -05:00
co-authored by Claude Sonnet 4.6
parent cd3d3bb9c0
commit 301b39ad0c
2 changed files with 153 additions and 21 deletions
+98 -19
View File
@@ -447,23 +447,25 @@ function renderCampos(esquema, prefilled) {
esquema.forEach(c => {
if (c.tipo === 'separador') {
html += `<div class="mt-4 mb-2">
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') {
// firma del paciente usa firma_modos global (topbar); profesional usa sus propios modos
const modos = (c.tipo === 'firma' && _firmaModosGlobal)
? _firmaModosGlobal
: (c.modos || ['canvas']);
html += renderFirmaInline(c.id, modos, c.label, c.required, c.tipo === 'firma_profesional');
? _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 += `<div class="mb-3" style="font-size:.88rem;line-height:1.75;color:#222;text-align:justify;white-space:${ws}">${esc(c.contenido||'')}</div>`;
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') {
@@ -482,7 +484,7 @@ function renderCampos(esquema, prefilled) {
return esc(p);
}).join('');
const inlineHtml = (c.contenido||'').split('\n').map(renderLine).join('<br>');
html += `<div class="mb-3" style="font-size:.88rem;line-height:2.4;color:#222;text-align:justify">${inlineHtml}</div>`;
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') {
@@ -493,14 +495,13 @@ function renderCampos(esquema, prefilled) {
<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 += `<div class="mb-3">${lbl}${opts}</div>`;
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>' : '';
// Valor pre-llenado (linked usa __paciente.xxx o key directo)
let val = '';
if (c.tipo === 'linked') {
val = pad['__paciente']?.[c.linked_key] || pad[c.linked_key] || '';
@@ -509,7 +510,6 @@ function renderCampos(esquema, prefilled) {
}
const isLinked = c.tipo === 'linked';
// Linked con valor → solo lectura. Linked sin valor → editable (el paciente puede completarlo)
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>'
@@ -518,21 +518,21 @@ function renderCampos(esquema, prefilled) {
const lbl = `<label class="form-label small fw-semibold mb-1">${esc(c.label)}${star}</label>`;
if (c.tipo === 'textarea') {
html += `<div class="mb-3">${lbl}
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>`;
</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 += `<div class="mb-3">${lbl}
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>`;
</div>` + wrapClose;
} else if (c.tipo === 'radio') {
const opts = (c.options||[]).map(o => `
<div class="form-check">
@@ -540,7 +540,7 @@ function renderCampos(esquema, prefilled) {
${o===val?'checked':''} ${isReadOnly?'disabled':''}>
<label class="form-check-label">${esc(o)}</label>
</div>`).join('');
html += `<div class="mb-3">${lbl}${opts}${linkedNote}</div>`;
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 => `
@@ -549,18 +549,17 @@ function renderCampos(esquema, prefilled) {
${vals.includes(o)?'checked':''} ${isReadOnly?'disabled':''}>
<label class="form-check-label">${esc(o)}</label>
</div>`).join('');
html += `<div class="mb-3">${lbl}${opts}${linkedNote}</div>`;
html += wrapOpen + `<div class="mb-3">${lbl}${opts}${linkedNote}</div>` + wrapClose;
} else {
// texto, numero, fecha, hora, linked
const t = c.tipo === 'numero' ? 'number'
: (c.tipo === 'fecha' || c.linked_key === 'fecha_nacimiento') ? 'date'
: c.tipo === 'hora' ? 'time' : 'text';
html += `<div class="mb-3">${lbl}
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>`;
</div>` + wrapClose;
}
});
@@ -924,6 +923,7 @@ const formCliente = {
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) {
@@ -1093,6 +1093,85 @@ const formCliente = {
},
};
// ══════════════════════════════════════════════════════════════════════
// 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 } = sec.condicion;
// Leer valor del campo controlador
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) {
// checkbox múltiple: activo si alguno con ese valor está checked
checks.forEach(cb => { if (cb.value === valor && cb.checked) activo = true; });
} else {
const radio = ctrl.querySelector(`input[type="radio"]:checked`);
const sel = ctrl.querySelector('select');
if (radio) activo = radio.value === valor;
else if (sel) activo = sel.value === valor;
}
// 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
// ══════════════════════════════════════════════════════════════════════
+55 -2
View File
@@ -707,7 +707,10 @@ function renderPreview() {
function renderCampoPreview(c) {
if (c.tipo === 'separador') {
return `<hr class="preview-sep"><div class="preview-sep-label">${esc(c.label)}</div>`;
const condBadge = c.condicion
? `<span title="Visible solo si: ${esc(c.condicion.valor)}" style="font-size:9px;background:#fff3cd;color:#856404;border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:middle"><i class="fas fa-eye-slash"></i> si: ${esc(c.condicion.valor)}</span>`
: '';
return `<hr class="preview-sep"><div class="preview-sep-label">${esc(c.label)}${condBadge}</div>`;
}
const req = c.required ? '<span style="color:#dc3545"> *</span>' : '';
const lbl = `<label>${esc(c.label)}${req}</label>`;
@@ -779,7 +782,35 @@ function abrirEditorCampo(idx) {
</div>`;
if (c.tipo === 'separador') {
// solo label
// Campos con opciones que pueden controlar visibilidad
const camposOpciones = _campos.filter(f =>
['checkbox','radio','select'].includes(f.tipo) && f.id !== c.id && (f.options||[]).length
);
const condCampoId = c.condicion?.campo_id || '';
const condValor = c.condicion?.valor || '';
const campoCtrl = camposOpciones.find(f => f.id === condCampoId);
const optsCtrl = campoCtrl ? (campoCtrl.options||[]) : [];
const optsCampos = camposOpciones.map(f =>
`<option value="${esc(f.id)}" ${f.id===condCampoId?'selected':''}>${esc(f.label)}</option>`
).join('');
const optsValores = optsCtrl.map(o =>
`<option value="${esc(o)}" ${o===condValor?'selected':''}>${esc(o)}</option>`
).join('');
html += `<hr class="my-2">
<div class="mb-2">
<label class="form-label small fw-semibold"><i class="fas fa-eye-slash me-1 text-warning"></i>Visibilidad condicional</label>
<div class="form-text mb-2">Esta sección (y los campos hasta el siguiente separador) se muestra solo si se cumple la condición.</div>
<select class="form-select form-select-sm mb-2" id="ce-cond-campo" onchange="actualizarOpcionesCondicion(this.value)">
<option value="">— Siempre visible —</option>
${optsCampos}
</select>
<select class="form-select form-select-sm" id="ce-cond-valor" ${condCampoId?'':'disabled'}>
<option value="">— Selecciona campo primero —</option>
${optsValores}
</select>
</div>`;
} else if (c.tipo === 'linked') {
html += `<div class="alert alert-info small py-2 mb-0">
<i class="fas fa-info-circle me-1"></i>Campo vinculado al paciente. Se llenará automáticamente al enviar.
@@ -879,6 +910,17 @@ function abrirEditorCampo(idx) {
setTimeout(() => document.getElementById('ce-label')?.select(), 200);
}
function actualizarOpcionesCondicion(campoId) {
const selValor = document.getElementById('ce-cond-valor');
if (!selValor) return;
if (!campoId) { selValor.innerHTML = '<option value="">— Selecciona campo primero —</option>'; selValor.disabled = true; return; }
const campo = _campos.find(f => f.id === campoId);
const opts = campo?.options || [];
selValor.innerHTML = '<option value="">— Selecciona valor —</option>' +
opts.map(o => `<option value="${esc(o)}">${esc(o)}</option>`).join('');
selValor.disabled = false;
}
function _insertarKey(key) {
const ta = document.getElementById('ce-contenido');
if (!ta) return;
@@ -921,6 +963,17 @@ function aplicarCampo() {
if (mc.length) c.modos = mc;
}
// Condición de visibilidad (solo separadores)
const condCampo = document.getElementById('ce-cond-campo');
const condValor = document.getElementById('ce-cond-valor');
if (condCampo !== null) {
if (condCampo.value && condValor.value) {
c.condicion = { campo_id: condCampo.value, valor: condValor.value };
} else {
delete c.condicion;
}
}
_bsCampo.hide();
renderCanvas();
renderPreview();