Files
whatsapp/lab_formulario_builder.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 301b39ad0c 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>
2026-06-23 23:24:08 -05:00

1228 lines
64 KiB
PHP

<?php
/**
* lab_formulario_builder.php
* Editor de formularios dedicado — se abre en ventana nueva.
* ?id=X → editar formulario X
* (sin id) → nuevo formulario
*/
require_once 'config/config.php';
if (!defined('APP_ROOT')) { define('APP_ROOT', __DIR__); }
if (!isUserLoggedIn()) { echo '<script>window.close();</script>'; exit; }
if (isEnfermero()) { echo '<script>window.close();</script>'; exit; }
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
$formId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Editor de Formulario — Lab</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
height: 100%; margin: 0; padding: 0;
background: #f0f2f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 13.5px;
}
/* ── TOPBAR ─────────────────────────────────────────────── */
#topbar {
height: 56px; background: #1565c0; color: #fff;
display: flex; align-items: center; gap: 12px;
padding: 0 16px; position: fixed; top: 0; left: 0; right: 0; z-index: 200;
box-shadow: 0 2px 8px rgba(0,0,0,.25);
}
#topbar .brand { font-size: 16px; font-weight: 700; white-space: nowrap; }
#topbar .brand i { opacity: .8; margin-right: 6px; }
#topbar #tb-nombre {
flex: 1; min-width: 0;
background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.3);
color: #fff; border-radius: 6px; padding: 6px 12px; font-size: 14px;
font-weight: 600;
}
#topbar #tb-nombre::placeholder { color: rgba(255,255,255,.5); }
#topbar #tb-nombre.is-invalid { border-color: #ff6b6b; background: rgba(255,107,107,.2); }
#topbar .top-meta { display: flex; gap: 8px; align-items: center; }
#topbar .top-meta select { border-radius: 6px; padding: 4px 8px; font-size: 12px; border: none; }
#topbar .checks { display: flex; gap: 12px; }
#topbar .checks label { color: rgba(255,255,255,.9); font-size: 12px; cursor: pointer; }
#topbar .checks input { cursor: pointer; }
#topbar .btn-save {
background: #fff; color: #1565c0; font-weight: 700;
border: none; border-radius: 6px; padding: 7px 18px; white-space: nowrap;
cursor: pointer; transition: background .15s;
}
#topbar .btn-save:hover { background: #e3f2fd; }
#topbar .btn-save:disabled { opacity: .6; cursor: default; }
#topbar .btn-close-win {
background: rgba(255,255,255,.15); border: none; color: #fff;
border-radius: 6px; padding: 7px 12px; cursor: pointer;
}
#topbar .btn-close-win:hover { background: rgba(255,255,255,.3); }
/* ── LAYOUT PRINCIPAL ───────────────────────────────────── */
#workspace {
position: fixed; top: 56px; bottom: 0; left: 0; right: 0;
display: flex;
}
/* ── PANEL IZQUIERDO — PALETA ───────────────────────────── */
#panel-palette {
width: 210px; min-width: 210px;
background: #fff; border-right: 1px solid #dee2e6;
display: flex; flex-direction: column; overflow: hidden;
}
#panel-palette .pane-hdr {
padding: 10px 12px; font-size: 10px; font-weight: 700;
text-transform: uppercase; color: #6c757d; letter-spacing: .5px;
border-bottom: 1px solid #f0f0f0; background: #f8f9fa; flex-shrink: 0;
}
#panel-palette .pane-scroll { flex: 1; overflow-y: auto; padding: 10px; }
.palette-group-title {
font-size: 10px; font-weight: 700; text-transform: uppercase;
color: #adb5bd; letter-spacing: .5px; margin: 8px 0 4px;
}
.palette-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: 7px; cursor: grab;
border: 1px solid #dee2e6; margin-bottom: 4px;
font-size: 12px; font-weight: 500; background: #fff;
transition: all .12s; user-select: none;
}
.palette-item:hover { background: #e8f0fe; border-color: #90caf9; color: #1565c0; }
.palette-item i { width: 14px; text-align: center; }
.palette-item.linked { border-color: #b2dfdb; color: #00796b; }
.palette-item.linked:hover { background: #e0f2f1; }
/* ── PANEL CENTRAL — CANVAS ─────────────────────────────── */
#panel-canvas {
flex: 1; display: flex; flex-direction: column;
min-width: 0; overflow: hidden; background: #f0f2f5;
}
#canvas-scroll { flex: 1; overflow-y: auto; padding: 20px; }
/* Secciones canvas */
.cv-card {
background: #fff; border-radius: 10px; border: 1px solid #dee2e6;
padding: 16px 18px; margin-bottom: 16px;
}
.cv-card-title {
font-size: 10px; font-weight: 700; text-transform: uppercase;
color: #6c757d; letter-spacing: .5px; margin-bottom: 12px;
border-bottom: 2px solid #e9ecef; padding-bottom: 6px;
}
.cv-card-title i { margin-right: 4px; }
/* Canvas drop zone */
#drop-zone {
min-height: 280px; border: 2px dashed #b3c6f7;
border-radius: 10px; padding: 10px;
background: #f8faff; transition: all .15s;
}
#drop-zone.drag-over { border-color: #1565c0; background: #e8f0fe; }
/* Field items */
.field-item {
display: flex; align-items: center; gap: 8px;
background: #fff; border: 1px solid #dee2e6; border-radius: 8px;
padding: 9px 12px; margin-bottom: 7px; cursor: default;
transition: box-shadow .12s; position: relative;
}
.field-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,.09); }
.field-item.dragging { opacity: .45; }
.field-item .fi-handle { cursor: grab; color: #adb5bd; font-size: 13px; }
.field-item .fi-handle:active { cursor: grabbing; }
.field-item .fi-icon { color: #1565c0; font-size: 13px; width: 16px; text-align: center; }
.field-item .fi-label { flex: 1; font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field-item .fi-badges { display: flex; gap: 4px; flex-shrink: 0; }
.field-item .fi-badge { font-size: 10px; padding: 1px 5px; border-radius: 4px; }
.field-item .fi-actions { display: flex; gap: 4px; flex-shrink: 0; }
.field-item .fi-btn { background: none; border: none; padding: 3px 5px;
border-radius: 4px; cursor: pointer; color: #6c757d; font-size: 12px; }
.field-item .fi-btn:hover { background: #f0f0f0; }
.field-item .fi-btn.danger:hover { background: #fde8e8; color: #dc3545; }
#drop-hint { text-align: center; color: #9bb3d4; padding: 40px 0; font-size: 13px; }
#drop-hint i { font-size: 28px; display: block; margin-bottom: 8px; opacity: .5; }
/* Doc design toggle */
.doc-section-toggle {
cursor: pointer; user-select: none;
display: flex; align-items: center; gap: 6px;
color: #1565c0; font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: .5px;
}
.doc-section-toggle:hover { color: #0d47a1; }
/* ── PANEL DERECHO — PREVIEW ────────────────────────────── */
#panel-preview {
width: 320px; min-width: 320px;
background: #f8f9fa; border-left: 1px solid #dee2e6;
display: flex; flex-direction: column; overflow: hidden;
}
#panel-preview .pane-hdr {
padding: 10px 14px; font-size: 10px; font-weight: 700;
text-transform: uppercase; color: #6c757d; letter-spacing: .5px;
border-bottom: 1px solid #dee2e6; background: #f0f2f5; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between;
}
#preview-scroll { flex: 1; overflow-y: auto; padding: 12px; }
#preview-doc {
background: #fff; border-radius: 8px; border: 1px solid #dee2e6;
padding: 16px; min-height: 100%;
}
.preview-field { margin-bottom: 12px; }
.preview-field label { display: block; font-size: 11px; font-weight: 600;
margin-bottom: 3px; color: #444; }
.preview-field input, .preview-field select,
.preview-field textarea { width: 100%; font-size: 11px; border: 1px solid #dee2e6;
border-radius: 4px; padding: 4px 6px; background: #fafafa; }
.preview-sep { border: none; border-top: 2px solid #1565c0;
margin: 14px 0 6px; opacity: .25; }
.preview-sep-label { font-size: 10px; font-weight: 700; text-transform: uppercase;
color: #6c757d; letter-spacing: .5px; margin-bottom: 8px; }
/* ── MODAL campo ─────────────────────────────────────────── */
.modal-backdrop.show { opacity: .45; }
/* ── STATUSBAR ───────────────────────────────────────────── */
#statusbar {
height: 26px; background: #1565c0; color: rgba(255,255,255,.7);
font-size: 11px; display: flex; align-items: center; gap: 16px;
padding: 0 14px; position: fixed; bottom: 0; left: 0; right: 0; z-index: 200;
}
#statusbar span { display: flex; align-items: center; gap: 4px; }
/* ── Toast ───────────────────────────────────────────────── */
#toast-msg {
position: fixed; bottom: 36px; left: 50%; transform: translateX(-50%);
background: #1b5e20; color: #fff; border-radius: 20px;
padding: 8px 20px; font-size: 13px; font-weight: 600;
z-index: 9999; display: none; box-shadow: 0 3px 12px rgba(0,0,0,.3);
}
/* ── Scrollbars ──────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #c1c9d4; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #8fa0b5; }
@media (max-width: 900px) {
#panel-palette { width: 170px; min-width: 170px; }
#panel-preview { width: 260px; min-width: 260px; }
}
/* ── Pantalla de éxito ─────────────────────────────── */
#success-screen {
position: fixed; inset: 0; z-index: 1000;
background: linear-gradient(135deg, #1565c0 0%, #0d47a1 100%);
display: none; flex-direction: column;
align-items: center; justify-content: center;
color: #fff; text-align: center;
}
#success-screen.show { display: flex; }
#success-screen .success-icon {
font-size: 72px; margin-bottom: 16px; line-height: 1;
animation: pop .4s cubic-bezier(.175,.885,.32,1.275);
}
@keyframes pop {
from { transform: scale(0); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
#success-screen h2 { font-size: 28px; font-weight: 700; margin: 0 0 8px; }
#success-screen p { font-size: 16px; opacity: .8; margin: 0 0 32px; }
#success-screen .success-actions { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; }
#success-screen .btn-white {
background: #fff; color: #1565c0; border: none;
border-radius: 8px; padding: 12px 28px; font-size: 15px;
font-weight: 700; cursor: pointer; transition: transform .1s, box-shadow .1s;
}
#success-screen .btn-white:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,.25); }
#success-screen .btn-outline {
background: transparent; color: #fff;
border: 2px solid rgba(255,255,255,.6);
border-radius: 8px; padding: 11px 28px; font-size: 15px;
font-weight: 600; cursor: pointer; transition: background .15s;
}
#success-screen .btn-outline:hover { background: rgba(255,255,255,.15); }
</style>
</head>
<body>
<!-- ── TOPBAR ──────────────────────────────────────────────────────── -->
<div id="topbar">
<div class="brand"><i class="fas fa-magic"></i>Builder</div>
<input type="text" id="tb-nombre" placeholder="Nombre del formulario *" autocomplete="off">
<div class="top-meta">
<select id="tb-categoria" title="Categoría">
<option value="consentimiento">Consentimiento</option>
<option value="historia_clinica">Historia clínica</option>
<option value="autorizacion">Autorización</option>
<option value="encuesta">Encuesta</option>
<option value="otro" selected>Otro</option>
</select>
<div class="checks">
<label><input type="checkbox" id="tb-firma" checked> Firma</label>
<label><input type="checkbox" id="tb-firma-req"> Req.</label>
<label><input type="checkbox" id="tb-firma-canvas" checked> ✍️ Dibujar</label>
<label><input type="checkbox" id="tb-firma-foto" checked> 📷 Foto</label>
</div>
</div>
<button class="btn-save" id="btn-save" onclick="guardar()">
<i class="fas fa-save me-1"></i>Guardar
</button>
<button class="btn-close-win" title="Cerrar editor" onclick="cerrar()">
<i class="fas fa-times"></i>
</button>
</div>
<!-- ── WORKSPACE ───────────────────────────────────────────────────── -->
<div id="workspace">
<!-- ── PALETA ─────────────────────────────────────────────────── -->
<div id="panel-palette">
<div class="pane-hdr"><i class="fas fa-th-large me-1"></i>Tipos de campo</div>
<div class="pane-scroll" id="palette-scroll">
<div class="palette-group-title">Campos básicos</div>
<div id="palette"></div>
<div class="palette-group-title" style="margin-top:12px">Vinculados al paciente</div>
<div id="palette-linked"></div>
</div>
</div>
<!-- ── CANVAS ─────────────────────────────────────────────────── -->
<div id="panel-canvas">
<div id="canvas-scroll">
<!-- Info del formulario -->
<div class="cv-card">
<div class="cv-card-title"><i class="fas fa-info-circle"></i>Información del formulario</div>
<div class="mb-2">
<label class="form-label small mb-1 fw-semibold">Descripción breve</label>
<textarea class="form-control form-control-sm" id="tb-descripcion"
rows="2" placeholder="Describe el propósito del formulario (opcional)"></textarea>
</div>
</div>
<!-- Drop zone -->
<div class="cv-card" style="padding:0;overflow:hidden">
<div style="padding:12px 16px 0">
<div class="cv-card-title" style="margin-bottom:8px">
<i class="fas fa-layer-group"></i>Campos del formulario
<span id="campos-count" class="ms-2 text-muted fw-normal" style="font-size:11px;text-transform:none"></span>
</div>
</div>
<div id="drop-zone" style="margin:0 12px 12px"
ondragover="onDragOver(event)" ondrop="onDrop(event)"
ondragleave="onDragLeave(event)">
<div id="drop-hint">
<i class="fas fa-mouse-pointer"></i>
Arrastra campos desde el panel izquierdo
</div>
</div>
</div>
<!-- Diseño del documento -->
<div class="cv-card">
<div class="d-flex align-items-center justify-content-between mb-0" style="cursor:pointer"
onclick="toggleDocSection()">
<div class="doc-section-toggle">
<i class="fas fa-paint-brush"></i>Diseño del documento
<i class="fas fa-chevron-down ms-1" id="doc-chevron" style="transition:.2s"></i>
</div>
<div class="form-check form-switch mb-0" onclick="event.stopPropagation()">
<input class="form-check-input" type="checkbox" id="tb-usar-global"
onchange="toggleDocGlobal(this.checked)" checked>
<label class="form-check-label small text-muted" for="tb-usar-global"
style="font-size:11px">Config global</label>
</div>
</div>
<div id="doc-section-body" class="mt-3">
<div id="doc-global-hint" class="alert alert-light py-2 mb-0" style="font-size:12px">
<i class="fas fa-info-circle me-1 text-primary"></i>
Se usará el logo y encabezado configurados globalmente.
<a href="lab_configuracion.php" target="_blank">Editar config global</a>
</div>
<div id="doc-override-fields" style="display:none">
<div class="row g-2">
<div class="col-8">
<label class="form-label small mb-1">Encabezado (nombre empresa)</label>
<input type="text" class="form-control form-control-sm" id="tb-doc-encabezado"
placeholder="Ej: Laboratorio Clínico Ximena">
</div>
<div class="col-4">
<label class="form-label small mb-1">Color</label>
<input type="color" class="form-control form-control-sm form-control-color w-100"
id="tb-doc-color" value="#1565c0" title="Color del encabezado"
oninput="renderPreview()">
</div>
<div class="col-12">
<label class="form-label small mb-1">Subtítulo</label>
<input type="text" class="form-control form-control-sm" id="tb-doc-subtitulo"
placeholder="Ej: Análisis Clínicos Especializados">
</div>
<div class="col-12">
<label class="form-label small mb-1">Logo del formulario</label>
<input type="file" class="form-control form-control-sm" id="tb-logo-file"
accept="image/*" onchange="cargarLogo(this)">
<div id="tb-logo-preview" class="mt-2" style="display:none">
<div class="d-flex align-items-center gap-2">
<img id="tb-logo-img" src="" style="max-height:44px;max-width:100px;border:1px solid #dee2e6;border-radius:5px;padding:2px">
<button type="button" class="btn btn-link btn-sm text-danger p-0"
onclick="quitarLogo()"><i class="fas fa-trash"></i> Quitar</button>
</div>
</div>
<input type="hidden" id="tb-logo-base64">
</div>
<div class="col-12">
<label class="form-label small mb-1">Pie de página</label>
<input type="text" class="form-control form-control-sm" id="tb-doc-pie"
placeholder="Ej: Documento de uso confidencial">
</div>
</div>
</div>
</div>
</div>
</div><!-- /canvas-scroll -->
</div>
<!-- ── PREVIEW ────────────────────────────────────────────────── -->
<div id="panel-preview">
<div class="pane-hdr">
<span><i class="fas fa-eye me-1"></i>Vista previa del cliente</span>
<button class="btn btn-xs btn-outline-secondary" style="font-size:10px;padding:2px 7px"
onclick="renderPreview()">
<i class="fas fa-sync-alt me-1"></i>Actualizar
</button>
</div>
<div id="preview-scroll">
<div id="preview-doc">
<p class="text-muted text-center small mt-3">El formulario aparecerá aquí…</p>
</div>
</div>
</div>
</div><!-- /workspace -->
<!-- ── STATUSBAR ───────────────────────────────────────────────────── -->
<div id="statusbar">
<span><i class="fas fa-wpforms"></i><span id="sb-form-id"><?= $formId ? "Form #$formId" : 'Nuevo formulario' ?></span></span>
<span><i class="fas fa-layer-group"></i><span id="sb-campos">0 campos</span></span>
<span id="sb-status" style="margin-left:auto"><i class="fas fa-circle" style="color:#69f0ae;font-size:8px"></i> Listo</span>
</div>
<!-- ── MODAL CONFIGURAR CAMPO ──────────────────────────────────────── -->
<div class="modal fade" id="modalCampo" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2 bg-light">
<h6 class="modal-title mb-0"><i class="fas fa-cog me-1 text-primary"></i>Configurar campo</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="campo-editor-body"></div>
<div class="modal-footer py-2 bg-light">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-primary btn-sm" id="campo-save-btn">
<i class="fas fa-check me-1"></i>Aplicar
</button>
</div>
</div>
</div>
</div>
<!-- ── PANTALLA DE ÉXITO ──────────────────────────────────────────── -->
<div id="success-screen">
<div class="success-icon">✅</div>
<h2 id="success-title">¡Formulario guardado!</h2>
<p id="success-sub">Los cambios quedaron guardados correctamente.</p>
<div class="success-actions">
<button class="btn-white" onclick="irAFormularios()">
<i class="fas fa-arrow-left me-2"></i>Ir a Formularios
</button>
<button class="btn-outline" onclick="seguirEditando()">
<i class="fas fa-edit me-2"></i>Seguir editando
</button>
</div>
</div>
<div id="toast-msg"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// ════════════════════════════════════════════════════════════════════
// CONSTANTES
// ════════════════════════════════════════════════════════════════════
const FORM_ID = <?= $formId ?>;
const TIPOS_CAMPO = [
{ tipo:'texto', label:'Texto corto', icon:'fa-font' },
{ tipo:'textarea', label:'Texto largo', icon:'fa-align-left' },
{ tipo:'numero', label:'Número', icon:'fa-hashtag' },
{ tipo:'fecha', label:'Fecha', icon:'fa-calendar' },
{ tipo:'hora', label:'Hora', icon:'fa-clock' },
{ tipo:'select', label:'Lista desplegable', icon:'fa-list' },
{ tipo:'radio', label:'Selección única', icon:'fa-dot-circle' },
{ tipo:'checkbox', label:'Múltiple opción', icon:'fa-check-square' },
{ tipo:'firma', label:'Firma del paciente', icon:'fa-signature' },
{ tipo:'firma_profesional', label:'Firma del profesional', icon:'fa-user-md' },
{ tipo:'separador', label:'Separador / Título', icon:'fa-minus' },
{ tipo:'parrafo', label:'Párrafo de texto', icon:'fa-paragraph' },
{ tipo:'parrafo_inline', label:'Párrafo con campos', icon:'fa-align-left' },
{ tipo:'lista_marcable', label:'Lista marcable', icon:'fa-list-ol' },
];
const TIPOS_LINKED = [
{ key:'nombre_completo', label:'Nombre completo', icon:'fa-user' },
{ key:'numero_documento', label:'N.º documento', icon:'fa-id-card' },
{ key:'tipo_documento', label:'Tipo documento', icon:'fa-id-badge' },
{ key:'fecha_nacimiento', label:'Fecha nacimiento', icon:'fa-birthday-cake' },
{ key:'telefono', label:'Teléfono', icon:'fa-phone' },
{ key:'email', label:'Email', icon:'fa-envelope' },
{ key:'eps', label:'EPS / Aseguradora', icon:'fa-hospital' },
{ key:'direccion', label:'Dirección', icon:'fa-map-marker-alt' },
];
// ════════════════════════════════════════════════════════════════════
// ESTADO
// ════════════════════════════════════════════════════════════════════
let _campos = [];
let _dragSrc = null; // índice del item siendo reordenado
let _dragTipo = null; // { tipo, linked } desde la paleta
let _globalCfg = {};
let _bsCampo = null;
let _saved = false;
let _docOpen = false;
let _campoIdx = null;
// ════════════════════════════════════════════════════════════════════
// DOM HELPERS
// ════════════════════════════════════════════════════════════════════
const $ = id => document.getElementById(id);
const esc = s => String(s||'').replace(/[<>&"']/g,
c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c]));
const uid = () => '_' + Math.random().toString(36).slice(2,9);
function showToast(msg, color='#1b5e20') {
const t = $('toast-msg');
t.textContent = msg;
t.style.background = color;
t.style.display = 'block';
clearTimeout(t._tid);
t._tid = setTimeout(() => t.style.display = 'none', 2800);
}
function sbStatus(msg, ok=true) {
$('sb-status').innerHTML = `<i class="fas fa-circle" style="color:${ok?'#69f0ae':'#ff8a65'};font-size:8px"></i> ${msg}`;
}
// ════════════════════════════════════════════════════════════════════
// RENDER PALETA
// ════════════════════════════════════════════════════════════════════
function renderPalette() {
$('palette').innerHTML = TIPOS_CAMPO.map(t => `
<div class="palette-item" draggable="true"
data-tipo="${t.tipo}"
ondragstart="palDragStart(event,'${t.tipo}')">
<i class="fas ${t.icon}"></i>${t.label}
</div>`).join('');
$('palette-linked').innerHTML = TIPOS_LINKED.map(t => `
<div class="palette-item linked" draggable="true"
data-linked="${t.key}"
ondragstart="palDragStart(event,'linked','${t.key}')">
<i class="fas ${t.icon}"></i>${t.label}
</div>`).join('');
// Click también agrega campo (además del drag)
document.querySelectorAll('.palette-item').forEach(el => {
el.addEventListener('click', () => {
const tipo = el.dataset.tipo || 'linked';
const linked = el.dataset.linked || null;
agregarCampo({ tipo, linked });
});
});
}
// ════════════════════════════════════════════════════════════════════
// DRAG & DROP — PALETA ➜ CANVAS
// ════════════════════════════════════════════════════════════════════
function palDragStart(e, tipo, linked = null) {
_dragTipo = { tipo, linked };
_dragSrc = null;
e.dataTransfer.effectAllowed = 'copy';
}
function onDragOver(e) {
e.preventDefault();
$('drop-zone').classList.add('drag-over');
}
function onDragLeave(e) {
$('drop-zone').classList.remove('drag-over');
}
function onDrop(e) {
e.preventDefault();
$('drop-zone').classList.remove('drag-over');
if (_dragTipo) { agregarCampo(_dragTipo); _dragTipo = null; }
}
// ════════════════════════════════════════════════════════════════════
// AGREGAR CAMPO
// ════════════════════════════════════════════════════════════════════
function agregarCampo({ tipo, linked }) {
const id = uid();
let campo;
if (tipo === 'linked') {
campo = { id, tipo:'linked', linked_key:linked,
label: TIPOS_LINKED.find(t=>t.key===linked)?.label || linked,
required:false };
} else if (tipo === 'separador') {
campo = { id, tipo:'separador', label:'Nueva sección' };
} else if (['select','radio','checkbox'].includes(tipo)) {
campo = { id, tipo, label:'Campo sin título', options:['Opción 1','Opción 2'], required:false };
} else if (tipo === 'firma') {
campo = { id, tipo:'firma', label:'Firma del paciente', modos: ['canvas','foto'] };
} else if (tipo === 'firma_profesional') {
campo = { id, tipo:'firma_profesional', label:'Firma del profesional', modos: ['canvas'] };
} else if (tipo === 'parrafo') {
campo = { id, tipo:'parrafo', contenido:'Escribe aquí el texto del párrafo...', flujoLibre:false };
} else if (tipo === 'parrafo_inline') {
campo = { id, tipo:'parrafo_inline', contenido:'Yo, {nombre_completo}, con N.º de identificación {numero_documento}, declaro que…' };
} else if (tipo === 'lista_marcable') {
campo = { id, tipo:'lista_marcable', label:'Marque con X la prueba solicitada', items:['Opción 1','Opción 2'], required:false };
} else {
campo = { id, tipo, label:'Campo sin título', placeholder:'', required:false };
}
_campos.push(campo);
renderCanvas();
renderPreview();
}
// ════════════════════════════════════════════════════════════════════
// RENDER CANVAS
// ════════════════════════════════════════════════════════════════════
const TIPO_ICON = {
texto:'fa-font', textarea:'fa-align-left', numero:'fa-hashtag',
fecha:'fa-calendar', hora:'fa-clock', select:'fa-list', radio:'fa-dot-circle',
checkbox:'fa-check-square', firma:'fa-signature', firma_profesional:'fa-user-md', separador:'fa-minus', linked:'fa-link',
parrafo:'fa-paragraph', parrafo_inline:'fa-align-left', lista_marcable:'fa-list-ol'
};
function renderCanvas() {
const zone = $('drop-zone');
$('drop-hint').style.display = _campos.length ? 'none' : '';
zone.querySelectorAll('.field-item').forEach(el => el.remove());
$('campos-count').textContent = _campos.length
? `(${_campos.length} campo${_campos.length!==1?'s':''})` : '';
$('sb-campos').textContent = `${_campos.length} campo${_campos.length!==1?'s':''}`;
_campos.forEach((c, idx) => {
const div = document.createElement('div');
div.className = 'field-item';
div.dataset.idx = idx;
div.setAttribute('draggable', 'true');
const reqBadge = c.required
? '<span class="badge bg-danger fi-badge">req</span>' : '';
const linkedBadge = c.tipo === 'linked'
? '<span class="badge bg-teal fi-badge" style="background:#009688;color:#fff">vinculado</span>' : '';
div.innerHTML = `
<span class="fi-handle"><i class="fas fa-grip-vertical"></i></span>
<i class="fas ${TIPO_ICON[c.tipo]||'fa-question'} fi-icon"></i>
<span class="fi-label" title="${esc(c.label)}">${esc(c.label)}</span>
<div class="fi-badges">${reqBadge}${linkedBadge}</div>
<div class="fi-actions">
<button class="fi-btn" title="Configurar" onclick="abrirEditorCampo(${idx})">
<i class="fas fa-cog"></i>
</button>
<button class="fi-btn danger" title="Eliminar" onclick="eliminarCampo(${idx})">
<i class="fas fa-trash"></i>
</button>
</div>`;
// Reordenar drag
div.addEventListener('dragstart', e => {
_dragSrc = idx; _dragTipo = null;
e.dataTransfer.effectAllowed = 'move';
div.classList.add('dragging');
});
div.addEventListener('dragend', () => div.classList.remove('dragging'));
div.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect='move'; });
div.addEventListener('drop', e => {
e.stopPropagation();
if (_dragSrc !== null && _dragSrc !== idx) {
const [moved] = _campos.splice(_dragSrc, 1);
_campos.splice(idx, 0, moved);
_dragSrc = null;
renderCanvas();
renderPreview();
}
});
zone.appendChild(div);
});
}
function eliminarCampo(idx) {
_campos.splice(idx, 1);
renderCanvas();
renderPreview();
}
// ════════════════════════════════════════════════════════════════════
// RENDER PREVIEW
// ════════════════════════════════════════════════════════════════════
function renderPreview() {
const pv = $('preview-doc');
const titulo = $('tb-nombre').value || 'Vista previa';
const desc = $('tb-descripcion').value;
let html = `<h6 style="font-size:13px;font-weight:700;margin:0 0 2px">${esc(titulo)}</h6>`;
if (desc) html += `<p style="font-size:11px;color:#888;margin:0 0 10px">${esc(desc)}</p>`;
if (titulo || desc) html += '<hr style="margin:8px 0">';
if (!_campos.length) {
html += '<p style="font-size:11px;color:#aaa;text-align:center;margin-top:24px">Agrega campos desde el panel izquierdo</p>';
} else {
html += _campos.map(c => renderCampoPreview(c)).join('');
}
if ($('tb-firma').checked) {
const _showCanvas = $('tb-firma-canvas').checked;
const _showFoto = $('tb-firma-foto').checked;
html += `<div class="preview-field" style="margin-top:14px"><label>Firma del paciente</label>`;
if (_showCanvas) html += `<div style="border:1px solid #dee2e6;border-radius:5px;background:#f5f5f5;height:50px;display:flex;align-items:center;justify-content:center;font-size:11px;color:#aaa;margin-bottom:6px">✍️ Área de dibujo</div>`;
if (_showFoto) html += `<div style="border:2px dashed #adb5bd;border-radius:5px;background:#f5f5f5;height:40px;display:flex;align-items:center;justify-content:center;font-size:11px;color:#aaa">📷 Foto de firma</div>`;
html += `</div>`;
}
pv.innerHTML = html;
}
function renderCampoPreview(c) {
if (c.tipo === 'separador') {
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>`;
if (c.tipo === 'linked') return `<div class="preview-field">${lbl}
<input type="text" placeholder="Auto-llenado (${esc(c.linked_key)})" disabled></div>`;
if (c.tipo === 'textarea') return `<div class="preview-field">${lbl}
<textarea rows="2" disabled placeholder="${esc(c.placeholder||'')}"></textarea></div>`;
if (c.tipo === 'select') {
const opts = (c.options||[]).map(o=>`<option>${esc(o)}</option>`).join('');
return `<div class="preview-field">${lbl}
<select disabled><option>— seleccionar —</option>${opts}</select></div>`;
}
if (c.tipo === 'radio') {
const opts = (c.options||[]).map(o=>
`<div style="font-size:11px;margin:2px 0"><input type="radio" disabled> ${esc(o)}</div>`
).join('');
return `<div class="preview-field">${lbl}${opts}</div>`;
}
if (c.tipo === 'checkbox') {
const opts = (c.options||[]).map(o=>
`<div style="font-size:11px;margin:2px 0"><input type="checkbox" disabled> ${esc(o)}</div>`
).join('');
return `<div class="preview-field">${lbl}${opts}</div>`;
}
if (c.tipo === 'firma') {
return `<div class="preview-field">${lbl}
<div style="border:1px solid #1565c0;border-radius:5px;height:50px;display:flex;align-items:center;justify-content:center;font-size:11px;color:#1565c0;background:#e8f0fe">✍️ Firma del paciente</div></div>`;
}
if (c.tipo === 'firma_profesional') {
return `<div class="preview-field">${lbl}
<div style="border:1px solid #198754;border-radius:5px;height:50px;display:flex;align-items:center;justify-content:center;font-size:11px;color:#198754;background:#f0fff4">✍️ Firma del profesional</div></div>`;
}
if (c.tipo === 'parrafo') {
const ws = c.flujoLibre ? 'normal' : 'pre-wrap';
return `<div class="preview-field" style="font-size:11px;line-height:1.75;color:#222;text-align:justify;white-space:${ws};border-top:1px solid #eee;padding-top:6px">${esc(c.contenido||'')}</div>`;
}
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);
return `<span style="display:inline-block;min-width:80px;border-bottom:1px dashed #888;color:#0055aa;font-size:10px;font-style:italic;vertical-align:baseline;padding:0 2px">${esc(key)}</span>`;
}
return esc(p);
}).join('');
const html2 = (c.contenido||'').split('\n').map(renderLine).join('<br>');
return `<div class="preview-field" style="font-size:11px;line-height:2;color:#222;text-align:justify;border-top:1px solid #eee;padding-top:6px">${html2}</div>`;
}
if (c.tipo === 'lista_marcable') {
const items = (c.items||[]).map((it,i) =>
`<div style="font-size:11px;margin:2px 0"><input type="checkbox" disabled> ${i+1}. ${esc(it)}</div>`
).join('');
return `<div class="preview-field">${lbl}${items}</div>`;
}
const t = c.tipo==='numero'?'number':c.tipo==='fecha'?'date':c.tipo==='hora'?'time':'text';
return `<div class="preview-field">${lbl}
<input type="${t}" disabled placeholder="${esc(c.placeholder||'')}"></div>`;
}
// ════════════════════════════════════════════════════════════════════
// EDITOR DE CAMPO (modal)
// ════════════════════════════════════════════════════════════════════
function abrirEditorCampo(idx) {
_campoIdx = idx;
const c = _campos[idx];
let html = `<div class="mb-3">
<label class="form-label small fw-semibold">Etiqueta del campo</label>
<input type="text" class="form-control" id="ce-label" value="${esc(c.label)}">
</div>`;
if (c.tipo === 'separador') {
// 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.
</div>`;
} else if (['texto','textarea','numero'].includes(c.tipo)) {
html += `<div class="mb-3">
<label class="form-label small">Placeholder</label>
<input type="text" class="form-control form-control-sm" id="ce-placeholder" value="${esc(c.placeholder||'')}">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="ce-required" ${c.required?'checked':''}>
<label class="form-check-label small" for="ce-required">Campo obligatorio</label>
</div>`;
} else if (['select','radio','checkbox'].includes(c.tipo)) {
const opts = (c.options||[]).join('\n');
html += `<div class="mb-3">
<label class="form-label small">Opciones <small class="text-muted">(una por línea)</small></label>
<textarea class="form-control form-control-sm" id="ce-options" rows="6"
placeholder="Opción 1\nOpción 2">${esc(opts)}</textarea>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="ce-required" ${c.required?'checked':''}>
<label class="form-check-label small" for="ce-required">Campo obligatorio</label>
</div>`;
} else if (['fecha','hora'].includes(c.tipo)) {
html += `<div class="form-check">
<input class="form-check-input" type="checkbox" id="ce-required" ${c.required?'checked':''}>
<label class="form-check-label small" for="ce-required">Campo obligatorio</label>
</div>`;
} else if (c.tipo === 'parrafo') {
// Sobreescribir el label genérico: no aplica para párrafo
html = `<div class="alert alert-warning small py-2 mb-3"><i class="fas fa-paragraph me-1"></i>Párrafo de texto estático</div>` +
`<div class="mb-2">
<label class="form-label small fw-semibold">Contenido del párrafo</label>
<textarea class="form-control form-control-sm" id="ce-contenido" rows="8"
placeholder="Escribe el texto legal o informativo…">${esc(c.contenido||'')}</textarea>
<div class="form-text">El paciente solo puede leer este texto, no lo edita.</div>
</div>
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" id="ce-flujo-libre" ${c.flujoLibre?'checked':''}>
<label class="form-check-label small" for="ce-flujo-libre">Texto continuo (ignorar saltos de línea) — ideal para consentimientos</label>
</div>`;
} else if (c.tipo === 'parrafo_inline') {
const keys = ['nombre_completo','numero_documento','tipo_documento','fecha_nacimiento','telefono','email','eps','direccion'];
const chips = keys.map(k => `<span class="badge bg-secondary me-1 mb-1" style="cursor:pointer;font-size:10px" onclick="_insertarKey('${k}')">{${k}}</span>`).join('');
html = `<div class="alert alert-info small py-2 mb-3"><i class="fas fa-align-left me-1"></i>Párrafo con campos inline — usa <code>{clave}</code> para insertar datos del paciente en el texto</div>` +
`<div class="mb-1"><label class="form-label small fw-semibold">Claves disponibles (clic para insertar)</label><div class="mb-2">${chips}</div></div>` +
`<div class="mb-2">
<label class="form-label small fw-semibold">Texto del párrafo</label>
<textarea class="form-control form-control-sm" id="ce-contenido" rows="8"
placeholder="Yo, {nombre_completo}, con N.º {numero_documento}, declaro que…">${esc(c.contenido||'')}</textarea>
<div class="form-text">El paciente ve los campos prellenados con sus datos, inline dentro del texto.</div>
</div>`;
} else if (c.tipo === 'lista_marcable') {
const items = (c.items||[]).join('\n');
html += `<div class="mb-3">
<label class="form-label small">Ítems <small class="text-muted">(uno por línea)</small></label>
<textarea class="form-control form-control-sm" id="ce-items" rows="6"
placeholder="Secreción Uretral\nTest de glucosa 50gr">${esc(items)}</textarea>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="ce-required" ${c.required?'checked':''}>
<label class="form-check-label small" for="ce-required">Requiere al menos una selección</label>
</div>`;
} else if (c.tipo === 'firma' || c.tipo === 'firma_profesional') {
if (c.tipo === 'firma_profesional') {
const cm = c.modos || ['canvas'];
html += `<div class="mb-2">
<label class="form-label small fw-semibold">Modos de firma disponibles</label>
<div class="d-flex gap-4">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="ce-modo-canvas" ${cm.includes('canvas')?'checked':''}>
<label class="form-check-label small" for="ce-modo-canvas">✍️ Dibujar (canvas)</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="ce-modo-foto" ${cm.includes('foto')?'checked':''}>
<label class="form-check-label small" for="ce-modo-foto">📷 Foto</label>
</div>
</div>
</div>`;
} else {
html += `<div class="alert alert-info py-2 small mb-2">
<i class="fas fa-info-circle me-1"></i>
Los modos del paciente (<strong>✍️ Dibujar / 📷 Foto</strong>) se configuran
desde el panel superior <strong>↑</strong> (sección <em>Firma del paciente</em>).
</div>`;
}
}
$('campo-editor-body').innerHTML = html;
$('campo-save-btn').onclick = aplicarCampo;
if (!_bsCampo) _bsCampo = new bootstrap.Modal('#modalCampo');
_bsCampo.show();
// Foco al label
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;
const s = ta.selectionStart, e = ta.selectionEnd;
const v = ta.value;
ta.value = v.slice(0, s) + '{' + key + '}' + v.slice(e);
ta.selectionStart = ta.selectionEnd = s + key.length + 2;
ta.focus();
}
function aplicarCampo() {
const c = _campos[_campoIdx];
const labelEl = document.getElementById('ce-label');
if (labelEl) c.label = labelEl.value.trim() || c.label;
const ph = document.getElementById('ce-placeholder');
if (ph) c.placeholder = ph.value;
const req = document.getElementById('ce-required');
if (req) c.required = req.checked;
const opts = document.getElementById('ce-options');
if (opts) c.options = opts.value.split('\n').map(s=>s.trim()).filter(Boolean);
const contenido = document.getElementById('ce-contenido');
if (contenido) c.contenido = contenido.value;
const flujoLibre = document.getElementById('ce-flujo-libre');
if (flujoLibre !== null) c.flujoLibre = flujoLibre.checked;
const items = document.getElementById('ce-items');
if (items) c.items = items.value.split('\n').map(s=>s.trim()).filter(Boolean);
const modoCanvas = document.getElementById('ce-modo-canvas');
const modoFoto = document.getElementById('ce-modo-foto');
if (modoCanvas !== null || modoFoto !== null) {
const mc = [];
if (modoCanvas?.checked) mc.push('canvas');
if (modoFoto?.checked) mc.push('foto');
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();
}
// ════════════════════════════════════════════════════════════════════
// DISEÑO DEL DOCUMENTO
// ════════════════════════════════════════════════════════════════════
function toggleDocSection() {
_docOpen = !_docOpen;
$('doc-section-body').style.display = _docOpen ? '' : 'none';
$('doc-chevron').style.transform = _docOpen ? 'rotate(180deg)' : '';
}
function toggleDocGlobal(usarGlobal) {
$('doc-override-fields').style.display = usarGlobal ? 'none' : '';
$('doc-global-hint').style.display = usarGlobal ? '' : 'none';
}
function cargarLogo(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const b64 = e.target.result;
$('tb-logo-base64').value = b64;
$('tb-logo-img').src = b64;
$('tb-logo-preview').style.display = '';
};
reader.readAsDataURL(file);
}
function quitarLogo() {
$('tb-logo-base64').value = '';
$('tb-logo-img').src = '';
$('tb-logo-preview').style.display = 'none';
$('tb-logo-file').value = '';
}
// ════════════════════════════════════════════════════════════════════
// CARGAR FORMULARIO EXISTENTE
// ════════════════════════════════════════════════════════════════════
async function cargarFormulario(id) {
sbStatus('Cargando formulario…', true);
const r = await fetch(`api/lab/get_formularios.php?id=${id}`);
const d = await r.json();
const f = d.formulario;
if (!f) { sbStatus('Error al cargar', false); return; }
$('tb-nombre').value = f.nombre;
$('tb-descripcion').value = f.descripcion || '';
$('tb-categoria').value = f.categoria;
$('tb-firma').checked = !!f.permite_firma;
$('tb-firma-req').checked = !!f.requiere_firma;
_campos = f.esquema_decoded || [];
// Leer modos: primero columna dedicada, luego campo tipo:'firma' en esquema, nunca defaultear a foto
const _firmaCampo = _campos.find(c => c.tipo === 'firma');
const _fModosStr = f.firma_modos || ''; // columna DB: 'canvas', 'foto', 'canvas,foto' o ''
const _fModos = _fModosStr
? _fModosStr.split(',').map(m => m.trim()).filter(Boolean)
: (_firmaCampo?.modos ?? ['canvas','foto']);
$('tb-firma-canvas').checked = _fModos.includes('canvas');
$('tb-firma-foto').checked = _fModos.includes('foto');
// Campos doc
const tieneOverride = !!(f.doc_encabezado || f.doc_logo_base64 || f.doc_color);
$('tb-usar-global').checked = !tieneOverride;
toggleDocGlobal(!tieneOverride);
$('tb-doc-encabezado').value = f.doc_encabezado || '';
$('tb-doc-subtitulo').value = f.doc_subtitulo || '';
$('tb-doc-color').value = f.doc_color || _globalCfg.doc_color || '#1565c0';
$('tb-doc-pie').value = f.doc_pie_pagina || '';
$('tb-logo-base64').value = f.doc_logo_base64 || '';
if (f.doc_logo_base64) {
$('tb-logo-img').src = f.doc_logo_base64;
$('tb-logo-preview').style.display = '';
}
document.title = `Editar: ${f.nombre} — Builder Lab`;
$('sb-form-id').textContent = `Form #${id}: ${f.nombre}`;
renderCanvas();
renderPreview();
sbStatus('Listo', true);
}
// ════════════════════════════════════════════════════════════════════
// GUARDAR
// ════════════════════════════════════════════════════════════════════
async function guardar() {
const nombre = $('tb-nombre').value.trim();
if (!nombre) {
$('tb-nombre').classList.add('is-invalid');
$('tb-nombre').focus();
showToast('⚠️ Ingresa el nombre del formulario', '#e65100');
return;
}
if (!_campos.length) {
showToast('⚠️ Agrega al menos un campo', '#e65100');
return;
}
$('tb-nombre').classList.remove('is-invalid');
const usarGlobal = $('tb-usar-global').checked;
// Sincronizar modos ANTES de serializar el esquema
const _modos = []; if($('tb-firma-canvas').checked)_modos.push('canvas'); if($('tb-firma-foto').checked)_modos.push('foto');
const datos = {
nombre,
descripcion: $('tb-descripcion').value.trim() || null,
categoria: $('tb-categoria').value,
esquema: JSON.stringify(_campos),
permite_firma: $('tb-firma').checked ? 1 : 0,
requiere_firma: $('tb-firma-req').checked ? 1 : 0,
firma_modos: _modos.length ? _modos.join(',') : 'canvas',
doc_encabezado: usarGlobal ? null : ($('tb-doc-encabezado').value.trim() || null),
doc_subtitulo: usarGlobal ? null : ($('tb-doc-subtitulo').value.trim() || null),
doc_color: usarGlobal ? null : ($('tb-doc-color').value || null),
doc_logo_base64: usarGlobal ? null : ($('tb-logo-base64').value || null),
doc_pie_pagina: usarGlobal ? null : ($('tb-doc-pie').value.trim() || null),
};
if (FORM_ID) datos.id = FORM_ID;
const btn = $('btn-save');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" style="width:.75em;height:.75em"></span>Guardando…';
sbStatus('Guardando…', true);
try {
const r = await fetch('api/lab/save_formulario.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
});
const d = await r.json();
if (d.success) {
_saved = true;
sbStatus('Guardado ✓', true);
// Notificar ventana padre para refrescar lista
if (window.opener && !window.opener.closed) {
window.opener.postMessage('builder:saved', '*');
}
// Actualizar ID si era formulario nuevo
const savedId = d.id || FORM_ID;
if (!FORM_ID && d.id) {
history.replaceState({}, '', `?id=${d.id}`);
$('sb-form-id').textContent = `Form #${d.id}: ${nombre}`;
}
// Mostrar pantalla de éxito
$('success-title').textContent = '¡Formulario guardado!';
$('success-sub').textContent = `"${nombre}" fue guardado correctamente.`;
$('success-screen').classList.add('show');
// Si la ventana tiene opener, intentar cerrar automáticamente
if (window.opener && !window.opener.closed) {
setTimeout(() => {
try { window.close(); } catch(e) {}
}, 1600);
}
} else {
showToast('❌ ' + (d.error || 'Error al guardar'), '#c62828');
sbStatus('Error al guardar', false);
}
} catch(e) {
showToast('❌ Error de red', '#c62828');
sbStatus('Error de red', false);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar';
}
}
// ════════════════════════════════════════════════════════════════════
// PANTALLA DE ÉXITO
// ════════════════════════════════════════════════════════════════════
function irAFormularios() {
if (window.opener && !window.opener.closed) {
window.close();
} else {
window.location.href = 'lab_formularios.php';
}
}
function seguirEditando() {
$('success-screen').classList.remove('show');
_saved = false; // permitir volver a guardar después de editar
}
// ════════════════════════════════════════════════════════════════════
// CERRAR
// ════════════════════════════════════════════════════════════════════
function cerrar() {
if (_saved || !_campos.length) { window.close(); return; }
if (confirm('¿Cerrar sin guardar los cambios?')) window.close();
}
// Ctrl+S para guardar
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
guardar();
}
});
// Prevenir cierre accidental si hay cambios
window.addEventListener('beforeunload', e => {
if (!_saved && _campos.length) {
e.preventDefault();
e.returnValue = '';
}
});
// ════════════════════════════════════════════════════════════════════
// INIT
// ════════════════════════════════════════════════════════════════════
// Ocultar doc-section-body por defecto
$('doc-section-body').style.display = 'none';
document.addEventListener('DOMContentLoaded', async () => {
// Preview auto-update al cambiar nombre/descripción
$('tb-nombre').addEventListener('input', renderPreview);
$('tb-descripcion').addEventListener('input', renderPreview);
$('tb-firma').addEventListener('change', renderPreview);
$('tb-firma-canvas').addEventListener('change', renderPreview);
$('tb-firma-foto').addEventListener('change', renderPreview);
// Cargar config global
try {
const r = await fetch('api/lab/get_config.php');
const d = await r.json();
if (d.success) {
_globalCfg = d.config || {};
$('tb-doc-color').value = _globalCfg.doc_color || '#1565c0';
}
} catch(e) {}
renderPalette();
if (FORM_ID) {
await cargarFormulario(FORM_ID);
} else {
renderCanvas();
renderPreview();
}
});
</script>
<script src="assets/js/lab-sidebar.js"></script>
</body>
</html>