Tres bugs relacionados en el formulario "Control de Tomas de Muestras Prolongadas" (F-LAB-28), detectados en el turno E017: 1. El modal de documentos del historial abría el documento sin &embed=1, por lo que el filtrado de tomas irrelevantes (según examen seleccionado) nunca se aplicaba — se mostraban las 16 secciones posibles en vez de solo las 2-3 que aplicaban, con textos "Pendiente de firma" de más. 2. El guardado de firma en tomas progresivas usa un endpoint distinto (_guardarFirmaMP → guardar_toma.php) que no pasaba por el fix de nombre/cédula del firmante aplicado antes solo a _guardarFirmaPro. Como además una toma progresiva puede pasar por varios profesionales en el transcurso de horas, ahora se guarda identidad por-toma (campoId + '_pro_nombre'/'_pro_cedula'), no solo global. 3. El historial no mostraba quién atendió cada toma. get_consentimientos.php ahora expone tomas_detalle (etiqueta, hora, firmante) por consentimiento de toma progresiva, y el panel de documentos del historial lo renderiza en una tabla debajo del pill del formulario. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
846 lines
40 KiB
PHP
846 lines
40 KiB
PHP
<?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; }
|
||
|
||
/* ── Exámenes y comentarios ── */
|
||
.detail-extra { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
|
||
.detail-extra .d-lbl { font-size:.67rem; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
|
||
.detail-comment { padding:5px 0; border-bottom:1px solid #f1f5f9; font-size:.82rem; }
|
||
.detail-comment:last-child { border-bottom:none; }
|
||
|
||
/* ── Documentos firmados ── */
|
||
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
|
||
.docs-lbl { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
|
||
.doc-pill { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:99px;
|
||
font-size:.73rem; font-weight:600; margin:2px 2px; text-decoration:none; transition:opacity .15s; }
|
||
.doc-pill:hover { opacity:.8; }
|
||
.doc-pill.firmado { background:#dcfce7; color:#166534; border:1px solid #bbf7d0; }
|
||
.doc-pill.enviado { background:#fef9c3; color:#78350f; border:1px solid #fde68a; }
|
||
.doc-pill.pendiente{ background:#f1f5f9; color:#64748b; border:1px solid #e2e8f0; }
|
||
.docs-empty { font-size:.78rem; color:#94a3b8; font-style:italic; }
|
||
|
||
/* ── Modal cambio de estado ── */
|
||
.estado-modal-backdrop {
|
||
position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:1050;
|
||
display:flex; align-items:center; justify-content:center;
|
||
}
|
||
.estado-modal {
|
||
background:#fff; border-radius:14px; padding:24px 28px; width:340px;
|
||
box-shadow:0 20px 60px rgba(0,0,0,.2);
|
||
}
|
||
.estado-modal h5 { font-size:.95rem; font-weight:700; margin-bottom:4px; }
|
||
.estado-modal .sub { font-size:.78rem; color:#64748b; margin-bottom:16px; }
|
||
.estado-modal select { width:100%; padding:8px 10px; border:1px solid #e2e8f0;
|
||
border-radius:8px; font-size:.85rem; margin-bottom:16px; }
|
||
.estado-modal .actions { display:flex; gap:8px; justify-content:flex-end; }
|
||
|
||
/* ── Modal documento firmado ── */
|
||
.doc-modal-backdrop {
|
||
position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:1060;
|
||
display:flex; align-items:center; justify-content:center; padding:20px;
|
||
}
|
||
.doc-modal { background:#fff; border-radius:12px; width:100%; max-width:820px; height:88vh;
|
||
display:flex; flex-direction:column; overflow:hidden; box-shadow:0 20px 60px rgba(0,0,0,.25); }
|
||
.doc-modal-head { display:flex; align-items:center; gap:10px; padding:12px 16px; border-bottom:1px solid #e2e8f0; }
|
||
.doc-modal-head h5 { font-size:.9rem; font-weight:700; margin:0; flex:1; }
|
||
.doc-modal-head button { border:none; background:none; font-size:1.1rem; color:#64748b; cursor:pointer; }
|
||
.doc-modal iframe { flex:1; border:none; width:100%; }
|
||
</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>Recepción</th>
|
||
<th>Lugar / Bacteriólogo</th>
|
||
<th>Exámenes</th>
|
||
<th>Estado</th>
|
||
<th>Espera</th>
|
||
<th>Servicio</th>
|
||
<th>Total</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/';
|
||
const SERVICIO_PROLONGADO_MIN = 20; // minutos — a partir de aquí se resalta en rojo
|
||
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/"/g,'"').replace(/'/g,''').replace(/`/g,'`');
|
||
|
||
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: hoy
|
||
document.getElementById('fDesde').value = hoy();
|
||
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();
|
||
}
|
||
|
||
function irATurno(codigo, fecha) {
|
||
document.getElementById('fDesde').value = fecha || hoy();
|
||
document.getElementById('fHasta').value = fecha || hoy();
|
||
document.getElementById('fQ').value = codigo;
|
||
document.getElementById('fEstado').value = '';
|
||
buscar();
|
||
window.scrollTo({top:0, behavior:'smooth'});
|
||
}
|
||
|
||
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="12">
|
||
<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 nombreBd = esc(t.paciente_bd || '');
|
||
const nombreKiosko = esc(t.paciente_nombre || '');
|
||
const nombre = nombreBd || nombreKiosko || '—';
|
||
const docBd = t.paciente_doc ? esc((t.paciente_tipo_doc || 'CC') + ' ' + t.paciente_doc) : '';
|
||
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 esProlongado = t.servicio_min != null && t.servicio_min > SERVICIO_PROLONGADO_MIN;
|
||
const srvMin = t.servicio_min != null
|
||
? `<span${esProlongado ? ' style="color:#dc2626;font-weight:700" title="Servicio prolongado"' : ''}>${esProlongado ? '<i class="fas fa-exclamation-triangle me-1" style="font-size:.7rem"></i>' : ''}${t.servicio_min} m</span>`
|
||
: '—';
|
||
const totMin = t.total_min != null ? t.total_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}`;
|
||
|
||
// Recepción: desk + recepcionista
|
||
const desk = esc(t.recepcion_desk_nombre || '—');
|
||
const recep = esc(t.atendido_recepcion_nombre || '—');
|
||
const bacterio = esc(t.atendido_lugar_nombre || '—');
|
||
|
||
// Exámenes: badges con código
|
||
const examsHtml = (t.examenes && t.examenes.length)
|
||
? t.examenes.map(e => `<span class="badge bg-success-subtle text-success me-1 mb-1" style="font-size:.65rem">${esc(e.codigo)}</span>`).join('')
|
||
: '<span class="text-muted small">—</span>';
|
||
|
||
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>
|
||
<div>${nombre}${docBd ? `<span class="text-muted" style="font-size:.7rem;margin-left:5px">${docBd}</span>` : ''}</div>
|
||
${nombreKiosko ? `<div class="text-muted" style="font-size:.72rem">Kiosko: ${nombreKiosko}</div>` : ''}
|
||
</td>
|
||
<td class="small">
|
||
<div class="text-muted" style="font-size:.72rem"><i class="fas fa-desktop me-1 opacity-50"></i>${desk}</div>
|
||
<div><i class="fas fa-user-tie me-1 opacity-50" style="font-size:.72rem"></i>${recep}</div>
|
||
</td>
|
||
<td class="small">
|
||
<div class="fw-semibold">${lugar}</div>
|
||
<div class="text-muted" style="font-size:.72rem"><i class="fas fa-microscope me-1 opacity-50"></i>${bacterio}</div>
|
||
</td>
|
||
<td style="max-width:140px">${examsHtml}</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">${totMin}</td>
|
||
<td class="text-nowrap">
|
||
<button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1"
|
||
onclick="toggleDetalle('${rowId}', this, ${t.id})"
|
||
title="Ver detalles">
|
||
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-warning py-0 px-2"
|
||
onclick="abrirModalEstado(${t.id}, '${t.estado}', '${nombre}')"
|
||
title="Cambiar estado">
|
||
<i class="fas fa-exchange-alt" 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 || '—') },
|
||
{ lbl:'Recepción (desk)', val: esc(t.recepcion_desk_nombre || '—') },
|
||
{ lbl:'Atendió recepción', val: esc(t.atendido_recepcion_nombre || '—') },
|
||
{ lbl:'Atendió muestras', val: esc(t.atendido_lugar_nombre || '—') },
|
||
{ lbl:'Notas', val: esc(t.notas || '—') },
|
||
];
|
||
// Turnos vinculados (muestra pendiente completada en otra visita, o viceversa)
|
||
let relHtml = '';
|
||
if (t.relacionados && t.relacionados.length) {
|
||
relHtml = `<div class="detail-extra">
|
||
<div class="d-lbl"><i class="fas fa-link me-1 text-primary"></i>Turno vinculado</div>
|
||
<div class="d-val">${t.relacionados.map(r => {
|
||
const txt = r.direccion === 'seguimiento'
|
||
? `Muestra completada en ${esc(r.codigo)}`
|
||
: `Completa muestra pendiente de ${esc(r.codigo)}`;
|
||
return `<button class="btn btn-sm btn-outline-primary py-0 px-2 mb-1 me-1"
|
||
style="font-size:.72rem" onclick="irATurno('${esc(r.codigo)}','${r.fecha}')">
|
||
<i class="fas fa-arrow-right me-1"></i>${txt}
|
||
</button>`;
|
||
}).join('')}</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Exámenes
|
||
let examHtml = '';
|
||
if (t.examenes && t.examenes.length) {
|
||
examHtml = `<div class="detail-extra">
|
||
<div class="d-lbl"><i class="fas fa-vials me-1 text-success"></i>Exámenes (${t.examenes.length})</div>
|
||
<div class="d-val">${t.examenes.map(e => `<span class="badge bg-success-subtle text-success me-1 mb-1">${esc(e.nombre)}</span>`).join('')}</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Comentarios
|
||
let comHtml = '';
|
||
if (t.comentarios && t.comentarios.length) {
|
||
comHtml = `<div class="detail-extra">
|
||
<div class="d-lbl"><i class="fas fa-comments me-1 text-primary"></i>Comentarios</div>
|
||
<div>${t.comentarios.map(c => `
|
||
<div class="detail-comment">
|
||
<span class="fw-semibold">${esc(c.usuario_nombre)}</span>
|
||
<span class="text-muted small ms-1">${c.creado_at ? new Date(c.creado_at.replace(' ','T')).toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'}) : ''}</span>
|
||
<div class="text-secondary">${esc(c.comentario)}</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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>
|
||
${relHtml}${examHtml}${comHtml}
|
||
<div class="docs-section" id="docs-${t.id}">
|
||
<div class="docs-lbl"><i class="fas fa-file-signature me-1"></i>Documentos</div>
|
||
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
|
||
</div>`;
|
||
}
|
||
|
||
function toggleDetalle(rowId, btn, turnoId) {
|
||
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';
|
||
if (!visible && turnoId) {
|
||
const docsEl = document.getElementById(`docs-${turnoId}`);
|
||
if (docsEl && !docsEl.dataset.loaded) {
|
||
docsEl.dataset.loaded = '1';
|
||
cargarDocumentosTurno(turnoId, docsEl);
|
||
}
|
||
}
|
||
}
|
||
|
||
const BASE_URL_ROOT = '<?= BASE_URL ?>';
|
||
|
||
async function cargarDocumentosTurno(turnoId, container) {
|
||
try {
|
||
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
|
||
const json = await res.json();
|
||
if (!json.ok) { container.querySelector('span').textContent = 'Error al cargar'; return; }
|
||
const list = json.consentimientos || [];
|
||
if (!list.length) {
|
||
container.innerHTML = '<div class="docs-lbl"><i class="fas fa-file-signature me-1"></i>Documentos</div>'
|
||
+ '<span class="docs-empty">Sin documentos para este turno</span>';
|
||
return;
|
||
}
|
||
const LBLS = { firmado:'Firmado', enviado:'Enviado', visto:'Visto', pendiente:'Pendiente' };
|
||
const pills = list.map(c => {
|
||
const est = c.estado || 'pendiente';
|
||
const nombre = esc(c.formulario_nombre || 'Documento');
|
||
// Formulario de tomas progresivas (ej. Control de Tomas de Muestras Prolongadas F-LAB-28)
|
||
const tomas = c.es_toma_progresiva
|
||
? ` <span class="badge bg-danger-subtle text-danger ms-1" style="font-size:.62rem">${c.tomas_firmadas||0}/${c.tomas_total||0} tomas</span>`
|
||
: '';
|
||
if (est === 'firmado' && c.token) {
|
||
const url = BASE_URL_ROOT + 'ver_formulario_enviado.php?token=' + encodeURIComponent(c.token) + '&embed=1';
|
||
return `<a class="doc-pill firmado" href="${url}" onclick="return abrirDocModal(event,'${url}','${nombre.replace(/'/g,"\\'")}')">
|
||
<i class="fas fa-check-circle"></i>${nombre}${tomas}
|
||
</a>`;
|
||
}
|
||
const cls = est === 'enviado' || est === 'visto' ? 'enviado' : 'pendiente';
|
||
const ico = est === 'enviado' ? 'fa-paper-plane' : (est === 'visto' ? 'fa-eye' : 'fa-clock');
|
||
return `<span class="doc-pill ${cls}"><i class="fas ${ico}"></i>${nombre}${tomas}</span>`;
|
||
}).join('');
|
||
|
||
// Detalle por toma (quién atendió cada una) para formularios de tomas progresivas
|
||
const detalles = list.filter(c => c.es_toma_progresiva && c.tomas_detalle && c.tomas_detalle.length)
|
||
.map(c => `<table class="table table-sm mb-2" style="font-size:.76rem">
|
||
<thead><tr><th>${esc(c.formulario_nombre||'')}</th><th>Hora</th><th>Atendió</th></tr></thead>
|
||
<tbody>${c.tomas_detalle.map(t => `<tr class="${t.firmado ? '' : 'text-muted'}">
|
||
<td>${esc((t.label||'').replace(/^Toma\s*·\s*/,''))}</td>
|
||
<td>${esc(t.hora || '—')}</td>
|
||
<td>${t.firmado ? esc(t.firmante || '—') : '<i class="fas fa-clock"></i> pendiente'}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table>`).join('');
|
||
|
||
container.innerHTML = '<div class="docs-lbl"><i class="fas fa-file-signature me-1"></i>Documentos</div>' + pills + detalles;
|
||
} catch(_) {
|
||
const sp = container.querySelector('span');
|
||
if (sp) sp.textContent = 'Error de conexión';
|
||
}
|
||
}
|
||
|
||
// ── 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');
|
||
}
|
||
|
||
// ── Cambio de estado ─────────────────────────────────────────
|
||
const ESTADOS_OPCIONES = {
|
||
espera : [{v:'en_recepcion', l:'En recepción'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
|
||
en_recepcion : [{v:'en_espera_lugar', l:'Esp. lugar'}, {v:'espera', l:'Espera'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
|
||
en_espera_lugar : [{v:'en_servicio', l:'En servicio'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
|
||
en_servicio : [{v:'finalizado', l:'Finalizado'}, {v:'en_espera_lugar', l:'Esp. lugar'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
|
||
// estados finales: reapertura (admin)
|
||
finalizado : [{v:'en_servicio', l:'Reabrir → En servicio'}, {v:'en_espera_lugar', l:'Reabrir → Esp. lugar'}, {v:'espera', l:'Reabrir → Espera'}],
|
||
ausente : [{v:'espera', l:'Reabrir → Espera'}, {v:'en_servicio', l:'Reabrir → En servicio'}],
|
||
cancelado : [{v:'espera', l:'Reabrir → Espera'}, {v:'en_servicio', l:'Reabrir → En servicio'}],
|
||
};
|
||
|
||
let _modalTurnoId = null;
|
||
|
||
function abrirModalEstado(turnoId, estadoActual, nombre) {
|
||
_modalTurnoId = turnoId;
|
||
const opciones = ESTADOS_OPCIONES[estadoActual] || [];
|
||
if (!opciones.length) { alert('No hay transiciones disponibles para este estado.'); return; }
|
||
|
||
const sel = opciones.map(o => `<option value="${o.v}">${o.l}</option>`).join('');
|
||
const esReopetura = ['finalizado','ausente','cancelado'].includes(estadoActual);
|
||
|
||
const backdrop = document.createElement('div');
|
||
backdrop.className = 'estado-modal-backdrop';
|
||
backdrop.id = 'estadoModalBackdrop';
|
||
backdrop.innerHTML = `
|
||
<div class="estado-modal" onclick="event.stopPropagation()">
|
||
<h5><i class="fas fa-exchange-alt me-2 text-warning"></i>Cambiar estado del turno</h5>
|
||
<div class="sub">${nombre}${esReopetura ? ' <span class="badge bg-warning text-dark ms-1">Reapertura admin</span>' : ''}</div>
|
||
<select id="selectNuevoEstado">${sel}</select>
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-outline-secondary" onclick="cerrarModalEstado()">Cancelar</button>
|
||
<button class="btn btn-sm btn-warning" onclick="confirmarCambioEstado()">
|
||
<i class="fas fa-check me-1"></i>Confirmar
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
backdrop.addEventListener('click', cerrarModalEstado);
|
||
document.body.appendChild(backdrop);
|
||
}
|
||
|
||
function cerrarModalEstado() {
|
||
document.getElementById('estadoModalBackdrop')?.remove();
|
||
_modalTurnoId = null;
|
||
}
|
||
|
||
async function confirmarCambioEstado() {
|
||
const nuevoEstado = document.getElementById('selectNuevoEstado').value;
|
||
if (!_modalTurnoId || !nuevoEstado) return;
|
||
|
||
const btn = document.querySelector('#estadoModalBackdrop .btn-warning');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
|
||
|
||
try {
|
||
const res = await fetch(`${API}cambiar_estado.php`, {
|
||
method : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body : JSON.stringify({ turno_id: _modalTurnoId, nuevo_estado: nuevoEstado }),
|
||
});
|
||
const json = await res.json();
|
||
if (json.ok) {
|
||
cerrarModalEstado();
|
||
buscar(_state.page);
|
||
} else {
|
||
alert('Error: ' + (json.error || 'No se pudo cambiar el estado.'));
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<i class="fas fa-check me-1"></i>Confirmar';
|
||
}
|
||
} catch(e) {
|
||
alert('Error de conexión');
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<i class="fas fa-check me-1"></i>Confirmar';
|
||
}
|
||
}
|
||
|
||
// ── Modal documento firmado ────────────────────────────────────
|
||
function abrirDocModal(ev, url, nombre) {
|
||
ev.preventDefault();
|
||
const backdrop = document.createElement('div');
|
||
backdrop.className = 'doc-modal-backdrop';
|
||
backdrop.id = 'docModalBackdrop';
|
||
backdrop.innerHTML = `
|
||
<div class="doc-modal" onclick="event.stopPropagation()">
|
||
<div class="doc-modal-head">
|
||
<h5><i class="fas fa-file-signature me-2 text-success"></i>${esc(nombre)}</h5>
|
||
<a href="${url}" target="_blank" title="Abrir en pestaña nueva" class="me-2"><i class="fas fa-external-link-alt"></i></a>
|
||
<button onclick="cerrarDocModal()" title="Cerrar"><i class="fas fa-times"></i></button>
|
||
</div>
|
||
<iframe src="${url}"></iframe>
|
||
</div>`;
|
||
backdrop.addEventListener('click', cerrarDocModal);
|
||
document.body.appendChild(backdrop);
|
||
return false;
|
||
}
|
||
function cerrarDocModal() {
|
||
document.getElementById('docModalBackdrop')?.remove();
|
||
}
|
||
|
||
// ── 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(); ?>
|