Files
whatsapp/lab_formularios.php
Lizandro GuarnizoandClaude Sonnet 4.6 20146535c7 formularios: agregar botón Clonar para duplicar plantillas
- save_formulario.php: manejo de {clonar:true} → crea copia con "Copia de [nombre]"
- lab_formularios.php: botón icono copy en cada tarjeta + función clonarFormulario()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:21:50 -05:00

1063 lines
54 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* lab_formularios.php — Módulo de Formularios
* Admin: crea y diseña formularios drag & drop
* Enfermero: solo puede ver la lista y enviar formularios pre-llenados
*/
require_once 'config/config.php';
if (!defined('APP_ROOT')) { define('APP_ROOT', __DIR__); }
if (!isUserLoggedIn()) { header('Location: login.php'); exit; }
if (isEnfermero() && !enfermeraId()) { header('Location: enfermero_portal.php'); exit; }
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
$adminNombre = $_SESSION['admin_user']['full_name'] ?? 'Usuario';
$esAdmin = !isEnfermero();
$puedeEscribir = $esAdmin && hasModuleWrite('lab_formularios');
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Formularios — Módulo 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">
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
<style>
/* ── Tarjeta formulario ─────────────────────────── */
.form-card { border-radius:10px; border:1px solid #dee2e6; background:#fff;
transition:box-shadow .2s; }
.form-card:hover { box-shadow:0 3px 12px rgba(0,0,0,.1); }
/* ── Altura uniforme filtros ─────────────────────── */
#panel-envios .form-control-sm,
#panel-envios .form-select-sm { height:31px; padding-top:.25rem; padding-bottom:.25rem; }
/* ── Colores estado envío ───────────────────────── */
.badge-pendiente { background:#6c757d; }
.badge-completado { background:#0d6efd; }
.badge-firmado { background:#198754; }
.badge-expirado { background:#dc3545; }
/* ── Animación toast ────────────────────────────── */
@keyframes fadeInUp {
from { opacity:0; transform:translateY(12px); }
to { opacity:1; transform:translateY(0); }
}
</style>
</head>
<body>
<?php
$SIDEBAR_TITLE = 'Panel Lab';
$SIDEBAR_ICON = 'fas fa-flask';
require_once __DIR__ . '/shared/components/sidebar.php';
?>
<main class="main-content">
<header class="content-header d-flex align-items-center justify-content-between">
<div>
<h1><i class="fas fa-wpforms text-primary"></i> Formularios</h1>
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
</div>
<?php if ($puedeEscribir): ?>
<button class="btn btn-primary btn-sm" onclick="builder.nuevo()">
<i class="fas fa-plus me-1"></i> Nuevo Formulario
</button>
<?php endif; ?>
</header>
<!-- ── TABS ─────────────────────────────────────────────────── -->
<ul class="nav nav-tabs mb-4" id="formTabs">
<li class="nav-item">
<a class="nav-link active" href="#" onclick="tabs.mostrar('lista')">
<i class="fas fa-list me-1"></i>Mis Formularios
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="tabs.mostrar('envios')">
<i class="fas fa-paper-plane me-1"></i>Envíos
<span id="badge-envios" class="badge bg-secondary ms-1" style="display:none"></span>
</a>
</li>
</ul>
<!-- ── LISTA ──────────────────────────────────────────────── -->
<div id="panel-lista">
<div class="mb-3">
<div class="input-group input-group-sm" style="max-width:320px">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" id="inp-buscar-form" class="form-control"
placeholder="Buscar formulario…" oninput="filtrarFormularios(this.value)" autocomplete="off">
</div>
</div>
<div class="row g-3" id="formularios-grid">
<div class="col-12 text-center py-5 text-muted">
<div class="spinner-border spinner-border-sm"></div> Cargando…
</div>
</div>
</div>
<!-- ── ENVÍOS ─────────────────────────────────────────────── -->
<div id="panel-envios" style="display:none">
<!-- Filtros en una sola fila compacta -->
<div class="card border-0 bg-light mb-3 px-3 py-2">
<div class="row g-2 align-items-end">
<div class="col-6 col-sm-2">
<label class="form-label form-label-sm mb-1 text-muted">Desde</label>
<input type="date" id="env-f-desde" class="form-control form-control-sm" oninput="envios.filtrar()">
</div>
<div class="col-6 col-sm-2">
<label class="form-label form-label-sm mb-1 text-muted">Hasta</label>
<input type="date" id="env-f-hasta" class="form-control form-control-sm" oninput="envios.filtrar()">
</div>
<div class="col-12 col-sm-3">
<label class="form-label form-label-sm mb-1 text-muted">Formulario</label>
<select id="env-f-formulario" class="form-select form-select-sm" onchange="envios.filtrar()">
<option value="">Todos los formularios</option>
</select>
</div>
<div class="col-6 col-sm-2">
<label class="form-label form-label-sm mb-1 text-muted">Estado</label>
<select id="env-f-estado" class="form-select form-select-sm" onchange="envios.filtrar()">
<option value="">Todos</option>
<option value="pendiente">🕐 Pendiente</option>
<option value="completado">✅ Completado</option>
<option value="firmado">✍️ Firmado</option>
<option value="expirado">⏰ Expirado</option>
</select>
</div>
<div class="col-6 col-sm-2">
<label class="form-label form-label-sm mb-1 text-muted">Origen</label>
<select id="env-f-origen" class="form-select form-select-sm" onchange="envios.filtrar()">
<option value="">Todos</option>
<option value="turnero">Turnero</option>
<option value="otros">Otros</option>
</select>
</div>
<div class="col-6 col-sm-3">
<label class="form-label form-label-sm mb-1 text-muted">Paciente</label>
<input type="text" id="env-f-paciente" class="form-control form-control-sm"
placeholder="Buscar…" oninput="envios.filtrar()">
</div>
<div class="col-12 col-sm-auto ms-sm-auto d-flex gap-2">
<button class="btn btn-success btn-sm" onclick="envios.exportarExcel()" title="Exportar a Excel">
<i class="fas fa-file-excel me-1"></i><span class="d-none d-md-inline">Excel</span>
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="envios.limpiarFiltros()" title="Limpiar filtros">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- Spinner de carga -->
<div id="env-spinner" class="text-center py-5" style="display:none">
<div class="spinner-border text-primary" role="status" style="width:2rem;height:2rem">
<span class="visually-hidden">Cargando…</span>
</div>
<p class="text-muted small mt-2 mb-0">Cargando envíos…</p>
</div>
<div class="table-responsive" id="env-tabla-wrap">
<table class="table table-hover align-middle small" id="tabla-envios">
<thead class="table-light">
<tr>
<th>Formulario</th>
<th>Paciente</th>
<th>Origen</th>
<th>Estado</th>
<th>Fecha</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="tbody-envios">
<tr><td colspan="7" class="text-center text-muted py-4">Cargando…</td></tr>
</tbody>
</table>
</div>
<!-- Paginación -->
<div class="d-flex align-items-center justify-content-between mt-3 flex-wrap gap-2">
<small class="text-muted" id="env-pag-info"></small>
<nav>
<ul class="pagination pagination-sm mb-0" id="env-paginacion"></ul>
</nav>
</div>
</div>
</main>
<!-- Builder movido a lab_formulario_builder.php (ventana dedicada) -->
<!-- ═══════════════════ MODAL ENVIAR ═══════════════════════════════ -->
<div class="modal fade" id="modalEnviar" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-success text-white py-2">
<h6 class="modal-title mb-0">
<i class="fas fa-paper-plane me-1"></i>Enviar formulario al cliente
</h6>
<button class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="env-form-id">
<!-- Paso 1: selección paciente/domicilio -->
<div id="env-paso1">
<div class="row g-3">
<div class="col-12">
<label class="fw-semibold small">Paciente</label>
<div class="input-group input-group-sm">
<input type="text" id="env-pac-busq" class="form-control"
placeholder="Buscar paciente…" autocomplete="off">
<button class="btn btn-outline-secondary" onclick="envio.buscarPaciente()">
<i class="fas fa-search"></i>
</button>
</div>
<div id="env-pac-list" class="list-group shadow mt-1"
style="position:relative;z-index:1060;max-height:150px;overflow-y:auto;display:none"></div>
<div id="env-pac-card" class="alert alert-success py-2 mt-2 d-none">
<i class="fas fa-user-check me-1"></i>
<span id="env-pac-nombre"></span>
<button class="btn-close float-end btn-sm" onclick="envio.limpiarPaciente()"></button>
</div>
</div>
<!-- Pre-llenado dinámico basado en el esquema -->
<div class="col-12" id="env-prefill-cont" style="display:none">
<p class="fw-semibold small text-secondary mb-2">
<i class="fas fa-fill-drip me-1"></i>PRE-LLENAR CAMPOS
<small class="text-muted fw-normal">(el cliente solo podrá firmar)</small>
</p>
<div id="env-prefill-fields" class="row g-2"></div>
</div>
<div class="col-12">
<label class="form-label small">Enviar vía</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="env-via"
id="via-wa" value="whatsapp" checked>
<label class="form-check-label small" for="via-wa">
<i class="fab fa-whatsapp text-success me-1"></i>WhatsApp
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="env-via"
id="via-link" value="link">
<label class="form-check-label small" for="via-link">
<i class="fas fa-link me-1"></i>Solo link
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Paso 2: resultado / link generado -->
<div id="env-paso2" style="display:none">
<div class="text-center py-3">
<div style="font-size:2.5rem">✅</div>
<h6 class="mt-2">¡Link generado!</h6>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Link del formulario</label>
<div class="input-group">
<input type="text" class="form-control form-control-sm" id="env-link-url" readonly>
<button class="btn btn-outline-secondary btn-sm" onclick="envio.copiarLink()">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="mb-3" id="env-wa-cont">
<label class="form-label small fw-semibold">Mensaje de WhatsApp listo</label>
<textarea class="form-control form-control-sm" id="env-wa-msg"
rows="5" readonly></textarea>
<div class="mt-2">
<a id="env-wa-btn" href="#" target="_blank"
class="btn btn-success btn-sm w-100">
<i class="fab fa-whatsapp me-1"></i>Abrir WhatsApp y enviar
</a>
</div>
</div>
</div>
</div>
<div class="modal-footer py-2">
<div id="env-footer1">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-success btn-sm" onclick="envio.generar()">
<i class="fas fa-paper-plane me-1"></i>Generar link
</button>
</div>
<div id="env-footer2" style="display:none">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cerrar</button>
<button class="btn btn-outline-primary btn-sm" onclick="envio.otroEnvio()">
<i class="fas fa-plus me-1"></i>Enviar a otro paciente
</button>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════ MODAL VER RESPUESTA ══════════════════════════ -->
<div class="modal fade" id="modalRespuesta" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title mb-0"><i class="fas fa-file-alt me-1"></i>Respuesta del cliente</h6>
<button class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="resp-body"></div>
<div class="modal-footer py-2">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cerrar</button>
<a id="btn-pdf-link" href="#" target="_blank" class="btn btn-danger btn-sm">
<i class="fas fa-file-pdf me-1"></i>Ver / Descargar PDF
</a>
<button class="btn btn-outline-secondary btn-sm" onclick="verRespuesta.imprimir()">
<i class="fas fa-print me-1"></i>Imprimir
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// ══════════════════════════════════════════════════════════════════════
// CONFIGURACIÓN
// ══════════════════════════════════════════════════════════════════════
const ES_ADMIN = <?= $esAdmin ? 'true' : 'false' ?>;
const PUEDE_ESCRIBIR = <?= $puedeEscribir ? 'true' : 'false' ?>;
// Tipos de campo disponibles
const TIPOS_CAMPO = [
{ tipo:'texto', label:'Texto corto', icon:'fa-font', linked:false },
{ tipo:'textarea', label:'Texto largo', icon:'fa-align-left', linked:false },
{ tipo:'numero', label:'Número', icon:'fa-hashtag', linked:false },
{ tipo:'fecha', label:'Fecha', icon:'fa-calendar', linked:false },
{ tipo:'hora', label:'Hora', icon:'fa-clock', linked:false },
{ tipo:'select', label:'Lista desplegable',icon:'fa-list', linked:false },
{ tipo:'radio', label:'Selección única', icon:'fa-dot-circle', linked:false },
{ tipo:'checkbox', label:'Múltiple opción', icon:'fa-check-square', linked:false },
{ tipo:'firma', label:'Firma digital', icon:'fa-signature', linked:false },
{ tipo:'separador', label:'Separador / Título',icon:'fa-minus', linked:false },
];
// Campos vinculados (auto-llenados desde el paciente)
const TIPOS_LINKED = [
{ tipo:'linked', key:'nombre_completo', label:'Nombre completo (paciente)', icon:'fa-user' },
{ tipo:'linked', key:'numero_documento',label:'Número de documento', icon:'fa-id-card' },
{ tipo:'linked', key:'tipo_documento', label:'Tipo de documento', icon:'fa-id-badge' },
{ tipo:'linked', key:'fecha_nacimiento',label:'Fecha de nacimiento', icon:'fa-birthday-cake' },
{ tipo:'linked', key:'telefono', label:'Teléfono', icon:'fa-phone' },
{ tipo:'linked', key:'email', label:'Email', icon:'fa-envelope' },
{ tipo:'linked', key:'eps', label:'EPS / Aseguradora', icon:'fa-hospital' },
{ tipo:'linked', key:'direccion', label:'Dirección', icon:'fa-map-marker-alt' },
];
// ══════════════════════════════════════════════════════════════════════
// ESTADO
// ══════════════════════════════════════════════════════════════════════
let _formularios = [];
let _globalCfg = {}; // config global del lab
let _bsEnviar = null;
let _bsResp = null;
// ══════════════════════════════════════════════════════════════════════
// HELPERS DOM
// ══════════════════════════════════════════════════════════════════════
const $ = id => document.getElementById(id);
function esc(s) {
return String(s||'').replace(/[<>&"']/g, c =>
({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c]));
}
function uid() { return '_' + Math.random().toString(36).slice(2, 9); }
// ══════════════════════════════════════════════════════════════════════
// TABS
// ══════════════════════════════════════════════════════════════════════
const tabs = {
mostrar(tab) {
$('panel-lista').style.display = tab === 'lista' ? '' : 'none';
$('panel-envios').style.display = tab === 'envios' ? '' : 'none';
document.querySelectorAll('#formTabs .nav-link').forEach((a,i) =>
a.classList.toggle('active', (tab==='lista') === (i===0))
);
if (tab === 'envios') { envios.poblarSelectFormulario(); envios.cargar(1); }
}
};
// ══════════════════════════════════════════════════════════════════════
// LISTA DE FORMULARIOS
// ══════════════════════════════════════════════════════════════════════
async function cargarFormularios() {
const r = await fetch('api/lab/get_formularios.php');
const d = await r.json();
_formularios = d.data || [];
renderFormularios();
}
function filtrarFormularios(q) {
q = q.trim().toLowerCase();
const grid = $('formularios-grid');
if (!q) { renderFormularios(); return; }
const filtrados = _formularios.filter(f =>
(f.nombre || '').toLowerCase().includes(q) ||
(f.tipo || '').toLowerCase().includes(q)
);
const orig = _formularios;
_formularios = filtrados;
renderFormularios();
_formularios = orig;
}
function renderFormularios() {
const grid = $('formularios-grid');
if (!_formularios.length) {
grid.innerHTML = `<div class="col-12 text-center py-5 text-muted">
<i class="fas fa-wpforms fa-3x mb-3 opacity-25"></i>
<p>No hay formularios creados aún.</p>
${PUEDE_ESCRIBIR ? '<button class="btn btn-primary" onclick="builder.nuevo()"><i class="fas fa-plus me-1"></i>Crear primer formulario</button>' : ''}
</div>`;
return;
}
const CATS = { consentimiento:'⚕️',historia_clinica:'📋',autorizacion:'✍️',encuesta:'📊',otro:'📄' };
grid.innerHTML = _formularios.map(f => `
<div class="col-md-4 col-lg-3">
<div class="form-card p-3 h-100">
<div class="d-flex justify-content-between align-items-start mb-2">
<span class="badge bg-light text-dark border">${CATS[f.categoria]||'📄'} ${esc(f.categoria)}</span>
${f.requiere_firma ? '<span class="badge bg-warning text-dark small"><i class="fas fa-signature me-1"></i>Firma req.</span>' : ''}
</div>
<h6 class="fw-bold mb-1">${esc(f.nombre)}</h6>
${f.descripcion ? `<p class="small text-muted mb-2">${esc(f.descripcion)}</p>` : ''}
<div class="d-flex gap-2 small text-muted mb-3">
<span title="Envíos"><i class="fas fa-paper-plane me-1"></i>${f.total_envios||0}</span>
<span title="Completados"><i class="fas fa-check-circle me-1 text-success"></i>${f.total_completados||0}</span>
<span class="ms-auto">v${f.version}</span>
</div>
<div class="d-flex gap-1 flex-wrap">
<button class="btn btn-sm btn-outline-success flex-grow-1"
onclick="envio.abrir(${f.id})">
<i class="fas fa-paper-plane me-1"></i>Enviar
</button>
${PUEDE_ESCRIBIR ? `
<button class="btn btn-sm btn-outline-secondary" onclick="clonarFormulario(${f.id},'${esc(f.nombre)}')"
title="Clonar formulario"><i class="fas fa-copy"></i></button>
<button class="btn btn-sm btn-outline-primary" onclick="builder.editar(${f.id})"
title="Editar diseño"><i class="fas fa-edit"></i></button>
<button class="btn btn-sm btn-outline-danger" onclick="confirmarBorrar(${f.id},'${esc(f.nombre)}')"
title="Eliminar"><i class="fas fa-trash"></i></button>
` : ''}
</div>
</div>
</div>
`).join('');
}
async function clonarFormulario(id, nombre) {
if (!confirm(`¿Clonar el formulario "${nombre}"?\nSe creará una copia con el nombre "Copia de ${nombre}".`)) return;
const r = await fetch('api/lab/save_formulario.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id, clonar: true }),
});
const d = await r.json();
if (d.success || d.ok) {
showToast('✅ Formulario clonado');
cargarFormularios();
} else {
alert(d.error || 'Error al clonar');
}
}
async function confirmarBorrar(id, nombre) {
if (!confirm(`¿Eliminar el formulario "${nombre}"?\nLos envíos existentes se conservarán.`)) return;
const r = await fetch('api/lab/save_formulario.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ id, borrar: true }),
});
const d = await r.json();
if (d.success) { cargarFormularios(); } else { alert(d.error); }
}
// ══════════════════════════════════════════════════════════════════════
// BUILDER (drag & drop)
// ══════════════════════════════════════════════════════════════════════
const builder = {
_win: null,
_abrirVentana(url) {
// Reusar ventana si ya está abierta
if (this._win && !this._win.closed) {
this._win.location.href = url;
this._win.focus();
} else {
this._win = window.open(
url,
'lab_builder',
'width=1440,height=900,resizable=yes,scrollbars=yes'
);
}
},
nuevo() {
if (!PUEDE_ESCRIBIR) return;
this._abrirVentana('lab_formulario_builder.php');
},
editar(id) {
if (!PUEDE_ESCRIBIR) return;
this._abrirVentana(`lab_formulario_builder.php?id=${id}`);
},
}; // end builder
// ENVÍOS (lista)
// ══════════════════════════════════════════════════════════════════════
const ESTADO_ENV = {
pendiente: { cls:'badge-pendiente', icon:'🕐' },
completado: { cls:'badge-completado', icon:'✅' },
firmado: { cls:'badge-firmado', icon:'✍️' },
expirado: { cls:'badge-expirado', icon:'⏰' },
};
const POR_PAGINA = 20;
const envios = {
_pagina: 1,
_total: 0,
_paginas: 1,
_paginaData: [],
_debTimer: null,
_params() {
return new URLSearchParams({
envios: 1,
page: this._pagina,
limit: POR_PAGINA,
fecha_desde: $('env-f-desde').value || '',
fecha_hasta: $('env-f-hasta').value || '',
paciente: $('env-f-paciente').value.trim() || '',
formulario_id: $('env-f-formulario').value || '',
estado: $('env-f-estado').value || '',
origen: $('env-f-origen').value || '',
});
},
async cargar(pag = 1) {
this._pagina = pag;
const spinner = $('env-spinner');
const wrap = $('env-tabla-wrap');
if (spinner) spinner.style.display = '';
if (wrap) wrap.style.display = 'none';
try {
const r = await fetch('api/lab/get_formularios.php?' + this._params());
const d = await r.json();
this._paginaData = d.data || [];
this._total = d.total || 0;
this._paginas = d.paginas || 1;
this._render();
} catch(e) {
const tb = $('tbody-envios');
if (tb) tb.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-4">Error al cargar envíos</td></tr>';
} finally {
if (spinner) spinner.style.display = 'none';
if (wrap) wrap.style.display = '';
}
},
filtrar() {
clearTimeout(this._debTimer);
this._debTimer = setTimeout(() => this.cargar(1), 350);
},
limpiarFiltros() {
$('env-f-desde').value = '';
$('env-f-hasta').value = '';
$('env-f-paciente').value = '';
$('env-f-formulario').value = '';
$('env-f-estado').value = '';
$('env-f-origen').value = '';
this.cargar(1);
},
poblarSelectFormulario() {
const sel = $('env-f-formulario');
if (!sel || sel.options.length > 1) return;
(_formularios || []).forEach(f => {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.nombre || ('Formulario #' + f.id);
sel.appendChild(opt);
});
},
async exportarExcel() {
const btnExcel = document.querySelector('[onclick="envios.exportarExcel()"]');
if (btnExcel) { btnExcel.disabled = true; btnExcel.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Exportando…'; }
let rows = [];
try {
const p = this._params();
p.set('export', '1'); p.delete('page'); p.delete('limit');
const r = await fetch('api/lab/get_formularios.php?' + p);
const d = await r.json();
rows = d.data || [];
} finally {
if (btnExcel) { btnExcel.disabled = false; btnExcel.innerHTML = '<i class="fas fa-file-excel me-1"></i><span class="d-none d-md-inline">Excel</span>'; }
}
if (!rows.length) { showToast('Sin datos para exportar', 'warning'); return; }
const fixedCols = ['Formulario','Categoría','Paciente','Estado','Fecha envío','Fecha completado'];
const fieldMap = new Map();
rows.forEach(e => {
try { JSON.parse(e.esquema||'[]').forEach(c => {
if (!['separador','firma','firma_profesional','parrafo','parrafo_inline','lista_marcable'].includes(c.tipo)
&& c.id && c.label && !fieldMap.has(c.label)) fieldMap.set(c.label, c.id);
}); } catch(_) {}
});
const fieldCols = [...fieldMap.entries()];
const csvRows = [[...fixedCols, ...fieldCols.map(([l])=>l)]];
rows.forEach(e => {
let cliente={}, prefill={};
try { cliente=JSON.parse(e.datos_cliente||'{}'); } catch(_) {}
try { prefill=JSON.parse(e.datos_prefilled||'{}'); } catch(_) {}
const todos = {...prefill,...cliente};
const lmap = new Map();
try { JSON.parse(e.esquema||'[]').forEach(c=>{if(c.id&&c.label)lmap.set(c.label,c.id);}); } catch(_) {}
csvRows.push([
e.form_nombre||'', e.categoria||'', e.paciente_nombre||'', e.estado||'',
(e.created_at||'').slice(0,16).replace('T',' '), (e.completado_en||'').slice(0,16).replace('T',' '),
...fieldCols.map(([l,d])=>{ const v=todos[lmap.get(l)||d]; return v==null?'':(Array.isArray(v)?v.join('; '):String(v)); }),
]);
});
const csv = '\uFEFF' + csvRows.map(r=>r.map(v=>'"'+String(v).replace(/"/g,'""')+'"').join(',')).join('\r\n');
const blob = new Blob([csv],{type:'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'),{href:url,download:'envios_formularios_'+new Date().toISOString().slice(0,10)+'.csv'});
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
showToast('✅ Excel descargado — ' + rows.length + ' registro(s)', 'success', 4000);
},
_render() {
const tb = $('tbody-envios');
if (!this._paginaData.length) {
tb.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">Sin envíos para los filtros seleccionados</td></tr>';
$('env-pag-info').textContent = '';
$('env-paginacion').innerHTML = '';
return;
}
const inicio = (this._pagina - 1) * POR_PAGINA;
tb.innerHTML = this._paginaData.map(e => {
const info = ESTADO_ENV[e.estado] || {};
const esTurnero = e.origen === 'turnero';
const origenBadge = esTurnero
? `<span class="badge bg-info text-dark">Turnero${e.turno_codigo ? ' #'+esc(e.turno_codigo):''}</span>`
: `<span class="badge bg-secondary">Otros</span>`;
const viewHref = esTurnero
? `ver_formulario_enviado.php?token=${esc(e.token)}`
: `ver_formulario_enviado.php?id=${e.id}`;
const fecha = (e.fecha||'').slice(0,16).replace('T',' ');
return `<tr>
<td><span class="fw-semibold">${esc(e.form_nombre)}</span>
<br><small class="text-muted">${esc(e.categoria)}</small></td>
<td>${esc(e.paciente_nombre || '—')}</td>
<td>${origenBadge}</td>
<td><span class="badge text-white ${info.cls}">${info.icon} ${e.estado}</span></td>
<td><small>${esc(fecha)}</small></td>
<td>
<button class="btn btn-xs btn-outline-secondary" title="Copiar link"
onclick="envios.copiarTokenLink('${esc(e.token)}')">
<i class="fas fa-copy"></i>
</button>
${['completado','firmado'].includes(e.estado)
? (esTurnero
? `<a class="btn btn-xs btn-outline-danger ms-1" href="${viewHref}" target="_blank"><i class="fas fa-file-pdf"></i></a>`
: `<button class="btn btn-xs btn-outline-primary ms-1" onclick="verRespuesta.abrir(${e.id})"><i class="fas fa-eye"></i></button>
<a class="btn btn-xs btn-outline-danger ms-1" href="${viewHref}" target="_blank"><i class="fas fa-file-pdf"></i></a>`)
: ''}
</td>
</tr>`;
}).join('');
$('env-pag-info').textContent =
`Mostrando ${inicio+1}${inicio+this._paginaData.length} de ${this._total} envío(s)`;
const ul = $('env-paginacion');
const pag = this._pagina, tot = this._paginas;
ul.innerHTML = '';
if (tot <= 1) return;
const li = (lbl, p, dis, act) => {
const el = document.createElement('li');
el.className = `page-item${dis?' disabled':''}${act?' active':''}`;
el.innerHTML = `<a class="page-link" href="#" onclick="event.preventDefault();${!dis?`envios.cargar(${p})`:''}">${lbl}</a>`;
ul.appendChild(el);
};
li('«',1,pag===1,false); li('',pag-1,pag===1,false);
const pMin=Math.max(1,pag-2), pMax=Math.min(tot,pag+2);
if(pMin>1){li('1',1,false,false);if(pMin>2)li('…',null,true,false);}
for(let p=pMin;p<=pMax;p++) li(p,p,false,p===pag);
if(pMax<tot){if(pMax<tot-1)li('…',null,true,false);li(tot,tot,false,false);}
li('',pag+1,pag===tot,false); li('»',tot,pag===tot,false);
},
irAPagina(p) { this.cargar(p); $('panel-envios').scrollIntoView({behavior:'smooth',block:'start'}); },
copiarTokenLink(token) {
const url = window.location.origin + window.location.pathname.replace(/\/[^/]+$/, '')
+ `/form_cliente.php?t=${token}`;
navigator.clipboard.writeText(url).then(() => showToast('Link copiado'));
},
};
// ══════════════════════════════════════════════════════════════════════
// ENVIAR FORMULARIO (modal)
// ══════════════════════════════════════════════════════════════════════
const envio = {
_pacienteId: null,
_prefilled: {},
_esquema: [],
async abrir(formId) {
$('env-form-id').value = formId;
this._pacienteId = null;
this._prefilled = {};
// Obtener esquema para pre-llenado
const r = await fetch(`api/lab/get_formularios.php?id=${formId}`);
const d = await r.json();
this._esquema = d.formulario?.esquema_decoded || [];
// Reset UI
$('env-pac-busq').value = '';
$('env-pac-list').style.display = 'none';
$('env-pac-card').classList.add('d-none');
$('env-prefill-cont').style.display = 'none';
$('env-paso1').style.display = '';
$('env-paso2').style.display = 'none';
$('env-footer1').style.display = '';
$('env-footer2').style.display = 'none';
document.getElementById('via-wa').checked = true;
if (!_bsEnviar) _bsEnviar = new bootstrap.Modal('#modalEnviar');
_bsEnviar.show();
},
async buscarPaciente() {
const q = $('env-pac-busq').value.trim();
if (!q) return;
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(q)}&limit=6`);
const d = await r.json();
const list = $('env-pac-list');
if (!d.data?.length) {
list.innerHTML = '<a class="list-group-item text-muted disabled small py-1">Sin resultados</a>';
} else {
list.innerHTML = d.data.map(p =>
`<button type="button" class="list-group-item list-group-item-action small py-2"
data-id="${p.id}" data-nombre="${esc(p.nombre_completo)}"
data-tel="${esc(p.telefono||'')}"
data-doc="${esc((p.tipo_documento||'') + ' ' + (p.numero_documento||''))}"
data-nac="${esc(p.fecha_nacimiento||'')}" data-eps="${esc(p.eps||'')}"
data-email="${esc(p.email||'')}" data-dir="${esc(p.direccion||'')}"
data-ciudad="${esc(p.ciudad||'')}"
onclick="envio.selPaciente(this)">
<strong>${esc(p.nombre_completo)}</strong>
<span class="text-muted ms-2">${esc(p.tipo_documento||'')} ${esc(p.numero_documento||'')}</span>
</button>`
).join('');
}
list.style.display = 'block';
},
selPaciente(btn) {
this._pacienteId = +btn.dataset.id;
$('env-pac-list').style.display = 'none';
$('env-pac-busq').value = btn.dataset.nombre;
$('env-pac-nombre').textContent = btn.dataset.nombre;
$('env-pac-card').classList.remove('d-none');
// Construir campos de pre-llenado a partir del esquema
this._buildPrefillFields(btn.dataset);
},
_buildPrefillFields(pacData) {
// Campos editables que el enfermero puede pre-llenar (excluir estáticos y especiales)
const editables = this._esquema.filter(c =>
!['firma','separador','linked','parrafo','parrafo_inline','lista_marcable'].includes(c.tipo)
&& c.label // debe tener etiqueta
);
if (!editables.length) { $('env-prefill-cont').style.display = 'none'; return; }
// Auto-llenar campos linked desde datos del paciente
const linkedMap = {
nombre_completo: pacData.nombre || pacData.nombre_completo || '',
numero_documento: (pacData.doc||'').replace(/^(CC|CE|TI|PA)\s*/,''),
tipo_documento: (pacData.doc||'').split(' ')[0] || '',
telefono: pacData.tel || '',
email: pacData.email|| '',
fecha_nacimiento: pacData.nac || '',
eps: pacData.eps || '',
direccion: pacData.dir || '',
};
this._prefilled = { ...linkedMap };
const cont = $('env-prefill-fields');
cont.innerHTML = editables.slice(0, 10).map(c => {
const val = this._prefilled[c.id] || '';
if (c.tipo === 'select') {
const opts = (c.options||[]).map(o =>
`<option value="${esc(o)}" ${o===val?'selected':''}>${esc(o)}</option>`
).join('');
return `<div class="col-md-6">
<label class="form-label small">${esc(c.label)}</label>
<select class="form-select form-select-sm" data-fid="${c.id}" onchange="envio._updatePre(this)">
<option value="">— sin especificar —</option>${opts}
</select></div>`;
}
return `<div class="col-md-6">
<label class="form-label small">${esc(c.label)}</label>
<input type="${c.tipo==='numero'?'number':c.tipo==='fecha'?'date':'text'}"
class="form-control form-control-sm" data-fid="${c.id}"
value="${esc(val)}" placeholder="${esc(c.placeholder||'')}"
onchange="envio._updatePre(this)">
</div>`;
}).join('');
$('env-prefill-cont').style.display = '';
},
_updatePre(el) { this._prefilled[el.dataset.fid] = el.value; },
limpiarPaciente() {
this._pacienteId = null;
$('env-pac-busq').value = '';
$('env-pac-card').classList.add('d-none');
$('env-prefill-cont').style.display = 'none';
},
async generar() {
const formId = +$('env-form-id').value;
const via = document.querySelector('input[name="env-via"]:checked')?.value || 'link';
const datos = {
formulario_id: formId,
paciente_id: this._pacienteId || null,
datos_prefilled: this._prefilled,
enviado_via: via,
};
const r = await fetch('api/lab/send_formulario.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(datos),
});
const d = await r.json();
if (!d.success) { alert(d.error); return; }
$('env-link-url').value = d.url;
$('env-wa-msg').value = d.mensaje_wa;
$('env-wa-btn').href = d.whatsapp_url;
$('env-wa-cont').style.display = via === 'whatsapp' ? '' : 'none';
$('env-paso1').style.display = 'none';
$('env-paso2').style.display = '';
$('env-footer1').style.display = 'none';
$('env-footer2').style.display = '';
envios._data = []; // invalidar caché
},
copiarLink() {
navigator.clipboard.writeText($('env-link-url').value).then(() => showToast('Link copiado'));
},
otroEnvio() {
$('env-paso1').style.display = '';
$('env-paso2').style.display = 'none';
$('env-footer1').style.display = '';
$('env-footer2').style.display = 'none';
this.limpiarPaciente();
},
};
// ══════════════════════════════════════════════════════════════════════
// VER RESPUESTA
// ══════════════════════════════════════════════════════════════════════
const verRespuesta = {
_envioId: null,
async abrir(envioId) {
this._envioId = envioId;
const e = envios._paginaData.find(x => x.id == envioId);
if (!e) return;
const cliente = JSON.parse(e.datos_cliente || '{}');
const prefill = JSON.parse(e.datos_prefilled || '{}');
const todos = { ...prefill, ...cliente };
const esquema = JSON.parse(e.esquema || '[]');
// Mapa id → label desde el esquema
const labels = {};
esquema.forEach(c => { if (c.id) labels[c.id] = c.label || c.id; });
let html = `<h6 class="fw-bold mb-1">${esc(e.form_nombre)}</h6>
<p class="small text-muted mb-3">
Paciente: <strong>${esc(e.paciente_nombre||'—')}</strong>
&nbsp;·&nbsp;
Completado: ${esc((e.completado_en||'').slice(0,16).replace('T',' '))}
</p><hr class="my-2">`;
// Recorrer en orden del esquema
const ordenados = esquema.filter(c =>
c.tipo !== 'separador' && c.tipo !== 'firma' && todos[c.id] !== undefined
);
if (ordenados.length) {
ordenados.forEach(c => {
const v = todos[c.id];
const display = Array.isArray(v) ? v.join(', ') : String(v ?? '—');
html += `<div class="row mb-2">
<div class="col-5 text-muted small">${esc(c.label || c.id)}</div>
<div class="col-7 small fw-semibold">${esc(display)}</div>
</div>`;
});
} else {
Object.entries(todos).forEach(([k, v]) => {
if (k.startsWith('__')) return;
const display = Array.isArray(v) ? v.join(', ') : String(v ?? '—');
html += `<div class="row mb-2">
<div class="col-5 text-muted small">${esc(labels[k] || k)}</div>
<div class="col-7 small fw-semibold">${esc(display)}</div>
</div>`;
});
}
if (e.firma_svg) {
html += `<hr class="my-3"><p class="small fw-semibold text-muted">Firma digital:</p>
<img src="${e.firma_svg}" class="border rounded p-2"
style="max-width:260px;max-height:160px;display:block">`;
}
$('resp-body').innerHTML = html;
$('btn-pdf-link').href = `ver_formulario_enviado.php?id=${envioId}`;
if (!_bsResp) _bsResp = new bootstrap.Modal('#modalRespuesta');
_bsResp.show();
},
imprimir() {
window.open(`ver_formulario_enviado.php?id=${this._envioId}`, '_blank');
},
};
// ══════════════════════════════════════════════════════════════════════
// RENDER CAMPO PARA PREVIEW/PÚBLICO
// ══════════════════════════════════════════════════════════════════════
function renderCampoPreview(c, valores) {
const v = valores[c.id] || valores[c.linked_key] || '';
if (c.tipo === 'separador') {
return `<div class="preview-field"><hr class="my-2"><p class="fw-bold small text-secondary mb-0">${esc(c.label)}</p></div>`;
}
const lbl = `<label class="form-label small fw-semibold mb-1">${esc(c.label)}${c.required?'<span class="text-danger ms-1">*</span>':''}</label>`;
if (c.tipo === 'linked') {
return `<div class="preview-field">${lbl}
<input type="text" class="form-control form-control-sm" value="${esc(v)}" disabled placeholder="Auto-llenado desde paciente">
</div>`;
}
if (c.tipo === 'textarea') {
return `<div class="preview-field">${lbl}
<textarea class="form-control form-control-sm" 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 class="form-select form-select-sm" disabled><option>— seleccionar —</option>${opts}</select>
</div>`;
}
if (c.tipo === 'radio') {
const opts = (c.options||[]).map(o =>
`<div class="form-check"><input class="form-check-input" type="radio" disabled>
<label class="form-check-label small">${esc(o)}</label></div>`
).join('');
return `<div class="preview-field">${lbl}${opts}</div>`;
}
if (c.tipo === 'checkbox') {
const opts = (c.options||[]).map(o =>
`<div class="form-check"><input class="form-check-input" type="checkbox" disabled>
<label class="form-check-label small">${esc(o)}</label></div>`
).join('');
return `<div class="preview-field">${lbl}${opts}</div>`;
}
if (c.tipo === 'firma') {
return `<div class="preview-field">${lbl}
<div class="border rounded p-2 text-center text-muted small bg-light" style="height:70px;line-height:50px">
✍️ Área de firma (paciente)</div></div>`;
}
if (c.tipo === 'firma_profesional') {
return `<div class="preview-field">${lbl}
<div class="border rounded p-2 text-center small fw-semibold" style="height:70px;line-height:50px;color:#198754;background:#f0fff4;border-color:#198754 !important">
✍️ Área de firma (profesional)</div></div>`;
}
if (c.tipo === 'parrafo') {
const txt = (c.contenido || '');
const ws = c.flujoLibre ? 'normal' : 'pre-wrap';
return `<div class="preview-field" style="font-size:11px;line-height:1.7;color:#222;text-align:justify;white-space:${ws};border-top:1px solid #eee;padding-top:6px">${esc(txt.slice(0,200))}${txt.length>200?'…':''}</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:60px;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 class="form-check"><input class="form-check-input" type="checkbox" disabled>
<label class="form-check-label small">${i+1}. ${esc(it)}</label></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}" class="form-control form-control-sm" value="${esc(v)}"
placeholder="${esc(c.placeholder||'')}" disabled>
</div>`;
}
// ══════════════════════════════════════════════════════════════════════
// HELPERS UI
// ══════════════════════════════════════════════════════════════════════
function showToast(msg, tipo = 'success', ms = 3000) {
const t = document.createElement('div');
t.className = `alert alert-${tipo} d-flex align-items-center gap-2 position-fixed bottom-0 end-0 m-3 shadow-sm py-2 px-3`;
t.style.cssText = 'z-index:9999;min-width:220px;max-width:360px;font-size:.875rem;border-radius:8px;animation:fadeInUp .2s ease';
t.innerHTML = `<span style="flex:1">${esc(msg)}</span><button type="button" class="btn-close btn-close-sm ms-2" style="font-size:.65rem" onclick="this.closest('.alert').remove()"></button>`;
document.body.appendChild(t);
setTimeout(() => t && t.remove(), ms);
}
document.addEventListener('keydown', e => {
if (e.target.id === 'env-pac-busq' && e.key === 'Enter') envio.buscarPaciente();
});
// ══════════════════════════════════════════════════════════════════════
// INIT
// ══════════════════════════════════════════════════════════════════════
// Escuchar mensaje del builder cuando guarda un formulario
window.addEventListener('message', e => {
if (e.data === 'builder:saved') {
cargarFormularios();
showToast('✅ Formulario guardado');
}
});
document.addEventListener('DOMContentLoaded', async () => {
// Cargar config global primero
try {
const r = await fetch('api/lab/get_config.php');
const d = await r.json();
if (d.success) _globalCfg = d.config || {};
} catch(e) {}
cargarFormularios();
});
</script>
<script src="assets/js/lab-sidebar.js"></script>
</body>
</html>