Instead of one field with a sub-option, there are now two independent palette items under a 'Calculados' section: - fecha_hoy: renders type=date pre-filled with today's date (YYYY-MM-DD) - edad: renders type=number pre-filled with age calculated from paciente.fecha_nacimiento Both are editable by the patient and have a yellow tint. Preview in the builder also shows the live date value for fecha_hoy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1428 lines
73 KiB
PHP
1428 lines
73 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; }
|
|
|
|
/* ── Drop indicators (touch drag) ─────────────────────── */
|
|
.field-item.touch-dragging { opacity: .3; }
|
|
.field-item.drop-above::before {
|
|
content: ''; position: absolute; top: -4px; left: 0; right: 0;
|
|
height: 3px; background: #1565c0; border-radius: 2px; pointer-events: none;
|
|
}
|
|
.field-item.drop-below::after {
|
|
content: ''; position: absolute; bottom: -4px; left: 0; right: 0;
|
|
height: 3px; background: #1565c0; border-radius: 2px; pointer-events: none;
|
|
}
|
|
#drag-ghost {
|
|
position: fixed; pointer-events: none; z-index: 9999;
|
|
background: #fff; border: 1.5px solid #1565c0; border-radius: 8px;
|
|
box-shadow: 0 8px 28px rgba(21,101,192,.25);
|
|
padding: 9px 12px; display: flex; align-items: center; gap: 8px;
|
|
font-size: 12.5px; font-weight: 600; color: #1565c0;
|
|
opacity: .92; transition: none;
|
|
}
|
|
|
|
/* ── Palette tap hint ──────────────────────────────────── */
|
|
.palette-item { touch-action: none; }
|
|
@media (pointer: coarse) {
|
|
.palette-item::after {
|
|
content: '+'; margin-left: auto; font-size: 15px;
|
|
font-weight: 700; color: #1565c0; opacity: .5;
|
|
}
|
|
.field-item .fi-handle {
|
|
font-size: 18px; padding: 4px 6px;
|
|
color: #90a4ae;
|
|
}
|
|
.field-item .fi-btn { padding: 6px 8px; font-size: 14px; }
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
#panel-palette { width: 170px; min-width: 170px; }
|
|
#panel-preview { width: 260px; min-width: 260px; }
|
|
}
|
|
@media (max-width: 680px) {
|
|
#panel-preview { display: none; }
|
|
#panel-palette { width: 140px; min-width: 140px; }
|
|
}
|
|
|
|
/* ── 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 class="palette-group-title" style="margin-top:12px">Calculados</div>
|
|
<div id="palette-calc"></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_CALC = [
|
|
{ tipo:'fecha_hoy', label:'Fecha actual', icon:'fa-calendar-day' },
|
|
{ tipo:'edad', label:'Edad del paciente', icon:'fa-user-clock' },
|
|
];
|
|
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 => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[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}')"
|
|
onclick="agregarCampo({tipo:'${t.tipo}',linked:null})">
|
|
<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-tipo="linked" data-linked="${t.key}"
|
|
ondragstart="palDragStart(event,'linked','${t.key}')"
|
|
onclick="agregarCampo({tipo:'linked',linked:'${t.key}'})">
|
|
<i class="fas ${t.icon}"></i>${t.label}
|
|
</div>`).join('');
|
|
|
|
$('palette-calc').innerHTML = TIPOS_CALC.map(t => `
|
|
<div class="palette-item" draggable="true"
|
|
data-tipo="${t.tipo}"
|
|
ondragstart="palDragStart(event,'${t.tipo}')"
|
|
onclick="agregarCampo({tipo:'${t.tipo}',linked:null})"
|
|
style="color:#856404">
|
|
<i class="fas ${t.icon}"></i>${t.label}
|
|
</div>`).join('');
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// 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 if (tipo === 'fecha_hoy') {
|
|
campo = { id, tipo:'fecha_hoy', label:'Fecha', required:false };
|
|
} else if (tipo === 'edad') {
|
|
campo = { id, tipo:'edad', label:'Edad', 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',
|
|
fecha_hoy:'fa-calendar-day', edad:'fa-user-clock'
|
|
};
|
|
|
|
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 — HTML5 drag (mouse desktop)
|
|
div.setAttribute('draggable', 'true');
|
|
div.addEventListener('dragstart', e => {
|
|
if (e.target.closest('.fi-btn')) { e.preventDefault(); return; }
|
|
_dragSrc = idx; _dragTipo = null;
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
div.classList.add('dragging');
|
|
});
|
|
div.addEventListener('dragend', () => {
|
|
div.classList.remove('dragging');
|
|
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
|
fi.classList.remove('drop-above','drop-below'));
|
|
});
|
|
div.addEventListener('dragover', e => {
|
|
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
|
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
|
fi.classList.remove('drop-above','drop-below'));
|
|
const r = div.getBoundingClientRect();
|
|
div.classList.add(e.clientY < r.top + r.height / 2 ? 'drop-above' : 'drop-below');
|
|
});
|
|
div.addEventListener('drop', e => {
|
|
e.stopPropagation();
|
|
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
|
fi.classList.remove('drop-above','drop-below'));
|
|
if (_dragSrc !== null && _dragSrc !== idx) {
|
|
const r = div.getBoundingClientRect();
|
|
const before = e.clientY < r.top + r.height / 2;
|
|
const [moved] = _campos.splice(_dragSrc, 1);
|
|
const dest = _dragSrc < idx ? (before ? idx - 1 : idx) : (before ? idx : idx + 1);
|
|
_campos.splice(dest, 0, moved);
|
|
_dragSrc = null;
|
|
renderCanvas(); renderPreview();
|
|
}
|
|
});
|
|
// Reordenar — Pointer Events (touch / tablet)
|
|
initPointerDrag(div, idx);
|
|
|
|
zone.appendChild(div);
|
|
});
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// POINTER DRAG (touch + tablet)
|
|
// ════════════════════════════════════════════════════════════════════
|
|
let _pDrag = null;
|
|
|
|
function initPointerDrag(div, idx) {
|
|
const handle = div.querySelector('.fi-handle');
|
|
if (!handle) return;
|
|
|
|
handle.addEventListener('pointerdown', e => {
|
|
// Solo si es touch o el botón primario del mouse (pero el mouse usa HTML5)
|
|
if (e.pointerType === 'mouse') return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
_dragSrc = idx; _dragTipo = null;
|
|
const rect = div.getBoundingClientRect();
|
|
|
|
// Crear ghost
|
|
const ghost = document.createElement('div');
|
|
ghost.id = 'drag-ghost';
|
|
ghost.innerHTML = div.querySelector('.fi-icon').outerHTML +
|
|
'<span>' + (div.querySelector('.fi-label')?.textContent || '') + '</span>';
|
|
ghost.style.top = rect.top + 'px';
|
|
ghost.style.left = rect.left + 'px';
|
|
ghost.style.width = rect.width + 'px';
|
|
document.body.appendChild(ghost);
|
|
|
|
div.classList.add('touch-dragging');
|
|
handle.setPointerCapture(e.pointerId);
|
|
|
|
_pDrag = { idx, div, ghost, offsetY: e.clientY - rect.top };
|
|
});
|
|
|
|
handle.addEventListener('pointermove', e => {
|
|
if (!_pDrag || _pDrag.idx !== idx) return;
|
|
e.preventDefault();
|
|
const { ghost } = _pDrag;
|
|
ghost.style.top = (e.clientY - _pDrag.offsetY) + 'px';
|
|
|
|
// Indicador de posición
|
|
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
|
fi.classList.remove('drop-above','drop-below'));
|
|
ghost.style.display = 'none';
|
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
|
ghost.style.display = '';
|
|
const target = el?.closest('.field-item');
|
|
if (target && target !== _pDrag.div) {
|
|
const r = target.getBoundingClientRect();
|
|
target.classList.add(e.clientY < r.top + r.height / 2 ? 'drop-above' : 'drop-below');
|
|
}
|
|
});
|
|
|
|
const endDrag = e => {
|
|
if (!_pDrag || _pDrag.idx !== idx) return;
|
|
const { div: origDiv, ghost } = _pDrag;
|
|
|
|
ghost.style.display = 'none';
|
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
|
ghost.remove();
|
|
origDiv.classList.remove('touch-dragging');
|
|
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
|
fi.classList.remove('drop-above','drop-below'));
|
|
|
|
const target = el?.closest('.field-item');
|
|
if (target && target !== origDiv) {
|
|
const targetIdx = parseInt(target.dataset.idx);
|
|
const r = target.getBoundingClientRect();
|
|
const before = e.clientY < r.top + r.height / 2;
|
|
if (!isNaN(targetIdx) && targetIdx !== idx) {
|
|
const [moved] = _campos.splice(idx, 1);
|
|
const dest = idx < targetIdx
|
|
? (before ? targetIdx - 1 : targetIdx)
|
|
: (before ? targetIdx : targetIdx + 1);
|
|
_campos.splice(dest, 0, moved);
|
|
renderCanvas(); renderPreview();
|
|
}
|
|
}
|
|
_pDrag = null; _dragSrc = null;
|
|
};
|
|
|
|
handle.addEventListener('pointerup', endDrag);
|
|
handle.addEventListener('pointercancel', e => {
|
|
if (!_pDrag || _pDrag.idx !== idx) return;
|
|
_pDrag.ghost.remove();
|
|
_pDrag.div.classList.remove('touch-dragging');
|
|
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
|
fi.classList.remove('drop-above','drop-below'));
|
|
_pDrag = null; _dragSrc = null;
|
|
});
|
|
}
|
|
|
|
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
|
|
? (()=>{ const vs=(c.condicion.valores||[c.condicion.valor]).join(', '); return `<span title="Visible si: ${esc(vs)}" 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(vs)}</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>`;
|
|
}
|
|
if (c.tipo === 'fecha_hoy') {
|
|
const hoy = new Date();
|
|
const val = hoy.getFullYear()+'-'+String(hoy.getMonth()+1).padStart(2,'0')+'-'+String(hoy.getDate()).padStart(2,'0');
|
|
return `<div class="preview-field">${lbl}
|
|
<input type="date" value="${val}" disabled style="background:#fff9e6;border-color:#ffc107">
|
|
<small style="color:#856404;font-size:9px"><i class="fas fa-calendar-day"></i> Fecha actual — editable</small>
|
|
</div>`;
|
|
}
|
|
if (c.tipo === 'edad') {
|
|
return `<div class="preview-field">${lbl}
|
|
<input type="number" placeholder="Ej: 34" disabled style="background:#fff9e6;border-color:#ffc107">
|
|
<small style="color:#856404;font-size:9px"><i class="fas fa-user-clock"></i> Edad calculada del paciente — editable</small>
|
|
</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 || '';
|
|
// soporta tanto valor (string) como valores (array)
|
|
const condValores = c.condicion?.valores || (c.condicion?.valor ? [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)}" ${condValores.includes(o)?'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">Se muestra si alguno de los valores seleccionados está marcado en el campo elegido. Ctrl+clic para selección múltiple.</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" multiple size="5"
|
|
style="${condCampoId?'':'opacity:.4;pointer-events:none'}"
|
|
${condCampoId?'':'disabled'}>
|
|
${optsValores}
|
|
</select>
|
|
<div class="form-text">Ctrl+clic para marcar varios valores.</div>
|
|
</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 === 'fecha_hoy' || c.tipo === 'edad') {
|
|
html += `<div class="alert alert-warning py-2 small mb-2">
|
|
<i class="fas fa-calculator me-1"></i>
|
|
${c.tipo === 'fecha_hoy'
|
|
? 'Se pre-llenará con la fecha actual al abrir el formulario.'
|
|
: 'Se pre-llenará con la edad calculada a partir de la fecha de nacimiento del paciente.'}
|
|
El paciente puede editarlo.
|
|
</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 (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 = '';
|
|
selValor.disabled = true;
|
|
selValor.style.opacity = '.4';
|
|
selValor.style.pointerEvents = 'none';
|
|
return;
|
|
}
|
|
const campo = _campos.find(f => f.id === campoId);
|
|
const opts = campo?.options || [];
|
|
selValor.innerHTML = opts.map(o => `<option value="${esc(o)}">${esc(o)}</option>`).join('');
|
|
selValor.disabled = false;
|
|
selValor.style.opacity = '';
|
|
selValor.style.pointerEvents = '';
|
|
}
|
|
|
|
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) {
|
|
const selVals = condValor
|
|
? [...condValor.selectedOptions].map(o => o.value).filter(Boolean)
|
|
: [];
|
|
if (condCampo.value && selVals.length) {
|
|
// ponytail: valores[] siempre, forma unificada
|
|
c.condicion = { campo_id: condCampo.value, valores: selVals };
|
|
} 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>
|