Files
whatsapp/modules/turnero/views/historial.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 2ba13add34 Corregir Tomas Prolongadas: filtrado de secciones, firmante por toma, detalle en historial
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>
2026-08-03 14:26:28 -05:00

846 lines
40 KiB
PHP
Raw 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; }
/* ── 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,'&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
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(); ?>