Marcar ausente solo pedía confirmación: el turno quedaba registrado sin ninguna explicación, y hoy son 6 en un día. Ahora se pide el motivo y se guarda como comentario de recepción, no como columna nueva: así aparece en todas las pantallas que ya muestran comentarios, sin duplicar el dato ni tocar el esquema. Dashboard e historial mostraban las notas sin indicar de dónde venían, así que no se distinguía una observación de toma de muestras de una de recepción. Se agrega la insignia de origen con los mismos colores que ya usa la bandeja, y el título pasa a "Notas y observaciones" con su cantidad. El historial además traía solo las 3 últimas por turno y en orden inverso. Con recepción empezando a dejar notas eso habría escondido justamente el motivo de una ausencia sin que nada indicara que faltaban; ahora trae hasta 20, en orden cronológico. De las 114 notas existentes, todas son de toma de muestras: recepción nunca dejó ninguna porque no tenía dónde hacerlo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
856 lines
40 KiB
PHP
856 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; }
|
||
/* ── 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; }
|
||
.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>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="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) {
|
||
// 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(); ?>
|