up
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,'&').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: ú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(); ?>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user