This commit is contained in:
Lizandro Guarnizo
2026-04-21 21:13:35 -05:00
parent 8cd3348d94
commit 3a19f08bf8
5 changed files with 916 additions and 0 deletions
+552
View File
@@ -0,0 +1,552 @@
<?php
/**
* modules/turnero/views/historial.php
* Historial consultable de turnos con filtros multi-día y paginación.
*/
require_once APP_ROOT . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
Layout::open('Historial de Turnos', 'fas fa-history');
?>
<style>
body { background:#f1f5f9; }
/* ── Encabezado ── */
.page-header {
background:#fff; border-bottom:1px solid #e2e8f0;
padding:12px 22px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;
}
.page-header h1 { font-size:1.12rem; font-weight:700; color:#1e293b; margin:0; flex:1; min-width:150px; }
/* ── Filtros ── */
.filter-bar {
background:#fff; border-bottom:1px solid #e2e8f0;
padding:12px 22px; display:flex; align-items:flex-end; gap:10px; flex-wrap:wrap;
}
.filter-group { display:flex; flex-direction:column; gap:3px; }
.filter-group label { font-size:.7rem; font-weight:600; text-transform:uppercase;
letter-spacing:.06em; color:#64748b; }
.filter-group .form-control,
.filter-group .form-select { font-size:.82rem; min-width:120px; }
.filter-group.wide .form-control { min-width:200px; }
/* ── Contenido ── */
.content-wrap { max-width:1200px; margin:22px auto; padding:0 16px 60px; }
/* ── KPIs resumen ── */
.kpi-strip {
display:flex; flex-wrap:wrap; gap:10px; margin-bottom:18px;
}
.kpi-chip {
display:flex; align-items:center; gap:7px;
background:#fff; border:1px solid #e2e8f0; border-radius:99px;
padding:5px 14px; font-size:.8rem;
}
.kpi-chip .chip-val { font-weight:800; font-size:1rem; }
.kpi-chip .chip-lbl { color:#64748b; }
/* ── Tabla ── */
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px;
padding:0; overflow:hidden; }
.section-card .table { margin:0; }
.section-card .table thead th {
background:#f8fafc; font-size:.7rem; text-transform:uppercase;
letter-spacing:.07em; color:#475569; font-weight:700;
padding:10px 12px; white-space:nowrap; border-bottom:2px solid #e2e8f0;
}
.section-card .table tbody td { padding:9px 12px; font-size:.82rem; vertical-align:middle; }
.section-card .table tbody tr:hover { background:#f8fafc; }
/* ── Estado badges ── */
.eb { font-size:.65rem; padding:2px 8px; border-radius:99px; font-weight:700; white-space:nowrap; }
.eb-espera { background:#fef9c3; color:#78350f; }
.eb-en_recepcion { background:#dbeafe; color:#1e40af; }
.eb-en_espera_lugar { background:#fde68a; color:#92400e; }
.eb-en_servicio { background:#dcfce7; color:#166534; }
.eb-finalizado { background:#f1f5f9; color:#334155; }
.eb-ausente { background:#fee2e2; color:#991b1b; }
.eb-cancelado { background:#f1f5f9; color:#9ca3af; }
/* ── Paginación ── */
.pagination-wrap {
display:flex; align-items:center; justify-content:space-between;
padding:12px 18px; border-top:1px solid #e2e8f0; flex-wrap:wrap; gap:8px;
}
.pagination-info { font-size:.78rem; color:#64748b; }
.page-btns { display:flex; gap:4px; }
.page-btns button { font-size:.78rem; padding:3px 10px; border-radius:6px;
border:1px solid #e2e8f0; background:#fff; cursor:pointer; }
.page-btns button.active { background:var(--brand,#2563eb); color:#fff; border-color:var(--brand,#2563eb); }
.page-btns button:disabled { opacity:.4; cursor:default; }
/* ── Estado vacío ── */
.empty-state { text-align:center; color:#94a3b8; padding:60px 20px; font-size:.9rem; }
.empty-state i { font-size:2.5rem; display:block; margin-bottom:10px; }
/* ── Spinner ── */
.spinner-row td { text-align:center; padding:40px; color:#94a3b8; }
/* ── Detalle expandible ── */
.detail-row { background:#f8fafc; }
.detail-row td { padding:0 !important; }
.detail-body { padding:10px 18px 14px; }
.detail-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:8px; }
.detail-item { background:#fff; border:1px solid #e2e8f0; border-radius:8px; padding:8px 12px; }
.detail-item .d-lbl { font-size:.67rem; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; }
.detail-item .d-val { font-size:.85rem; font-weight:600; color:#1e293b; }
</style>
<!-- ── Encabezado ────────────────────────────────────────────── -->
<div class="page-header">
<a href="<?= BASE_URL ?>/erp.php?m=turnero&v=dashboard" class="text-muted text-decoration-none small">
<i class="fas fa-arrow-left me-1"></i>Dashboard
</a>
<h1><i class="fas fa-history me-2 text-primary"></i>Historial de Turnos</h1>
<button class="btn btn-outline-success btn-sm" id="btnExport" title="Exportar CSV">
<i class="fas fa-file-csv me-1"></i>Exportar CSV
</button>
</div>
<!-- ── Barra de filtros ──────────────────────────────────────── -->
<div class="filter-bar">
<div class="filter-group">
<label>Desde</label>
<input type="date" id="fDesde" class="form-control form-control-sm">
</div>
<div class="filter-group">
<label>Hasta</label>
<input type="date" id="fHasta" class="form-control form-control-sm">
</div>
<div class="filter-group">
<label>Estado</label>
<select id="fEstado" class="form-select form-select-sm">
<option value="">Todos</option>
<option value="finalizado">Finalizado</option>
<option value="ausente">Ausente</option>
<option value="cancelado">Cancelado</option>
<option value="en_servicio">En servicio</option>
<option value="en_espera_lugar">Esp. lugar</option>
<option value="en_recepcion">En recepción</option>
<option value="espera">En espera</option>
</select>
</div>
<div class="filter-group" id="wrapLugar">
<label>Lugar</label>
<select id="fLugar" class="form-select form-select-sm">
<option value="0">Todos</option>
</select>
</div>
<div class="filter-group" id="wrapPrioridad">
<label>Prioridad</label>
<select id="fPrioridad" class="form-select form-select-sm">
<option value="0">Todas</option>
</select>
</div>
<div class="filter-group wide">
<label>Buscar paciente / código</label>
<input type="text" id="fQ" class="form-control form-control-sm" placeholder="Nombre o código…">
</div>
<button class="btn btn-primary btn-sm align-self-end" id="btnBuscar">
<i class="fas fa-search me-1"></i>Buscar
</button>
<button class="btn btn-outline-secondary btn-sm align-self-end" id="btnHoy" title="Solo hoy">
Hoy
</button>
<button class="btn btn-outline-secondary btn-sm align-self-end" id="btn7d" title="Últimos 7 días">
7 días
</button>
<button class="btn btn-outline-secondary btn-sm align-self-end" id="btn30d" title="Últimos 30 días">
30 días
</button>
</div>
<!-- ── Contenido ──────────────────────────────────────────────── -->
<div class="content-wrap">
<!-- Resumen por estado del rango -->
<div class="kpi-strip" id="kpiStrip" style="display:none"></div>
<!-- Tabla principal -->
<div class="section-card">
<div class="table-responsive">
<table class="table table-hover align-middle" id="tablaHistorial">
<thead>
<tr>
<th>Fecha</th>
<th>Código</th>
<th>Prioridad</th>
<th>Paciente</th>
<th>Teléfono</th>
<th>Lugar</th>
<th>Estado</th>
<th>Espera</th>
<th>Servicio</th>
<th>Entrada</th>
<th></th>
</tr>
</thead>
<tbody id="tbody">
<tr class="spinner-row">
<td colspan="11"><i class="fas fa-spinner fa-spin me-2"></i>Cargando…</td>
</tr>
</tbody>
</table>
</div>
<!-- Paginación -->
<div class="pagination-wrap" id="paginacionWrap" style="display:none">
<div class="pagination-info" id="paginacionInfo"></div>
<div class="page-btns" id="pagBtns"></div>
</div>
</div>
</div>
<script>
/* ═══════════════════════════════════════════════════════════
Historial de Turnos
═══════════════════════════════════════════════════════════ */
const API = '<?= BASE_URL ?>/modules/turnero/api/';
let _state = {
fechaDesde : '',
fechaHasta : '',
estado : '',
lugarId : 0,
prioridadId: 0,
q : '',
page : 1,
perPage : 50,
totalPages : 1,
total : 0,
};
// ── Utilidades ────────────────────────────────────────────────
const esc = s => (s == null ? '' : String(s))
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
function hoy() { return new Date().toISOString().slice(0,10); }
function hace(n){ return new Date(Date.now()-n*86400000).toISOString().slice(0,10); }
// ── Inicialización ────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
// Fecha por defecto: últimos 7 días
document.getElementById('fDesde').value = hace(6);
document.getElementById('fHasta').value = hoy();
document.getElementById('fHasta').max = hoy();
// Cargar catálogos (lugares + prioridades)
await cargarMeta();
// Primera carga
buscar();
// Eventos
document.getElementById('btnBuscar').addEventListener('click', buscar);
document.getElementById('fQ').addEventListener('keydown', e => { if (e.key==='Enter') buscar(); });
document.getElementById('btnExport').addEventListener('click', exportarCSV);
document.getElementById('btnHoy').addEventListener('click', () => {
document.getElementById('fDesde').value = hoy();
document.getElementById('fHasta').value = hoy();
buscar();
});
document.getElementById('btn7d').addEventListener('click', () => {
document.getElementById('fDesde').value = hace(6);
document.getElementById('fHasta').value = hoy();
buscar();
});
document.getElementById('btn30d').addEventListener('click', () => {
document.getElementById('fDesde').value = hace(29);
document.getElementById('fHasta').value = hoy();
buscar();
});
});
// ── Catálogos ─────────────────────────────────────────────────
async function cargarMeta() {
try {
const res = await fetch(`${API}get_historial.php?meta=1&fecha_desde=${hoy()}&fecha_hasta=${hoy()}&per_page=1`);
const json = await res.json();
if (!json.ok) return;
const selLugar = document.getElementById('fLugar');
(json.meta.lugares || []).forEach(l => {
const o = document.createElement('option');
o.value = l.id; o.textContent = l.nombre;
selLugar.appendChild(o);
});
const selPri = document.getElementById('fPrioridad');
(json.meta.prioridades || []).forEach(p => {
const o = document.createElement('option');
o.value = p.id;
o.textContent = `${p.codigo} — ${p.nombre}`;
selPri.appendChild(o);
});
} catch(_) {}
}
// ── Búsqueda principal ────────────────────────────────────────
function buscar(pageNum) {
_state.fechaDesde = document.getElementById('fDesde').value || hace(6);
_state.fechaHasta = document.getElementById('fHasta').value || hoy();
_state.estado = document.getElementById('fEstado').value;
_state.lugarId = parseInt(document.getElementById('fLugar').value) || 0;
_state.prioridadId = parseInt(document.getElementById('fPrioridad').value)|| 0;
_state.q = document.getElementById('fQ').value.trim();
_state.page = pageNum || 1;
cargarPagina();
}
async function cargarPagina() {
mostrarSpinner();
const params = new URLSearchParams({
fecha_desde : _state.fechaDesde,
fecha_hasta : _state.fechaHasta,
estado : _state.estado,
lugar_id : _state.lugarId,
prioridad_id : _state.prioridadId,
q : _state.q,
page : _state.page,
per_page : _state.perPage,
});
try {
const res = await fetch(`${API}get_historial.php?${params}`);
const json = await res.json();
if (!json.ok) { mostrarError(json.error || 'Error al cargar historial'); return; }
_state.totalPages = json.total_pages;
_state.total = json.total;
_state.page = json.page;
renderKpis(json.resumen_estados);
renderTabla(json.turnos);
renderPaginacion();
} catch(e) {
mostrarError('Error de conexión');
}
}
// ── KPIs de resumen ───────────────────────────────────────────
const ESTADO_META = {
finalizado : { lbl:'Finalizados', bg:'#f1f5f9', color:'#334155' },
ausente : { lbl:'Ausentes', bg:'#fee2e2', color:'#991b1b' },
cancelado : { lbl:'Cancelados', bg:'#f1f5f9', color:'#9ca3af' },
en_servicio : { lbl:'En servicio', bg:'#dcfce7', color:'#166534' },
en_espera_lugar : { lbl:'Esp. lugar', bg:'#fde68a', color:'#92400e' },
en_recepcion : { lbl:'En recepción', bg:'#dbeafe', color:'#1e40af' },
espera : { lbl:'En espera', bg:'#fef9c3', color:'#78350f' },
};
function renderKpis(resumen) {
const strip = document.getElementById('kpiStrip');
let total = Object.values(resumen).reduce((a,b)=>a+(+b),0);
if (!total) { strip.style.display='none'; return; }
strip.style.display = 'flex';
strip.innerHTML = `
<div class="kpi-chip">
<span class="chip-val">${total}</span>
<span class="chip-lbl">turnos (rango)</span>
</div>
${Object.entries(resumen).map(([est,cnt]) => {
const m = ESTADO_META[est] || {lbl:est, bg:'#f1f5f9', color:'#334155'};
return `<div class="kpi-chip" style="border-color:${m.color}22">
<span class="chip-val" style="color:${m.color}">${cnt}</span>
<span class="chip-lbl">${m.lbl}</span>
</div>`;
}).join('')}
`;
}
// ── Tabla ─────────────────────────────────────────────────────
const ESTADO_CLS = {
espera:'eb-espera', en_recepcion:'eb-en_recepcion', en_espera_lugar:'eb-en_espera_lugar',
en_servicio:'eb-en_servicio', finalizado:'eb-finalizado', ausente:'eb-ausente', cancelado:'eb-cancelado',
};
const ESTADO_LBL = {
espera:'Espera', en_recepcion:'Recepción', en_espera_lugar:'Esp. lugar',
en_servicio:'En servicio', finalizado:'Finalizado', ausente:'Ausente', cancelado:'Cancelado',
};
function renderTabla(turnos) {
const tbody = document.getElementById('tbody');
if (!turnos.length) {
tbody.innerHTML = `<tr><td colspan="11">
<div class="empty-state"><i class="fas fa-search"></i>Sin resultados para los filtros aplicados</div>
</td></tr>`;
return;
}
tbody.innerHTML = turnos.map((t,i) => {
const nombre = esc(t.paciente_bd || t.paciente_nombre || '—');
const lugar = esc(t.lugar_nombre || '—');
const fecha = t.fecha_sesion || '—';
const horaEnt = t.creado_at
? new Date(t.creado_at.replace(' ','T'))
.toLocaleString('es-CO',{hour:'2-digit',minute:'2-digit'})
: '—';
const espMin = t.espera_min != null ? t.espera_min + ' m' : '—';
const srvMin = t.servicio_min != null ? t.servicio_min + ' m' : '—';
const tel = esc(t.paciente_cel || '—');
const priCls = esc(t.prioridad_color || '#888');
const cls = ESTADO_CLS[t.estado] || 'eb-finalizado';
const lbl = ESTADO_LBL[t.estado] || t.estado;
const rowId = `det-${t.id}`;
return `<tr data-id="${t.id}">
<td class="text-nowrap text-muted small">${esc(fecha)}</td>
<td><strong style="color:${priCls}">${esc(t.codigo)}</strong></td>
<td>
<span class="eb" style="background:${priCls}22;color:${priCls}">
${esc(t.prioridad_codigo)}
</span>
</td>
<td>${nombre}</td>
<td class="text-muted small">${tel}</td>
<td>${lugar}</td>
<td><span class="eb ${cls}">${lbl}</span></td>
<td class="text-nowrap">${espMin}</td>
<td class="text-nowrap">${srvMin}</td>
<td class="text-nowrap small text-muted">${horaEnt}</td>
<td>
<button class="btn btn-sm btn-outline-secondary py-0 px-2"
onclick="toggleDetalle('${rowId}', this)"
title="Ver detalles de timestamps">
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
</button>
</td>
</tr>
<tr id="${rowId}" class="detail-row" style="display:none">
<td colspan="11">
<div class="detail-body">
${renderDetalle(t)}
</div>
</td>
</tr>`;
}).join('');
}
function renderDetalle(t) {
const fmt = dt => {
if (!dt) return '—';
const d = new Date(dt.replace(' ','T'));
return d.toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'});
};
const items = [
{ lbl:'Creado', val: fmt(t.creado_at) },
{ lbl:'Inicio recepción', val: fmt(t.inicio_recepcion_at) },
{ lbl:'Fin recepción', val: fmt(t.fin_recepcion_at) },
{ lbl:'Inicio servicio', val: fmt(t.inicio_lugar_at) },
{ lbl:'Fin servicio', val: fmt(t.fin_lugar_at) },
{ lbl:'Código completo', val: esc(t.codigo) },
{ lbl:'Número en cola', val: t.numero ?? '—' },
{ lbl:'Teléfono', val: esc(t.paciente_cel || '—') },
];
return `<div class="detail-grid">
${items.map(it=>`
<div class="detail-item">
<div class="d-lbl">${it.lbl}</div>
<div class="d-val">${it.val}</div>
</div>`).join('')}
</div>`;
}
function toggleDetalle(rowId, btn) {
const row = document.getElementById(rowId);
const icon = btn.querySelector('i');
if (!row) return;
const visible = row.style.display !== 'none';
row.style.display = visible ? 'none' : '';
icon.className = visible ? 'fas fa-chevron-down' : 'fas fa-chevron-up';
icon.style.fontSize = '.65rem';
}
// ── Paginación ────────────────────────────────────────────────
function renderPaginacion() {
const wrap = document.getElementById('paginacionWrap');
const info = document.getElementById('paginacionInfo');
const btns = document.getElementById('pagBtns');
wrap.style.display = '';
const desde = (_state.page - 1) * _state.perPage + 1;
const hasta = Math.min(_state.page * _state.perPage, _state.total);
info.textContent = `Mostrando ${desde}${hasta} de ${_state.total} turnos`;
// Generar botones de página (máximo 7 visibles)
const total = _state.totalPages;
const current = _state.page;
let pages = [];
if (total <= 7) {
pages = Array.from({length: total}, (_,i) => i+1);
} else {
pages = [1];
if (current > 3) pages.push('…');
for (let p = Math.max(2, current-1); p <= Math.min(total-1, current+1); p++) pages.push(p);
if (current < total-2) pages.push('…');
pages.push(total);
}
btns.innerHTML = `
<button ${current<=1?'disabled':''} onclick="irPagina(${current-1})">
<i class="fas fa-chevron-left"></i>
</button>
${pages.map(p =>
p === '…'
? `<button disabled>…</button>`
: `<button class="${p===current?'active':''}" onclick="irPagina(${p})">${p}</button>`
).join('')}
<button ${current>=total?'disabled':''} onclick="irPagina(${current+1})">
<i class="fas fa-chevron-right"></i>
</button>
`;
}
function irPagina(n) {
if (n < 1 || n > _state.totalPages) return;
_state.page = n;
cargarPagina();
window.scrollTo({top:0, behavior:'smooth'});
}
// ── Exportar CSV ──────────────────────────────────────────────
function exportarCSV() {
const params = new URLSearchParams({
fecha : _state.fechaDesde, // export_csv actual usa 'fecha' — usamos fecha_desde
m : 'historial', // flag para que export_csv sepa que es multi-día
fecha_desde : _state.fechaDesde,
fecha_hasta : _state.fechaHasta,
estado : _state.estado,
lugar_id : _state.lugarId,
prioridad_id : _state.prioridadId,
q : _state.q,
per_page : 9999,
page : 1,
});
// Generamos CSV en cliente a partir de los datos actuales si son pocos,
// o mandamos al endpoint get_historial con per_page alto para descarga
window.open(`${API}export_historial.php?${params}`, '_blank');
}
// ── Helpers visuales ──────────────────────────────────────────
function mostrarSpinner() {
document.getElementById('tbody').innerHTML =
'<tr class="spinner-row"><td colspan="11"><i class="fas fa-spinner fa-spin me-2"></i>Buscando…</td></tr>';
document.getElementById('paginacionWrap').style.display = 'none';
document.getElementById('kpiStrip').style.display = 'none';
}
function mostrarError(msg) {
document.getElementById('tbody').innerHTML =
`<tr><td colspan="11"><div class="empty-state text-danger">
<i class="fas fa-exclamation-circle"></i>${esc(msg)}
</div></td></tr>`;
}
</script>
<?php Layout::close(); ?>