From 301b39ad0ccd2739227980db05805ee3a10cd4c5 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:24:08 -0500 Subject: [PATCH] feat: visibilidad condicional de secciones en formularios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- form_cliente.php | 117 +++++++++++++++++++++++++++++++------ lab_formulario_builder.php | 57 +++++++++++++++++- 2 files changed, 153 insertions(+), 21 deletions(-) diff --git a/form_cliente.php b/form_cliente.php index b5ebfae..b9ada7c 100644 --- a/form_cliente.php +++ b/form_cliente.php @@ -447,23 +447,25 @@ function renderCampos(esquema, prefilled) { esquema.forEach(c => { if (c.tipo === 'separador') { - html += `
+ html += `

${esc(c.label)}


`; return; } + // Wrapper con data-campo-id para lógica de condiciones + const wrapOpen = `
`; + const wrapClose = `
`; + 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 += `
${esc(c.contenido||'')}
`; + html += wrapOpen + `
${esc(c.contenido||'')}
` + 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('
'); - html += `
${inlineHtml}
`; + html += wrapOpen + `
${inlineHtml}
` + wrapClose; return; } if (c.tipo === 'lista_marcable') { @@ -493,14 +495,13 @@ function renderCampos(esquema, prefilled) {
`).join(''); - html += `
${lbl}${opts}
`; + html += wrapOpen + `
${lbl}${opts}
` + wrapClose; return; } const req = c.required ? 'required' : ''; const star = c.required ? '*' : ''; - // 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 ? 'Campo autocompletado' @@ -518,21 +518,21 @@ function renderCampos(esquema, prefilled) { const lbl = ``; if (c.tipo === 'textarea') { - html += `
${lbl} + html += wrapOpen + `
${lbl} ${linkedNote} -
`; +
` + wrapClose; } else if (c.tipo === 'select') { const opts = (c.options||[]).map(o => `` ).join(''); - html += `
${lbl} + html += wrapOpen + `
${lbl} ${linkedNote} -
`; +
` + wrapClose; } else if (c.tipo === 'radio') { const opts = (c.options||[]).map(o => `
@@ -540,7 +540,7 @@ function renderCampos(esquema, prefilled) { ${o===val?'checked':''} ${isReadOnly?'disabled':''}>
`).join(''); - html += `
${lbl}${opts}${linkedNote}
`; + html += wrapOpen + `
${lbl}${opts}${linkedNote}
` + 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':''}> `).join(''); - html += `
${lbl}${opts}${linkedNote}
`; + html += wrapOpen + `
${lbl}${opts}${linkedNote}
` + 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 += `
${lbl} + html += wrapOpen + `
${lbl} ${linkedNote} -
`; +
` + 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 // ══════════════════════════════════════════════════════════════════════ diff --git a/lab_formulario_builder.php b/lab_formulario_builder.php index 2994372..2a017fd 100644 --- a/lab_formulario_builder.php +++ b/lab_formulario_builder.php @@ -707,7 +707,10 @@ function renderPreview() { function renderCampoPreview(c) { if (c.tipo === 'separador') { - return `
${esc(c.label)}
`; + const condBadge = c.condicion + ? ` si: ${esc(c.condicion.valor)}` + : ''; + return `
${esc(c.label)}${condBadge}
`; } const req = c.required ? ' *' : ''; const lbl = ``; @@ -779,7 +782,35 @@ function abrirEditorCampo(idx) { `; 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 => + `` + ).join(''); + const optsValores = optsCtrl.map(o => + `` + ).join(''); + + html += `
+
+ +
Esta sección (y los campos hasta el siguiente separador) se muestra solo si se cumple la condición.
+ + +
`; } else if (c.tipo === 'linked') { html += `
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 = ''; selValor.disabled = true; return; } + const campo = _campos.find(f => f.id === campoId); + const opts = campo?.options || []; + selValor.innerHTML = '' + + opts.map(o => ``).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();