feat(lab_pacientes): columna origen + vista mejorada con stats y filtros

- Migration: columna 'origen' (manual|rips|whatsapp) en lab_pacientes
- ingest_paciente.php: guarda origen='rips' en cada paciente importado
- Paciente::listar(): filtro por origen + orden por created_at DESC
- Paciente::stats(): conteo total/importados/con_wa/manuales
- get_pacientes.php: expone ?origen= y ?stats=1
- lab_pacientes.php: cards de stats clicables, badge origen por fila,
  columna ciudad, columna fecha registro, dropdown filtro por origen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-09 11:16:52 -05:00
co-authored by Claude Sonnet 4.6
parent 32c166360c
commit 828e323eba
5 changed files with 148 additions and 24 deletions
+7 -1
View File
@@ -13,11 +13,17 @@ try {
jsonOk(['data' => [$row]]); jsonOk(['data' => [$row]]);
} }
if (isset($_GET['stats'])) {
jsonOk(['stats' => $pac->stats()]);
}
$busqueda = trim($_GET['busqueda'] ?? $_GET['search'] ?? ''); $busqueda = trim($_GET['busqueda'] ?? $_GET['search'] ?? '');
$pagina = max(1, (int)($_GET['page'] ?? $_GET['pagina'] ?? 1)); $pagina = max(1, (int)($_GET['page'] ?? $_GET['pagina'] ?? 1));
$por = max(1, min(100, (int)($_GET['limit'] ?? $_GET['por_pagina'] ?? 30))); $por = max(1, min(100, (int)($_GET['limit'] ?? $_GET['por_pagina'] ?? 30)));
$origen = in_array($_GET['origen'] ?? '', ['manual', 'rips', 'whatsapp'], true)
? $_GET['origen'] : '';
jsonOk($pac->listar($busqueda, $pagina, $por)); jsonOk($pac->listar($busqueda, $pagina, $por, $origen));
} catch (Exception $e) { } catch (Exception $e) {
jsonError($e->getMessage(), 500); jsonError($e->getMessage(), 500);
} }
+3
View File
@@ -121,6 +121,9 @@ if (!empty($data['ciudad'])) {
if (!empty($data['eps'])) { if (!empty($data['eps'])) {
$campos['eps'] = trim($data['eps']); $campos['eps'] = trim($data['eps']);
} }
$origenValidos = ['manual', 'rips', 'whatsapp'];
$campos['origen'] = in_array($data['origen'] ?? '', $origenValidos, true)
? $data['origen'] : 'rips';
// ── Modo: "insertar" (solo nuevos) | "upsert" (crea o actualiza) ───────────── // ── Modo: "insertar" (solo nuevos) | "upsert" (crea o actualiza) ─────────────
$modo = trim($data['modo'] ?? 'upsert'); $modo = trim($data['modo'] ?? 'upsert');
+30 -12
View File
@@ -27,20 +27,25 @@ class Paciente {
public function listar( public function listar(
string $busqueda = '', string $busqueda = '',
int $pagina = 1, int $pagina = 1,
int $porPagina = 30 int $porPagina = 30,
string $origen = ''
): array { ): array {
$offset = ($pagina - 1) * $porPagina; $offset = ($pagina - 1) * $porPagina;
$like = "%$busqueda%"; $like = "%$busqueda%";
$params = $busqueda
? [$like, $like, $like, $like]
: [];
$where = $busqueda $conditions = [];
? "WHERE p.nombre_completo LIKE ? $params = [];
OR p.numero_documento LIKE ?
OR p.telefono LIKE ? if ($busqueda) {
OR p.email LIKE ?" $conditions[] = "(p.nombre_completo LIKE ? OR p.numero_documento LIKE ? OR p.telefono LIKE ? OR p.email LIKE ?)";
: ''; $params = [$like, $like, $like, $like];
}
if ($origen) {
$conditions[] = "p.origen = ?";
$params[] = $origen;
}
$where = $conditions ? 'WHERE ' . implode(' AND ', $conditions) : '';
$total = $this->db->fetch( $total = $this->db->fetch(
"SELECT COUNT(*) AS n FROM lab_pacientes p $where", "SELECT COUNT(*) AS n FROM lab_pacientes p $where",
@@ -56,7 +61,7 @@ class Paciente {
FROM lab_pacientes p FROM lab_pacientes p
LEFT JOIN users u ON u.id = p.user_id LEFT JOIN users u ON u.id = p.user_id
$where $where
ORDER BY p.nombre_completo ASC ORDER BY p.created_at DESC, p.nombre_completo ASC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
", array_merge($params, [$porPagina, $offset])); ", array_merge($params, [$porPagina, $offset]));
@@ -69,6 +74,19 @@ class Paciente {
]; ];
} }
public function stats(): array {
return $this->db->fetch("
SELECT
COUNT(*) AS total,
SUM(origen = 'rips') AS importados,
SUM(origen = 'manual') AS manuales,
SUM(origen = 'whatsapp') AS whatsapp,
SUM(user_id IS NOT NULL) AS con_wa
FROM lab_pacientes
WHERE is_active = 1
") ?: ['total' => 0, 'importados' => 0, 'manuales' => 0, 'whatsapp' => 0, 'con_wa' => 0];
}
/** /**
* Un paciente por ID (con datos del usuario WhatsApp). * Un paciente por ID (con datos del usuario WhatsApp).
*/ */
@@ -238,7 +256,7 @@ class Paciente {
'user_id', 'numero_documento', 'tipo_documento', 'user_id', 'numero_documento', 'tipo_documento',
'nombre_completo', 'telefono', 'email', 'nombre_completo', 'telefono', 'email',
'fecha_nacimiento', 'genero', 'direccion', 'fecha_nacimiento', 'genero', 'direccion',
'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active', 'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active', 'origen',
]; ];
$campos = array_intersect_key($datos, array_flip($permitidos)); $campos = array_intersect_key($datos, array_flip($permitidos));
if (!empty($campos['telefono'])) { if (!empty($campos['telefono'])) {
+95 -11
View File
@@ -155,8 +155,37 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</header> </header>
<div class="container-fluid py-3"> <div class="container-fluid py-3">
<!-- Buscador -->
<div class="row mb-3"> <!-- Stats -->
<div class="row g-2 mb-3" id="stats-row">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm rounded-3 text-center py-2">
<div class="fw-bold fs-4" id="stat-total">—</div>
<div class="text-muted small">Total</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('rips')">
<div class="fw-bold fs-4 text-primary" id="stat-rips">—</div>
<div class="text-muted small"><i class="fas fa-database me-1 text-primary"></i>Importados RIPS</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('whatsapp')">
<div class="fw-bold fs-4 text-success" id="stat-wa">—</div>
<div class="text-muted small"><i class="fab fa-whatsapp me-1 text-success"></i>Vinculados WA</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('manual')">
<div class="fw-bold fs-4 text-secondary" id="stat-manual">—</div>
<div class="text-muted small"><i class="fas fa-pen me-1"></i>Manuales</div>
</div>
</div>
</div>
<!-- Filtros -->
<div class="row mb-3 g-2">
<div class="col-md-5"> <div class="col-md-5">
<div class="input-group"> <div class="input-group">
<span class="input-group-text bg-white border-end-0"><i class="fas fa-search text-muted"></i></span> <span class="input-group-text bg-white border-end-0"><i class="fas fa-search text-muted"></i></span>
@@ -165,6 +194,19 @@ require_once __DIR__ . '/shared/components/sidebar.php';
oninput="debounce(cargarLista, 380)()"> oninput="debounce(cargarLista, 380)()">
</div> </div>
</div> </div>
<div class="col-md-3">
<select id="filtro-origen" class="form-select" onchange="cargarLista(1)">
<option value="">Todos los orígenes</option>
<option value="rips">Importados RIPS</option>
<option value="manual">Manuales</option>
<option value="whatsapp">Desde WhatsApp</option>
</select>
</div>
<div class="col-md-auto ms-auto">
<button class="btn btn-sm btn-outline-secondary" onclick="cargarStats()" title="Actualizar stats">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div> </div>
<div class="row g-3"> <div class="row g-3">
@@ -178,14 +220,15 @@ require_once __DIR__ . '/shared/components/sidebar.php';
<tr> <tr>
<th class="ps-3">Paciente</th> <th class="ps-3">Paciente</th>
<th>Documento</th> <th>Documento</th>
<th>Teléfono</th> <th>Ciudad</th>
<th>EPS</th> <th>Origen</th>
<th>Registro</th>
<th>Órd.</th> <th>Órd.</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody id="tabla-body"> <tbody id="tabla-body">
<tr><td colspan="6" class="text-center py-4 text-muted"> <tr><td colspan="7" class="text-center py-4 text-muted">
<i class="fas fa-spinner fa-spin me-2"></i>Cargando... <i class="fas fa-spinner fa-spin me-2"></i>Cargando...
</td></tr> </td></tr>
</tbody> </tbody>
@@ -367,16 +410,53 @@ require_once __DIR__ . '/shared/components/sidebar.php';
let paginaActual = 1; let paginaActual = 1;
const modal = new bootstrap.Modal('#modalPaciente'); const modal = new bootstrap.Modal('#modalPaciente');
// ── Stats ──────────────────────────────────────────────────────────────────
async function cargarStats() {
try {
const r = await fetch('api/lab/get_pacientes.php?stats=1');
const d = await r.json();
const s = d.stats || {};
document.getElementById('stat-total').textContent = s.total ?? '—';
document.getElementById('stat-rips').textContent = s.importados ?? '—';
document.getElementById('stat-wa').textContent = s.con_wa ?? '—';
document.getElementById('stat-manual').textContent = s.manuales ?? '—';
} catch(_) {}
}
function filtrarOrigen(origen) {
const sel = document.getElementById('filtro-origen');
sel.value = sel.value === origen ? '' : origen;
cargarLista(1);
}
// ── Lista ────────────────────────────────────────────────────────────────── // ── Lista ──────────────────────────────────────────────────────────────────
const _origenBadge = {
rips: '<span class="badge" style="background:#e3f2fd;color:#1565c0;font-size:.7rem"><i class="fas fa-database me-1"></i>RIPS</span>',
whatsapp: '<span class="badge" style="background:#e8f5e9;color:#2e7d32;font-size:.7rem"><i class="fab fa-whatsapp me-1"></i>WA</span>',
manual: '<span class="badge bg-secondary bg-opacity-10 text-secondary" style="font-size:.7rem"><i class="fas fa-pen me-1"></i>Manual</span>',
};
function origenBadge(origen) {
return _origenBadge[origen] || _origenBadge.manual;
}
function fmtFecha(str) {
if (!str) return '—';
return str.slice(0, 10).split('-').reverse().join('/');
}
async function cargarLista(pag = 1) { async function cargarLista(pag = 1) {
paginaActual = pag; paginaActual = pag;
const busq = document.getElementById('buscador').value.trim(); const busq = document.getElementById('buscador').value.trim();
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(busq)}&page=${pag}&limit=25`); const origen = document.getElementById('filtro-origen').value;
const r = await fetch(
`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(busq)}&page=${pag}&limit=25&origen=${encodeURIComponent(origen)}`
);
const d = await r.json(); const d = await r.json();
const tbody = document.getElementById('tabla-body'); const tbody = document.getElementById('tabla-body');
if (!d.data?.length) { if (!d.data?.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">Sin resultados</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted">Sin resultados</td></tr>';
document.getElementById('paginacion').innerHTML = ''; document.getElementById('paginacion').innerHTML = '';
return; return;
} }
@@ -385,11 +465,12 @@ async function cargarLista(pag = 1) {
<tr style="cursor:pointer" onclick="verDetalle(${p.id})"> <tr style="cursor:pointer" onclick="verDetalle(${p.id})">
<td class="ps-3"> <td class="ps-3">
<div class="fw-semibold">${esc(p.nombre_completo)}</div> <div class="fw-semibold">${esc(p.nombre_completo)}</div>
${p.phone_number ? `<small class="text-muted"><i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}</small>` : ''} ${p.telefono ? `<small class="text-muted">${esc(p.telefono)}</small>` : ''}
</td> </td>
<td class="small">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</td> <td class="small">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</td>
<td class="small">${esc(p.telefono||'—')}</td> <td class="small text-muted">${esc(p.ciudad||'—')}</td>
<td class="small">${esc(p.eps||'—')}</td> <td>${origenBadge(p.origen)}</td>
<td class="small text-muted">${fmtFecha(p.created_at)}</td>
<td><span class="badge bg-primary hist-badge">${p.total_ordenes||0}</span></td> <td><span class="badge bg-primary hist-badge">${p.total_ordenes||0}</span></td>
<td> <td>
<button class="btn btn-sm btn-outline-secondary py-1 px-2" <button class="btn btn-sm btn-outline-secondary py-1 px-2"
@@ -436,6 +517,8 @@ async function verDetalle(id) {
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd> <dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd> <dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd> <dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''} ${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
${p.fecha_nacimiento ? `<dt class="col-5 text-muted">Nacimiento</dt><dd class="col-7">${esc(p.fecha_nacimiento.slice(0,10))}</dd>` : ''} ${p.fecha_nacimiento ? `<dt class="col-5 text-muted">Nacimiento</dt><dd class="col-7">${esc(p.fecha_nacimiento.slice(0,10))}</dd>` : ''}
${p.genero ? `<dt class="col-5 text-muted">Género</dt><dd class="col-7">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</dd>` : ''} ${p.genero ? `<dt class="col-5 text-muted">Género</dt><dd class="col-7">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</dd>` : ''}
@@ -628,6 +711,7 @@ function mostrarToast(msg, tipo = 'success') {
} }
cargarLista(); cargarLista();
cargarStats();
</script> </script>
<script src="assets/js/lab-sidebar.js"></script> <script src="assets/js/lab-sidebar.js"></script>
</body> </body>
@@ -0,0 +1,13 @@
-- Migration: 20260709_lab_pacientes_origen
-- Agrega columna 'origen' para identificar pacientes importados desde RIPS
-- vs creados manualmente o desde WhatsApp
ALTER TABLE `lab_pacientes`
ADD COLUMN `origen` VARCHAR(20) NOT NULL DEFAULT 'manual'
COMMENT 'Origen del registro: manual | rips | whatsapp'
AFTER `notas_admin`;
-- Los pacientes con user_id ya vinculado se marcan como whatsapp
UPDATE `lab_pacientes` SET `origen` = 'whatsapp' WHERE `user_id` IS NOT NULL;
CREATE INDEX `idx_origen` ON `lab_pacientes` (`origen`);