This commit is contained in:
Lizandro Guarnizo
2026-04-21 21:13:35 -05:00
parent 8cd3348d94
commit 3a19f08bf8
5 changed files with 916 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
<?php
/**
* modules/turnero/api/export_historial.php
* Exporta CSV del historial multi-día con los mismos filtros de get_historial.php
*
* GET ?fecha_desde=YYYY-MM-DD&fecha_hasta=YYYY-MM-DD&estado=...&lugar_id=...&prioridad_id=...&q=...
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('GET');
$pdo = db();
// ── Parámetros ────────────────────────────────────────────────
$hoy = date('Y-m-d');
$hace7 = date('Y-m-d', strtotime('-6 days'));
$fechaDesde = trim($_GET['fecha_desde'] ?? '');
$fechaHasta = trim($_GET['fecha_hasta'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $hace7;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaHasta)) $fechaHasta = $hoy;
if ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta;
$estadoFiltro = trim($_GET['estado'] ?? '');
$lugarId = (int)($_GET['lugar_id'] ?? 0);
$prioridadId = (int)($_GET['prioridad_id'] ?? 0);
$q = trim($_GET['q'] ?? '');
// ── WHERE ─────────────────────────────────────────────────────
$where = ['DATE(ses.fecha) BETWEEN ? AND ?'];
$binds = [$fechaDesde, $fechaHasta];
$estadosValidos = ['espera','en_recepcion','en_espera_lugar','en_servicio','finalizado','ausente','cancelado'];
if ($estadoFiltro && in_array($estadoFiltro, $estadosValidos, true)) {
$where[] = 't.estado = ?';
$binds[] = $estadoFiltro;
}
if ($lugarId > 0) {
$where[] = 't.lugar_destino_id = ?';
$binds[] = $lugarId;
}
if ($prioridadId > 0) {
$where[] = 't.prioridad_id = ?';
$binds[] = $prioridadId;
}
if ($q !== '') {
$where[] = '(t.codigo LIKE ? OR t.paciente_nombre LIKE ? OR pac.nombre_completo LIKE ?)';
$like = '%' . $q . '%';
$binds[] = $like;
$binds[] = $like;
$binds[] = $like;
}
$whereStr = 'WHERE ' . implode(' AND ', $where);
// ── Consulta ──────────────────────────────────────────────────
$sql = "
SELECT
ses.fecha AS fecha_sesion,
t.codigo,
t.numero,
p.codigo AS prioridad,
p.nombre AS prioridad_nombre,
COALESCE(pac.nombre_completo, t.paciente_nombre) AS paciente,
pac.numero_documento,
pac.tipo_documento,
t.paciente_cel AS celular,
l.nombre AS lugar,
t.estado,
t.creado_at,
t.llamado_recepcion_at,
t.inicio_recepcion_at,
t.fin_recepcion_at,
t.llamado_lugar_at,
t.inicio_lugar_at,
t.fin_lugar_at,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at,
COALESCE(t.inicio_recepcion_at, t.fin_recepcion_at)) / 60.0, 1) AS espera_recepcion_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_lugar_min,
sol.total_cobrado,
sol.metodo_pago
FROM turnero_turnos t
JOIN turnero_sesiones ses ON ses.id = t.sesion_id
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_solicitudes sol ON sol.turno_id = t.id
LEFT JOIN lab_pacientes pac ON pac.id = sol.paciente_id
$whereStr
ORDER BY t.creado_at ASC";
$stmt = $pdo->prepare($sql);
$stmt->execute($binds);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
// ── Nombre de archivo ─────────────────────────────────────────
$nombreArchivo = "historial_turnos_{$fechaDesde}_a_{$fechaHasta}.csv";
// ── Output ────────────────────────────────────────────────────
// Limpiar cualquier output previo antes de los headers
ob_clean();
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $nombreArchivo . '"');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
$out = fopen('php://output', 'w');
echo "\xEF\xBB\xBF"; // BOM UTF-8 para Excel
// Encabezados
$headers = [
'Fecha', 'Código', 'Número', 'Prioridad', 'Prioridad nombre',
'Paciente', 'Documento', 'Tipo doc.', 'Celular',
'Lugar', 'Estado',
'Creado', 'Llamado recepción', 'Inicio recepción', 'Fin recepción',
'Llamado lugar', 'Inicio lugar', 'Fin lugar',
'Espera recepción (min)', 'Servicio lugar (min)',
'Total cobrado', 'Método pago',
];
fputcsv($out, $headers, ',', '"');
$fechaCols = [
'creado_at','llamado_recepcion_at','inicio_recepcion_at','fin_recepcion_at',
'llamado_lugar_at','inicio_lugar_at','fin_lugar_at',
];
$fields = [
'fecha_sesion', 'codigo', 'numero', 'prioridad', 'prioridad_nombre',
'paciente', 'numero_documento', 'tipo_documento', 'celular',
'lugar', 'estado',
'creado_at', 'llamado_recepcion_at', 'inicio_recepcion_at', 'fin_recepcion_at',
'llamado_lugar_at', 'inicio_lugar_at', 'fin_lugar_at',
'espera_recepcion_min', 'servicio_lugar_min',
'total_cobrado', 'metodo_pago',
];
foreach ($rows as $row) {
$line = [];
foreach ($fields as $f) {
$v = $row[$f] ?? '';
if ($v && in_array($f, $fechaCols, true)) {
$v = date('d/m/Y H:i:s', strtotime($v));
}
$line[] = $v;
}
fputcsv($out, $line, ',', '"');
}
fclose($out);
exit;
+165
View File
@@ -0,0 +1,165 @@
<?php
/**
* modules/turnero/api/get_historial.php
* GET Historial paginado de turnos con filtros multi-día.
*
* Parámetros (todos opcionales):
* fecha_desde YYYY-MM-DD (default: hace 7 días)
* fecha_hasta YYYY-MM-DD (default: hoy)
* estado string ("finalizado","ausente","cancelado","en_servicio",... o vacío = todos)
* lugar_id int (0 = todos)
* prioridad_id int (0 = todos)
* q string búsqueda libre por nombre/código
* page int (default 1)
* per_page int (default 50, max 200)
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('GET');
$pdo = db();
// ── Parámetros ────────────────────────────────────────────────
$hoy = date('Y-m-d');
$hace7 = date('Y-m-d', strtotime('-6 days'));
$fechaDesde = trim($_GET['fecha_desde'] ?? '');
$fechaHasta = trim($_GET['fecha_hasta'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $hace7;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaHasta)) $fechaHasta = $hoy;
if ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta;
$estadoFiltro = trim($_GET['estado'] ?? '');
$lugarId = (int)($_GET['lugar_id'] ?? 0);
$prioridadId = (int)($_GET['prioridad_id'] ?? 0);
$q = trim($_GET['q'] ?? '');
$perPage = min(200, max(10, (int)($_GET['per_page'] ?? 50)));
$page = max(1, (int)($_GET['page'] ?? 1));
$offset = ($page - 1) * $perPage;
// ── Base WHERE ───────────────────────────────────────────────
// Unimos turnos con sesiones para filtrar por fecha de sesión
$where = ['DATE(s.fecha) BETWEEN ? AND ?'];
$binds = [$fechaDesde, $fechaHasta];
$estadosValidos = ['espera','en_recepcion','en_espera_lugar','en_servicio','finalizado','ausente','cancelado'];
if ($estadoFiltro && in_array($estadoFiltro, $estadosValidos, true)) {
$where[] = 't.estado = ?';
$binds[] = $estadoFiltro;
}
if ($lugarId > 0) {
$where[] = 't.lugar_destino_id = ?';
$binds[] = $lugarId;
}
if ($prioridadId > 0) {
$where[] = 't.prioridad_id = ?';
$binds[] = $prioridadId;
}
if ($q !== '') {
$where[] = '(t.codigo LIKE ? OR t.paciente_nombre LIKE ? OR s2.nombre_completo LIKE ?)';
$like = '%' . $q . '%';
$binds[] = $like;
$binds[] = $like;
$binds[] = $like;
}
$whereStr = 'WHERE ' . implode(' AND ', $where);
// ── Conteo total ──────────────────────────────────────────────
$sqlCount = "
SELECT COUNT(*) AS total
FROM turnero_turnos t
JOIN turnero_sesiones s ON s.id = t.sesion_id
LEFT JOIN turnero_solicitudes ts2 ON ts2.turno_id = t.id
LEFT JOIN lab_pacientes s2 ON s2.id = ts2.paciente_id
$whereStr";
$stmtCount = $pdo->prepare($sqlCount);
$stmtCount->execute($binds);
$totalRows = (int)$stmtCount->fetchColumn();
$totalPages = max(1, (int)ceil($totalRows / $perPage));
if ($page > $totalPages) $page = $totalPages;
$offset = ($page - 1) * $perPage;
// ── Lista paginada ────────────────────────────────────────────
$sqlTurnos = "
SELECT
t.id,
t.codigo,
t.numero,
t.estado,
t.paciente_nombre,
t.paciente_cel,
t.creado_at,
t.inicio_recepcion_at,
t.fin_recepcion_at,
t.inicio_lugar_at,
t.fin_lugar_at,
s.fecha AS fecha_sesion,
p.codigo AS prioridad_codigo,
p.nombre AS prioridad_nombre,
p.color AS prioridad_color,
l.nombre AS lugar_nombre,
s2.nombre_completo AS paciente_bd,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at,
COALESCE(t.inicio_recepcion_at, t.fin_recepcion_at)) / 60.0, 1)
AS espera_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1)
AS servicio_min
FROM turnero_turnos t
JOIN turnero_sesiones s ON s.id = t.sesion_id
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_solicitudes ts2 ON ts2.turno_id = t.id
LEFT JOIN lab_pacientes s2 ON s2.id = ts2.paciente_id
$whereStr
ORDER BY t.creado_at DESC
LIMIT ? OFFSET ?";
$bindsPage = array_merge($binds, [$perPage, $offset]);
$stmtTurnos = $pdo->prepare($sqlTurnos);
$stmtTurnos->execute($bindsPage);
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
// ── Resumen por estado (del rango filtrado) ───────────────────
$sqlRes = "
SELECT t.estado, COUNT(*) AS cnt
FROM turnero_turnos t
JOIN turnero_sesiones s ON s.id = t.sesion_id
LEFT JOIN turnero_solicitudes ts2 ON ts2.turno_id = t.id
LEFT JOIN lab_pacientes s2 ON s2.id = ts2.paciente_id
$whereStr
GROUP BY t.estado";
$stmtRes = $pdo->prepare($sqlRes);
$stmtRes->execute($binds);
$resumenEstados = [];
foreach ($stmtRes->fetchAll(PDO::FETCH_ASSOC) as $r) {
$resumenEstados[$r['estado']] = (int)$r['cnt'];
}
// ── Catálogos para los selectores (si pide con ?meta=1) ──────
$meta = [];
if (!empty($_GET['meta'])) {
$meta['lugares'] = $pdo->query(
"SELECT id, nombre FROM turnero_lugares WHERE activo=1 ORDER BY sort_order ASC, nombre ASC"
)->fetchAll(PDO::FETCH_ASSOC);
$meta['prioridades'] = $pdo->query(
"SELECT id, nombre, codigo, color FROM turnero_prioridades ORDER BY orden_peso ASC"
)->fetchAll(PDO::FETCH_ASSOC);
}
jsonOk([
'fecha_desde' => $fechaDesde,
'fecha_hasta' => $fechaHasta,
'total' => $totalRows,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $totalPages,
'resumen_estados'=> $resumenEstados,
'turnos' => $turnos,
'meta' => $meta,
]);
+1
View File
@@ -7,6 +7,7 @@
// ── Links estáticos base ──────────────────────────────────────
$_trLinks = [
['name' => 'Dashboard', 'icon' => 'fas fa-tachometer-alt', 'route' => '/erp.php?m=turnero&v=dashboard'],
['name' => 'Historial', 'icon' => 'fas fa-history', 'route' => '/erp.php?m=turnero&v=historial'],
['name' => 'Configuración', 'icon' => 'fas fa-sliders-h', 'route' => '/erp.php?m=turnero&v=configuracion'],
['name' => 'Kiosko', 'icon' => 'fas fa-desktop', 'route' => '/erp.php?m=turnero&v=kiosko'],
['name' => 'Pantalla TV Global', 'icon' => 'fas fa-th-large', 'route' => '/erp.php?m=turnero&v=display_global'],
+552
View File
@@ -0,0 +1,552 @@
<?php
/**
* modules/turnero/views/historial.php
* Historial consultable de turnos con filtros multi-día y paginación.
*/
require_once APP_ROOT . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
Layout::open('Historial de Turnos', 'fas fa-history');
?>
<style>
body { background:#f1f5f9; }
/* ── Encabezado ── */
.page-header {
background:#fff; border-bottom:1px solid #e2e8f0;
padding:12px 22px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;
}
.page-header h1 { font-size:1.12rem; font-weight:700; color:#1e293b; margin:0; flex:1; min-width:150px; }
/* ── Filtros ── */
.filter-bar {
background:#fff; border-bottom:1px solid #e2e8f0;
padding:12px 22px; display:flex; align-items:flex-end; gap:10px; flex-wrap:wrap;
}
.filter-group { display:flex; flex-direction:column; gap:3px; }
.filter-group label { font-size:.7rem; font-weight:600; text-transform:uppercase;
letter-spacing:.06em; color:#64748b; }
.filter-group .form-control,
.filter-group .form-select { font-size:.82rem; min-width:120px; }
.filter-group.wide .form-control { min-width:200px; }
/* ── Contenido ── */
.content-wrap { max-width:1200px; margin:22px auto; padding:0 16px 60px; }
/* ── KPIs resumen ── */
.kpi-strip {
display:flex; flex-wrap:wrap; gap:10px; margin-bottom:18px;
}
.kpi-chip {
display:flex; align-items:center; gap:7px;
background:#fff; border:1px solid #e2e8f0; border-radius:99px;
padding:5px 14px; font-size:.8rem;
}
.kpi-chip .chip-val { font-weight:800; font-size:1rem; }
.kpi-chip .chip-lbl { color:#64748b; }
/* ── Tabla ── */
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px;
padding:0; overflow:hidden; }
.section-card .table { margin:0; }
.section-card .table thead th {
background:#f8fafc; font-size:.7rem; text-transform:uppercase;
letter-spacing:.07em; color:#475569; font-weight:700;
padding:10px 12px; white-space:nowrap; border-bottom:2px solid #e2e8f0;
}
.section-card .table tbody td { padding:9px 12px; font-size:.82rem; vertical-align:middle; }
.section-card .table tbody tr:hover { background:#f8fafc; }
/* ── Estado badges ── */
.eb { font-size:.65rem; padding:2px 8px; border-radius:99px; font-weight:700; white-space:nowrap; }
.eb-espera { background:#fef9c3; color:#78350f; }
.eb-en_recepcion { background:#dbeafe; color:#1e40af; }
.eb-en_espera_lugar { background:#fde68a; color:#92400e; }
.eb-en_servicio { background:#dcfce7; color:#166534; }
.eb-finalizado { background:#f1f5f9; color:#334155; }
.eb-ausente { background:#fee2e2; color:#991b1b; }
.eb-cancelado { background:#f1f5f9; color:#9ca3af; }
/* ── Paginación ── */
.pagination-wrap {
display:flex; align-items:center; justify-content:space-between;
padding:12px 18px; border-top:1px solid #e2e8f0; flex-wrap:wrap; gap:8px;
}
.pagination-info { font-size:.78rem; color:#64748b; }
.page-btns { display:flex; gap:4px; }
.page-btns button { font-size:.78rem; padding:3px 10px; border-radius:6px;
border:1px solid #e2e8f0; background:#fff; cursor:pointer; }
.page-btns button.active { background:var(--brand,#2563eb); color:#fff; border-color:var(--brand,#2563eb); }
.page-btns button:disabled { opacity:.4; cursor:default; }
/* ── Estado vacío ── */
.empty-state { text-align:center; color:#94a3b8; padding:60px 20px; font-size:.9rem; }
.empty-state i { font-size:2.5rem; display:block; margin-bottom:10px; }
/* ── Spinner ── */
.spinner-row td { text-align:center; padding:40px; color:#94a3b8; }
/* ── Detalle expandible ── */
.detail-row { background:#f8fafc; }
.detail-row td { padding:0 !important; }
.detail-body { padding:10px 18px 14px; }
.detail-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:8px; }
.detail-item { background:#fff; border:1px solid #e2e8f0; border-radius:8px; padding:8px 12px; }
.detail-item .d-lbl { font-size:.67rem; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; }
.detail-item .d-val { font-size:.85rem; font-weight:600; color:#1e293b; }
</style>
<!-- ── Encabezado ────────────────────────────────────────────── -->
<div class="page-header">
<a href="<?= BASE_URL ?>/erp.php?m=turnero&v=dashboard" class="text-muted text-decoration-none small">
<i class="fas fa-arrow-left me-1"></i>Dashboard
</a>
<h1><i class="fas fa-history me-2 text-primary"></i>Historial de Turnos</h1>
<button class="btn btn-outline-success btn-sm" id="btnExport" title="Exportar CSV">
<i class="fas fa-file-csv me-1"></i>Exportar CSV
</button>
</div>
<!-- ── Barra de filtros ──────────────────────────────────────── -->
<div class="filter-bar">
<div class="filter-group">
<label>Desde</label>
<input type="date" id="fDesde" class="form-control form-control-sm">
</div>
<div class="filter-group">
<label>Hasta</label>
<input type="date" id="fHasta" class="form-control form-control-sm">
</div>
<div class="filter-group">
<label>Estado</label>
<select id="fEstado" class="form-select form-select-sm">
<option value="">Todos</option>
<option value="finalizado">Finalizado</option>
<option value="ausente">Ausente</option>
<option value="cancelado">Cancelado</option>
<option value="en_servicio">En servicio</option>
<option value="en_espera_lugar">Esp. lugar</option>
<option value="en_recepcion">En recepción</option>
<option value="espera">En espera</option>
</select>
</div>
<div class="filter-group" id="wrapLugar">
<label>Lugar</label>
<select id="fLugar" class="form-select form-select-sm">
<option value="0">Todos</option>
</select>
</div>
<div class="filter-group" id="wrapPrioridad">
<label>Prioridad</label>
<select id="fPrioridad" class="form-select form-select-sm">
<option value="0">Todas</option>
</select>
</div>
<div class="filter-group wide">
<label>Buscar paciente / código</label>
<input type="text" id="fQ" class="form-control form-control-sm" placeholder="Nombre o código…">
</div>
<button class="btn btn-primary btn-sm align-self-end" id="btnBuscar">
<i class="fas fa-search me-1"></i>Buscar
</button>
<button class="btn btn-outline-secondary btn-sm align-self-end" id="btnHoy" title="Solo hoy">
Hoy
</button>
<button class="btn btn-outline-secondary btn-sm align-self-end" id="btn7d" title="Últimos 7 días">
7 días
</button>
<button class="btn btn-outline-secondary btn-sm align-self-end" id="btn30d" title="Últimos 30 días">
30 días
</button>
</div>
<!-- ── Contenido ──────────────────────────────────────────────── -->
<div class="content-wrap">
<!-- Resumen por estado del rango -->
<div class="kpi-strip" id="kpiStrip" style="display:none"></div>
<!-- Tabla principal -->
<div class="section-card">
<div class="table-responsive">
<table class="table table-hover align-middle" id="tablaHistorial">
<thead>
<tr>
<th>Fecha</th>
<th>Código</th>
<th>Prioridad</th>
<th>Paciente</th>
<th>Teléfono</th>
<th>Lugar</th>
<th>Estado</th>
<th>Espera</th>
<th>Servicio</th>
<th>Entrada</th>
<th></th>
</tr>
</thead>
<tbody id="tbody">
<tr class="spinner-row">
<td colspan="11"><i class="fas fa-spinner fa-spin me-2"></i>Cargando…</td>
</tr>
</tbody>
</table>
</div>
<!-- Paginación -->
<div class="pagination-wrap" id="paginacionWrap" style="display:none">
<div class="pagination-info" id="paginacionInfo"></div>
<div class="page-btns" id="pagBtns"></div>
</div>
</div>
</div>
<script>
/* ═══════════════════════════════════════════════════════════
Historial de Turnos
═══════════════════════════════════════════════════════════ */
const API = '<?= BASE_URL ?>/modules/turnero/api/';
let _state = {
fechaDesde : '',
fechaHasta : '',
estado : '',
lugarId : 0,
prioridadId: 0,
q : '',
page : 1,
perPage : 50,
totalPages : 1,
total : 0,
};
// ── Utilidades ────────────────────────────────────────────────
const esc = s => (s == null ? '' : String(s))
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
function hoy() { return new Date().toISOString().slice(0,10); }
function hace(n){ return new Date(Date.now()-n*86400000).toISOString().slice(0,10); }
// ── Inicialización ────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
// Fecha por defecto: últimos 7 días
document.getElementById('fDesde').value = hace(6);
document.getElementById('fHasta').value = hoy();
document.getElementById('fHasta').max = hoy();
// Cargar catálogos (lugares + prioridades)
await cargarMeta();
// Primera carga
buscar();
// Eventos
document.getElementById('btnBuscar').addEventListener('click', buscar);
document.getElementById('fQ').addEventListener('keydown', e => { if (e.key==='Enter') buscar(); });
document.getElementById('btnExport').addEventListener('click', exportarCSV);
document.getElementById('btnHoy').addEventListener('click', () => {
document.getElementById('fDesde').value = hoy();
document.getElementById('fHasta').value = hoy();
buscar();
});
document.getElementById('btn7d').addEventListener('click', () => {
document.getElementById('fDesde').value = hace(6);
document.getElementById('fHasta').value = hoy();
buscar();
});
document.getElementById('btn30d').addEventListener('click', () => {
document.getElementById('fDesde').value = hace(29);
document.getElementById('fHasta').value = hoy();
buscar();
});
});
// ── Catálogos ─────────────────────────────────────────────────
async function cargarMeta() {
try {
const res = await fetch(`${API}get_historial.php?meta=1&fecha_desde=${hoy()}&fecha_hasta=${hoy()}&per_page=1`);
const json = await res.json();
if (!json.ok) return;
const selLugar = document.getElementById('fLugar');
(json.meta.lugares || []).forEach(l => {
const o = document.createElement('option');
o.value = l.id; o.textContent = l.nombre;
selLugar.appendChild(o);
});
const selPri = document.getElementById('fPrioridad');
(json.meta.prioridades || []).forEach(p => {
const o = document.createElement('option');
o.value = p.id;
o.textContent = `${p.codigo} — ${p.nombre}`;
selPri.appendChild(o);
});
} catch(_) {}
}
// ── Búsqueda principal ────────────────────────────────────────
function buscar(pageNum) {
_state.fechaDesde = document.getElementById('fDesde').value || hace(6);
_state.fechaHasta = document.getElementById('fHasta').value || hoy();
_state.estado = document.getElementById('fEstado').value;
_state.lugarId = parseInt(document.getElementById('fLugar').value) || 0;
_state.prioridadId = parseInt(document.getElementById('fPrioridad').value)|| 0;
_state.q = document.getElementById('fQ').value.trim();
_state.page = pageNum || 1;
cargarPagina();
}
async function cargarPagina() {
mostrarSpinner();
const params = new URLSearchParams({
fecha_desde : _state.fechaDesde,
fecha_hasta : _state.fechaHasta,
estado : _state.estado,
lugar_id : _state.lugarId,
prioridad_id : _state.prioridadId,
q : _state.q,
page : _state.page,
per_page : _state.perPage,
});
try {
const res = await fetch(`${API}get_historial.php?${params}`);
const json = await res.json();
if (!json.ok) { mostrarError(json.error || 'Error al cargar historial'); return; }
_state.totalPages = json.total_pages;
_state.total = json.total;
_state.page = json.page;
renderKpis(json.resumen_estados);
renderTabla(json.turnos);
renderPaginacion();
} catch(e) {
mostrarError('Error de conexión');
}
}
// ── KPIs de resumen ───────────────────────────────────────────
const ESTADO_META = {
finalizado : { lbl:'Finalizados', bg:'#f1f5f9', color:'#334155' },
ausente : { lbl:'Ausentes', bg:'#fee2e2', color:'#991b1b' },
cancelado : { lbl:'Cancelados', bg:'#f1f5f9', color:'#9ca3af' },
en_servicio : { lbl:'En servicio', bg:'#dcfce7', color:'#166534' },
en_espera_lugar : { lbl:'Esp. lugar', bg:'#fde68a', color:'#92400e' },
en_recepcion : { lbl:'En recepción', bg:'#dbeafe', color:'#1e40af' },
espera : { lbl:'En espera', bg:'#fef9c3', color:'#78350f' },
};
function renderKpis(resumen) {
const strip = document.getElementById('kpiStrip');
let total = Object.values(resumen).reduce((a,b)=>a+(+b),0);
if (!total) { strip.style.display='none'; return; }
strip.style.display = 'flex';
strip.innerHTML = `
<div class="kpi-chip">
<span class="chip-val">${total}</span>
<span class="chip-lbl">turnos (rango)</span>
</div>
${Object.entries(resumen).map(([est,cnt]) => {
const m = ESTADO_META[est] || {lbl:est, bg:'#f1f5f9', color:'#334155'};
return `<div class="kpi-chip" style="border-color:${m.color}22">
<span class="chip-val" style="color:${m.color}">${cnt}</span>
<span class="chip-lbl">${m.lbl}</span>
</div>`;
}).join('')}
`;
}
// ── Tabla ─────────────────────────────────────────────────────
const ESTADO_CLS = {
espera:'eb-espera', en_recepcion:'eb-en_recepcion', en_espera_lugar:'eb-en_espera_lugar',
en_servicio:'eb-en_servicio', finalizado:'eb-finalizado', ausente:'eb-ausente', cancelado:'eb-cancelado',
};
const ESTADO_LBL = {
espera:'Espera', en_recepcion:'Recepción', en_espera_lugar:'Esp. lugar',
en_servicio:'En servicio', finalizado:'Finalizado', ausente:'Ausente', cancelado:'Cancelado',
};
function renderTabla(turnos) {
const tbody = document.getElementById('tbody');
if (!turnos.length) {
tbody.innerHTML = `<tr><td colspan="11">
<div class="empty-state"><i class="fas fa-search"></i>Sin resultados para los filtros aplicados</div>
</td></tr>`;
return;
}
tbody.innerHTML = turnos.map((t,i) => {
const nombre = esc(t.paciente_bd || t.paciente_nombre || '—');
const lugar = esc(t.lugar_nombre || '—');
const fecha = t.fecha_sesion || '—';
const horaEnt = t.creado_at
? new Date(t.creado_at.replace(' ','T'))
.toLocaleString('es-CO',{hour:'2-digit',minute:'2-digit'})
: '—';
const espMin = t.espera_min != null ? t.espera_min + ' m' : '—';
const srvMin = t.servicio_min != null ? t.servicio_min + ' m' : '—';
const tel = esc(t.paciente_cel || '—');
const priCls = esc(t.prioridad_color || '#888');
const cls = ESTADO_CLS[t.estado] || 'eb-finalizado';
const lbl = ESTADO_LBL[t.estado] || t.estado;
const rowId = `det-${t.id}`;
return `<tr data-id="${t.id}">
<td class="text-nowrap text-muted small">${esc(fecha)}</td>
<td><strong style="color:${priCls}">${esc(t.codigo)}</strong></td>
<td>
<span class="eb" style="background:${priCls}22;color:${priCls}">
${esc(t.prioridad_codigo)}
</span>
</td>
<td>${nombre}</td>
<td class="text-muted small">${tel}</td>
<td>${lugar}</td>
<td><span class="eb ${cls}">${lbl}</span></td>
<td class="text-nowrap">${espMin}</td>
<td class="text-nowrap">${srvMin}</td>
<td class="text-nowrap small text-muted">${horaEnt}</td>
<td>
<button class="btn btn-sm btn-outline-secondary py-0 px-2"
onclick="toggleDetalle('${rowId}', this)"
title="Ver detalles de timestamps">
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
</button>
</td>
</tr>
<tr id="${rowId}" class="detail-row" style="display:none">
<td colspan="11">
<div class="detail-body">
${renderDetalle(t)}
</div>
</td>
</tr>`;
}).join('');
}
function renderDetalle(t) {
const fmt = dt => {
if (!dt) return '—';
const d = new Date(dt.replace(' ','T'));
return d.toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'});
};
const items = [
{ lbl:'Creado', val: fmt(t.creado_at) },
{ lbl:'Inicio recepción', val: fmt(t.inicio_recepcion_at) },
{ lbl:'Fin recepción', val: fmt(t.fin_recepcion_at) },
{ lbl:'Inicio servicio', val: fmt(t.inicio_lugar_at) },
{ lbl:'Fin servicio', val: fmt(t.fin_lugar_at) },
{ lbl:'Código completo', val: esc(t.codigo) },
{ lbl:'Número en cola', val: t.numero ?? '—' },
{ lbl:'Teléfono', val: esc(t.paciente_cel || '—') },
];
return `<div class="detail-grid">
${items.map(it=>`
<div class="detail-item">
<div class="d-lbl">${it.lbl}</div>
<div class="d-val">${it.val}</div>
</div>`).join('')}
</div>`;
}
function toggleDetalle(rowId, btn) {
const row = document.getElementById(rowId);
const icon = btn.querySelector('i');
if (!row) return;
const visible = row.style.display !== 'none';
row.style.display = visible ? 'none' : '';
icon.className = visible ? 'fas fa-chevron-down' : 'fas fa-chevron-up';
icon.style.fontSize = '.65rem';
}
// ── Paginación ────────────────────────────────────────────────
function renderPaginacion() {
const wrap = document.getElementById('paginacionWrap');
const info = document.getElementById('paginacionInfo');
const btns = document.getElementById('pagBtns');
wrap.style.display = '';
const desde = (_state.page - 1) * _state.perPage + 1;
const hasta = Math.min(_state.page * _state.perPage, _state.total);
info.textContent = `Mostrando ${desde}${hasta} de ${_state.total} turnos`;
// Generar botones de página (máximo 7 visibles)
const total = _state.totalPages;
const current = _state.page;
let pages = [];
if (total <= 7) {
pages = Array.from({length: total}, (_,i) => i+1);
} else {
pages = [1];
if (current > 3) pages.push('…');
for (let p = Math.max(2, current-1); p <= Math.min(total-1, current+1); p++) pages.push(p);
if (current < total-2) pages.push('…');
pages.push(total);
}
btns.innerHTML = `
<button ${current<=1?'disabled':''} onclick="irPagina(${current-1})">
<i class="fas fa-chevron-left"></i>
</button>
${pages.map(p =>
p === '…'
? `<button disabled>…</button>`
: `<button class="${p===current?'active':''}" onclick="irPagina(${p})">${p}</button>`
).join('')}
<button ${current>=total?'disabled':''} onclick="irPagina(${current+1})">
<i class="fas fa-chevron-right"></i>
</button>
`;
}
function irPagina(n) {
if (n < 1 || n > _state.totalPages) return;
_state.page = n;
cargarPagina();
window.scrollTo({top:0, behavior:'smooth'});
}
// ── Exportar CSV ──────────────────────────────────────────────
function exportarCSV() {
const params = new URLSearchParams({
fecha : _state.fechaDesde, // export_csv actual usa 'fecha' — usamos fecha_desde
m : 'historial', // flag para que export_csv sepa que es multi-día
fecha_desde : _state.fechaDesde,
fecha_hasta : _state.fechaHasta,
estado : _state.estado,
lugar_id : _state.lugarId,
prioridad_id : _state.prioridadId,
q : _state.q,
per_page : 9999,
page : 1,
});
// Generamos CSV en cliente a partir de los datos actuales si son pocos,
// o mandamos al endpoint get_historial con per_page alto para descarga
window.open(`${API}export_historial.php?${params}`, '_blank');
}
// ── Helpers visuales ──────────────────────────────────────────
function mostrarSpinner() {
document.getElementById('tbody').innerHTML =
'<tr class="spinner-row"><td colspan="11"><i class="fas fa-spinner fa-spin me-2"></i>Buscando…</td></tr>';
document.getElementById('paginacionWrap').style.display = 'none';
document.getElementById('kpiStrip').style.display = 'none';
}
function mostrarError(msg) {
document.getElementById('tbody').innerHTML =
`<tr><td colspan="11"><div class="empty-state text-danger">
<i class="fas fa-exclamation-circle"></i>${esc(msg)}
</div></td></tr>`;
}
</script>
<?php Layout::close(); ?>
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Ejecuta la migración 005_registro_exams.sql
* Acceder UNA VEZ desde el navegador como administrador, luego borrar.
*/
require_once __DIR__ . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
$pdo = Database::getInstance()->getConnection();
$sqlFile = __DIR__ . '/migrations/005_registro_exams.sql';
$sql = file_get_contents($sqlFile);
// Separar sentencias (ignorar comentarios)
$statements = array_filter(array_map('trim', explode(';', $sql)));
$results = [];
foreach ($statements as $stmt) {
if (empty($stmt) || preg_match('/^\s*--/', $stmt)) continue;
try {
$pdo->exec($stmt);
$results[] = ['ok' => true, 'sql' => substr(preg_replace('/\s+/', ' ', $stmt), 0, 120)];
} catch (\PDOException $e) {
$results[] = ['ok' => false, 'sql' => substr(preg_replace('/\s+/', ' ', $stmt), 0, 120), 'error' => $e->getMessage()];
}
}
?><!DOCTYPE html>
<html lang="es"><head><meta charset="UTF-8"><title>Migración 005</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head><body class="p-4">
<h4>Migración 005 — Registro de Exámenes</h4>
<table class="table table-sm table-bordered small">
<thead><tr><th>#</th><th>Estado</th><th>SQL</th><th>Error</th></tr></thead>
<tbody>
<?php foreach ($results as $i => $r): ?>
<tr class="<?= $r['ok'] ? 'table-success' : 'table-danger' ?>">
<td><?= $i+1 ?></td>
<td><?= $r['ok'] ? '✓ OK' : '✗ Error' ?></td>
<td><code><?= htmlspecialchars($r['sql']) ?>…</code></td>
<td><?= htmlspecialchars($r['error'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="alert alert-warning mt-3">⚠️ Elimina este archivo del servidor después de ejecutarlo.</div>
<a href="<?= BASE_URL ?>erp.php?m=registro_exams&v=lista" class="btn btn-primary">Ir a Registro Exámenes</a>
</body></html>