Files
Lizandro GuarnizoandClaude Opus 5 8218daaff7 Historial: abrir el detalle al hacer clic en cualquier parte de la fila
Antes había que apuntarle a una flecha de pocos píxeles al final de la fila.
Ahora sirve toda la fila, como ya funciona el listado de pacientes.

Con dos resguardos, porque la fila lleva controles y datos que se copian:
no abre si el clic cayó sobre un botón o un enlace —el de cambiar estado
tiene lo suyo que hacer— ni si el usuario venía seleccionando texto, que en
esta pantalla es frecuente para copiar un nombre o un código.

La flecha se conserva: sigue siendo la señal visual de que la fila se abre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:35:47 -05:00

960 lines
46 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
/**
* 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; }
/* ── Origen de cada nota ── */
.com-tipo { font-size:.62rem; font-weight:700; letter-spacing:.04em; padding:1px 7px;
border-radius:10px; text-transform:uppercase; margin-right:6px; }
.com-tipo.recepcion { background:#dbeafe; color:#1d4ed8; }
.com-tipo.muestras { background:#dcfce7; color:#15803d; }
.com-tipo.general { background:#fef3c7; color:#92400e; }
/* ── Documentos firmados ── */
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
.traza { border-left:2px solid #e2e8f0; margin:2px 0 0 6px; padding-left:10px; }
.traza-fila { display:flex; align-items:flex-start; gap:8px; padding:3px 0; font-size:.78rem; }
.traza-hora { color:#94a3b8; font-variant-numeric:tabular-nums; min-width:42px; }
.traza-ico { width:14px; text-align:center; margin-top:2px; font-size:.72rem; }
.traza-txt { flex:1; color:#334155; }
.traza-quien { color:#64748b; font-style:italic; }
.traza-extra { color:#b45309; font-size:.74rem; margin-top:1px; }
.traza-nota { color:#94a3b8; font-size:.7rem; margin-top:6px; font-style:italic; }
.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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/`/g,'&#96;');
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
// Solo el conteo: con 9,5 exámenes de promedio por turno, las etiquetas de
// código llenaban cinco renglones y la tabla quedaba ilegible. Los nombres
// completos ya están en el detalle desplegable, así que no se pierde nada.
const nExam = (t.examenes && t.examenes.length) ? t.examenes.length : 0;
const examsHtml = nExam
? `<span class="badge bg-success-subtle text-success" style="font-size:.68rem"
title="${esc(t.examenes.map(e => e.nombre || e.codigo).join(' · '))}">
${nExam} examen${nExam === 1 ? '' : 'es'}
</span>`
: '<span class="text-muted small">—</span>';
return `<tr data-id="${t.id}" style="cursor:pointer"
onclick="filaDetalle(event, '${rowId}', ${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 class="text-nowrap">${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="event.stopPropagation();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="event.stopPropagation();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>Notas y observaciones (${t.comentarios.length})</div>
<div>${t.comentarios.map(c => `
<div class="detail-comment">
<span class="com-tipo ${esc(c.tipo||'general')}">${({recepcion:'Recepción',muestras:'Toma de muestras',general:'General'})[c.tipo]||c.tipo}</span>
<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="traza-${t.id}">
<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
</div>
<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>`;
}
/**
* Clic en cualquier parte de la fila: abre o cierra su detalle.
*
* Se ignora si el usuario estaba seleccionando texto —copiar un documento o un
* nombre es frecuente aquí y sería molesto que la fila se abriera al soltar— y
* si el clic cayó sobre un enlace o un botón, que tienen lo suyo que hacer.
*/
function filaDetalle(ev, rowId, turnoId) {
if (ev.target.closest('button, a, input, select')) return;
const sel = window.getSelection();
if (sel && sel.toString().length > 0) return;
const fila = document.querySelector(`tr[data-id="${turnoId}"]`);
const btn = fila ? fila.querySelector('button[title="Ver detalles"]') : null;
if (btn) toggleDetalle(rowId, btn, turnoId);
}
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 trazaEl = document.getElementById(`traza-${turnoId}`);
if (trazaEl && !trazaEl.dataset.loaded) {
trazaEl.dataset.loaded = '1';
cargarTrazaTurno(turnoId, trazaEl);
}
}
}
const BASE_URL_ROOT = '<?= BASE_URL ?>';
// ── Bitácora del turno ────────────────────────────────────────
// Junta en una sola línea de tiempo lo que antes estaba repartido: el recorrido
// del turno, los documentos, los comentarios del personal, las muestras y las
// acciones administrativas auditadas.
const TRAZA_ICONO = {
turno:'fa-plus-circle', llamado:'fa-bullhorn', atencion:'fa-user-check',
muestra:'fa-vial', fin:'fa-flag-checkered', doc:'fa-paper-plane',
doc_ok:'fa-file-signature', comentario:'fa-comment', alerta:'fa-triangle-exclamation',
admin:'fa-user-shield',
};
const TRAZA_COLOR = {
alerta:'#dc2626', doc_ok:'#16a34a', comentario:'#2563eb',
fin:'#0f766e', admin:'#7c3aed',
};
async function cargarTrazaTurno(turnoId, container) {
const titulo = '<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>';
try {
const res = await fetch(`${API}get_traza_turno.php?turno_id=${turnoId}`);
const json = await res.json();
if (!json.ok) { container.innerHTML = titulo + '<span class="docs-empty">No se pudo cargar</span>'; return; }
const ev = json.eventos || [];
if (!ev.length) { container.innerHTML = titulo + '<span class="docs-empty">Sin registros</span>'; return; }
const hora = c => c ? new Date(c.replace(' ','T')).toLocaleTimeString('es-CO',{hour:'2-digit',minute:'2-digit'}) : '';
const filas = ev.map(e => {
const color = TRAZA_COLOR[e.tipo] || '#94a3b8';
const ico = TRAZA_ICONO[e.tipo] || 'fa-circle';
const quien = e.quien ? `<span class="traza-quien">${esc(e.quien)}</span>` : '';
const extra = e.extra ? `<div class="traza-extra">${esc(e.extra)}</div>` : '';
return `<div class="traza-fila">
<span class="traza-hora">${hora(e.cuando)}</span>
<i class="fas ${ico} traza-ico" style="color:${color}"></i>
<div class="traza-txt"><span>${esc(e.texto)}</span> ${quien}${extra}</div>
</div>`;
}).join('');
// El chat no se vuelca aquí: son datos personales del paciente y esta es
// una pantalla de consulta. Se indica que existe y se enlaza.
const chat = json.chat
? `<div class="traza-fila">
<span class="traza-hora"></span>
<i class="fab fa-whatsapp traza-ico" style="color:#25d366"></i>
<div class="traza-txt">
<a href="erp.php?m=turnero&v=chat&user_id=${json.chat.user_id}" target="_blank">
${json.chat.mensajes} mensaje(s) de WhatsApp ese día — ver conversación
</a>
</div>
</div>` : '';
// Se dice qué NO quedó registrado, para que la ausencia de un evento no
// se lea como prueba de que no ocurrió.
const nota = (json.sinRastro && json.sinRastro.length)
? `<div class="traza-nota">El sistema aún no deja rastro de: ${json.sinRastro.map(esc).join(' · ')}.</div>`
: '';
container.innerHTML = titulo + `<div class="traza">${filas}${chat}</div>` + nota;
} catch (_) {
container.innerHTML = titulo + '<span class="docs-empty">Error al cargar</span>';
}
}
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) {
// Mismos parámetros que bandeja: compact activa la grilla de campos
// y las tarjetas de toma; sin ellos el documento se ve en lista larga.
const url = BASE_URL_ROOT + 'ver_formulario_enviado.php?token=' + encodeURIComponent(c.token) + '&embed=1&compact=1&zoom=1.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(); ?>