lab_formularios

This commit is contained in:
Lizandro Guarnizo
2026-04-07 15:12:22 -05:00
parent 58f420e7ce
commit ded060e161
3 changed files with 147 additions and 184 deletions
+5 -1
View File
@@ -17,11 +17,15 @@ try {
if (!empty($_GET['paciente_id'])) $filtros['paciente_id'] = (int)$_GET['paciente_id'];
if (!empty($_GET['estado'])) $filtros['estado'] = $_GET['estado'];
if (!empty($_GET['fecha_desde'])) $filtros['fecha_desde'] = $_GET['fecha_desde'];
if (!empty($_GET['fecha_hasta'])) $filtros['fecha_hasta'] = $_GET['fecha_hasta'];
if (!empty($_GET['paciente'])) $filtros['paciente'] = trim($_GET['paciente']);
// Enfermero solo ve los suyos
if (userRole() === 'enfermero') {
$filtros['enviado_por'] = adminId();
}
jsonOk(['data' => $form->listarEnvios($filtros)]);
$pagina = max(1, (int)($_GET['page'] ?? 1));
$porPagina = !empty($_GET['export']) ? 9999 : max(1, min(100, (int)($_GET['limit'] ?? 20)));
jsonOk($form->listarEnvios($filtros, $pagina, $porPagina));
}
// Plantilla específica
+27 -5
View File
@@ -173,7 +173,7 @@ class Formulario {
/**
* Listar envíos (admin/enfermero).
*/
public function listarEnvios(array $filtros = []): array {
public function listarEnvios(array $filtros = [], int $pagina = 1, int $porPagina = 20): array {
$where = ['1=1'];
$params = [];
if (!empty($filtros['formulario_id'])) { $where[] = 'e.formulario_id = ?'; $params[] = $filtros['formulario_id']; }
@@ -181,8 +181,22 @@ class Formulario {
if (!empty($filtros['enviado_por'])) { $where[] = 'e.enviado_por = ?'; $params[] = $filtros['enviado_por']; }
if (!empty($filtros['estado'])) { $where[] = 'e.estado = ?'; $params[] = $filtros['estado']; }
if (!empty($filtros['fecha_desde'])) { $where[] = 'DATE(e.created_at) >= ?'; $params[] = $filtros['fecha_desde']; }
$w = implode(' AND ', $where);
return $this->db->fetchAll("
if (!empty($filtros['fecha_hasta'])) { $where[] = 'DATE(e.created_at) <= ?'; $params[] = $filtros['fecha_hasta']; }
if (!empty($filtros['paciente'])) { $where[] = 'p.nombre_completo LIKE ?'; $params[] = '%' . $filtros['paciente'] . '%'; }
$w = implode(' AND ', $where);
$offset = ($pagina - 1) * $porPagina;
$total = $this->db->fetch(
"SELECT COUNT(*) AS n
FROM lab_form_envios e
JOIN lab_formularios f ON f.id = e.formulario_id
LEFT JOIN lab_pacientes p ON p.id = e.paciente_id
LEFT JOIN admin_users u ON u.id = e.enviado_por
WHERE $w",
$params
)['n'] ?? 0;
$rows = $this->db->fetchAll("
SELECT e.*, f.nombre AS form_nombre, f.categoria, f.esquema,
p.nombre_completo AS paciente_nombre,
u.full_name AS enviado_por_nombre
@@ -192,8 +206,16 @@ class Formulario {
LEFT JOIN admin_users u ON u.id = e.enviado_por
WHERE $w
ORDER BY e.created_at DESC
LIMIT 200
", $params);
LIMIT ? OFFSET ?
", array_merge($params, [$porPagina, $offset]));
return [
'data' => $rows,
'total' => (int)$total,
'pagina' => $pagina,
'por_pagina' => $porPagina,
'paginas' => (int)ceil($total / max(1, $porPagina)),
];
}
// ─────────────────────────────────────────────────────────────────────────
+115 -178
View File
@@ -29,6 +29,10 @@ $puedeEscribir = $esAdmin && hasModuleWrite('lab_formularios');
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; }
@@ -401,7 +405,7 @@ const tabs = {
document.querySelectorAll('#formTabs .nav-link').forEach((a,i) =>
a.classList.toggle('active', (tab==='lista') === (i===0))
);
if (tab === 'envios') envios.cargar();
if (tab === 'envios') { envios.poblarSelectFormulario(); envios.cargar(1); }
}
};
@@ -508,182 +512,130 @@ const ESTADO_ENV = {
firmado: { cls:'badge-firmado', icon:'✍️' },
expirado: { cls:'badge-expirado', icon:'⏰' },
};
const POR_PAGINA = 15;
const POR_PAGINA = 20;
const envios = {
_data: [],
_filtrado: [],
_pagina: 1,
_pagina: 1,
_total: 0,
_paginas: 1,
_paginaData: [],
_debTimer: null,
async cargar() {
// Mostrar spinner, ocultar tabla
_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 || '',
});
},
async cargar(pag = 1) {
this._pagina = pag;
const spinner = $('env-spinner');
const tabla = $('env-tabla-wrap');
const wrap = $('env-tabla-wrap');
if (spinner) spinner.style.display = '';
if (tabla) tabla.style.display = 'none';
const r = await fetch('api/lab/get_formularios.php?envios=1');
const d = await r.json();
this._data = d.data || [];
this._pagina = 1;
this._poblarSelectFormulario();
this._aplicarFiltros();
if (spinner) spinner.style.display = 'none';
if (tabla) tabla.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="6" 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() {
this._pagina = 1;
this._aplicarFiltros();
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 = '';
this.filtrar();
$('env-f-desde').value = '';
$('env-f-hasta').value = '';
$('env-f-paciente').value = '';
$('env-f-formulario').value = '';
$('env-f-estado').value = '';
this.cargar(1);
},
_poblarSelectFormulario() {
poblarSelectFormulario() {
const sel = $('env-f-formulario');
if (!sel) return;
const valorActual = sel.value;
sel.innerHTML = '<option value="">Todos los formularios</option>';
const vistos = new Set();
this._data.forEach(e => {
if (e.formulario_id && !vistos.has(e.formulario_id)) {
vistos.add(e.formulario_id);
const opt = document.createElement('option');
opt.value = e.formulario_id;
opt.textContent = e.form_nombre || ('Formulario #' + e.formulario_id);
sel.appendChild(opt);
}
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);
});
if (valorActual) sel.value = valorActual;
},
_aplicarFiltros() {
const desde = $('env-f-desde').value;
const hasta = $('env-f-hasta').value;
const paciente = $('env-f-paciente').value.trim().toLowerCase();
const formId = $('env-f-formulario').value;
const estadoFilt = $('env-f-estado').value;
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; }
this._filtrado = this._data.filter(e => {
const fecha = (e.created_at || '').slice(0, 10);
if (desde && fecha < desde) return false;
if (hasta && fecha > hasta) return false;
if (paciente && !(e.paciente_nombre || '').toLowerCase().includes(paciente)) return false;
if (formId && String(e.formulario_id) !== String(formId)) return false;
if (estadoFilt && e.estado !== estadoFilt) return false;
return true;
});
// badge de pendientes sobre el total sin filtrar
const badgePend = $('badge-envios');
const pend = this._data.filter(e => e.estado === 'pendiente').length;
if (pend) { badgePend.textContent = pend; badgePend.style.display = ''; }
else badgePend.style.display = 'none';
this._render();
},
exportarExcel() {
const rows = this._filtrado;
if (!rows.length) { showToast('Sin datos para exportar'); return; }
// Columnas fijas
const fixedCols = ['Formulario','Categoría','Paciente','Estado','Fecha envío','Fecha completado'];
// Recolectar todas las columnas dinámicas (campos del esquema) en orden de aparición
const fieldMap = new Map(); // label → id (primer visto)
const fieldMap = new Map();
rows.forEach(e => {
try {
const esquema = JSON.parse(e.esquema || '[]');
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(_) {}
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()]; // [[label, id], ...]
const allCols = [...fixedCols, ...fieldCols.map(([l]) => l)];
const csvRows = [allCols];
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 };
// Para este envío construir mapa label → id
const labelToId = new Map();
try {
JSON.parse(e.esquema || '[]').forEach(c => {
if (c.id && c.label) labelToId.set(c.label, c.id);
});
} catch(_) {}
const fixed = [
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', ' '),
];
const campos = fieldCols.map(([label, defaultId]) => {
const id = labelToId.get(label) || defaultId;
const val = todos[id];
if (val === undefined || val === null) return '';
return Array.isArray(val) ? val.join('; ') : String(val);
});
csvRows.push([...fixed, ...campos]);
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)); }),
]);
});
// Generar CSV con BOM UTF-8 para Excel
const csv = '\uFEFF' + csvRows.map(row =>
row.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(',')
).join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
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 = document.createElement('a');
a.href = url;
a.download = 'envios_formularios_' + new Date().toISOString().slice(0, 10) + '.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
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');
const total = this._filtrado.length;
if (!total) {
if (!this._paginaData.length) {
tb.innerHTML = '<tr><td colspan="6" 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 totalPag = Math.ceil(total / POR_PAGINA);
if (this._pagina > totalPag) this._pagina = totalPag;
const inicio = (this._pagina - 1) * POR_PAGINA;
const pagina = this._filtrado.slice(inicio, inicio + POR_PAGINA);
tb.innerHTML = pagina.map(e => {
tb.innerHTML = this._paginaData.map(e => {
const info = ESTADO_ENV[e.estado] || {};
return `<tr>
<td><span class="fw-semibold">${esc(e.form_nombre)}</span>
@@ -694,54 +646,39 @@ const envios = {
<td><small class="text-muted">${esc((e.expira_en||'').slice(0,10))}</small></td>
<td>
<button class="btn btn-xs btn-outline-secondary" title="Copiar link"
onclick="envios.copiarTokenLink('${esc(e.token)}')">
onclick="envios.copiarTokenLink('${esc(e.token)}')">
<i class="fas fa-copy"></i>
</button>
${['completado','firmado'].includes(e.estado)
? `<button class="btn btn-xs btn-outline-primary ms-1" title="Ver respuesta"
onclick="verRespuesta.abrir(${e.id})">
<i class="fas fa-eye"></i></button>
<a class="btn btn-xs btn-outline-danger ms-1" title="Descargar PDF"
href="ver_formulario_enviado.php?id=${e.id}" 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="ver_formulario_enviado.php?id=${e.id}" target="_blank"><i class="fas fa-file-pdf"></i></a>`
: ''}
</td>
</tr>`;
}).join('');
// Info
$('env-pag-info').textContent =
`Mostrando ${inicio + 1}${Math.min(inicio + POR_PAGINA, total)} de ${total} envíos`;
`Mostrando ${inicio+1}${inicio+this._paginaData.length} de ${this._total} envío(s)`;
// Paginación
const ul = $('env-paginacion');
const ul = $('env-paginacion');
const pag = this._pagina, tot = this._paginas;
ul.innerHTML = '';
const agregar = (label, pag, disabled, active) => {
const li = document.createElement('li');
li.className = `page-item${disabled?' disabled':''}${active?' active':''}`;
li.innerHTML = `<a class="page-link" href="#" onclick="event.preventDefault();${!disabled?`envios.irAPagina(${pag})`:''}">${label}</a>`;
ul.appendChild(li);
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);
};
agregar('«', 1, this._pagina === 1, false);
agregar('', this._pagina-1, this._pagina === 1, false);
// páginas con ventana deslizante ±2
const rango = 2;
const pMin = Math.max(1, this._pagina - rango);
const pMax = Math.min(totalPag, this._pagina + rango);
if (pMin > 1) { agregar('1', 1, false, false); if (pMin > 2) agregar('…', null, true, false); }
for (let p = pMin; p <= pMax; p++) agregar(p, p, false, p === this._pagina);
if (pMax < totalPag) { if (pMax < totalPag-1) agregar('…', null, true, false); agregar(totalPag, totalPag, false, false); }
agregar('', this._pagina+1, this._pagina === totalPag, false);
agregar('»', totalPag, this._pagina === totalPag, false);
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._pagina = p;
this._render();
$('panel-envios').scrollIntoView({ behavior:'smooth', block:'start' });
},
irAPagina(p) { this.cargar(p); $('panel-envios').scrollIntoView({behavior:'smooth',block:'start'}); },
copiarTokenLink(token) {
const url = window.location.origin + window.location.pathname.replace(/\/[^/]+$/, '')
@@ -919,7 +856,7 @@ const verRespuesta = {
async abrir(envioId) {
this._envioId = envioId;
const e = envios._data.find(x => x.id == envioId);
const e = envios._paginaData.find(x => x.id == envioId);
if (!e) return;
const cliente = JSON.parse(e.datos_cliente || '{}');