- 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>
719 lines
36 KiB
PHP
719 lines
36 KiB
PHP
<?php
|
||
/**
|
||
* Gestión de Pacientes — Módulo Administrativo de Laboratorio
|
||
*/
|
||
require_once 'config/config.php';
|
||
if (!defined('APP_ROOT')) { define('APP_ROOT', __DIR__); }
|
||
|
||
if (!isUserLoggedIn()) {
|
||
header('Location: login.php');
|
||
exit;
|
||
}
|
||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||
|
||
// Color de marca
|
||
$brandColor = '#1565c0';
|
||
try {
|
||
$row = Database::getInstance()->fetch("SELECT valor FROM lab_config WHERE clave = 'doc_color'");
|
||
if ($row && preg_match('/^#[0-9a-fA-F]{3,8}$/', $row['valor'])) $brandColor = $row['valor'];
|
||
} catch (\Throwable $_) {}
|
||
$hex = ltrim($brandColor, '#');
|
||
$brandDark = sprintf('#%02x%02x%02x',
|
||
max(0, hexdec(substr($hex,0,2)) - 40),
|
||
max(0, hexdec(substr($hex,2,2)) - 40),
|
||
max(0, hexdec(substr($hex,4,2)) - 40)
|
||
);
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Pacientes — Módulo Lab</title>
|
||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<link href="./assets/css/styles.css?v=13" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--brand: <?= htmlspecialchars($brandColor, ENT_QUOTES, 'UTF-8') ?>;
|
||
--brand-dark: <?= htmlspecialchars($brandDark, ENT_QUOTES, 'UTF-8') ?>;
|
||
--sidebar-width: 270px;
|
||
}
|
||
body { overflow-x: hidden; }
|
||
.main-content {
|
||
margin-left: var(--sidebar-width);
|
||
min-height: 100vh;
|
||
background: #f8f9fa;
|
||
}
|
||
@media (max-width: 991.98px) { .main-content { margin-left: 0; } }
|
||
|
||
.table-pacientes th {
|
||
font-size: .78rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: .05em;
|
||
white-space: nowrap;
|
||
color: #64748b;
|
||
}
|
||
.hist-badge { font-size: .72rem; }
|
||
#detail-panel { display: none; }
|
||
|
||
/* ── Modal header ──────────────────────────────── */
|
||
.modal-header-brand {
|
||
background: linear-gradient(135deg, var(--brand-dark) 0%, var(--brand) 100%);
|
||
color: #fff;
|
||
padding: 1.4rem 1.5rem 1rem;
|
||
position: relative;
|
||
}
|
||
.modal-header-brand .btn-close { filter: invert(1) brightness(2); }
|
||
.pac-avatar {
|
||
width: 52px; height: 52px; border-radius: 50%;
|
||
background: rgba(255,255,255,.18);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 1.4rem; font-weight: 800; color: #fff;
|
||
border: 2px solid rgba(255,255,255,.35);
|
||
flex-shrink: 0;
|
||
}
|
||
.modal-header-brand .modal-title {
|
||
color: #fff; font-size: 1rem; font-weight: 700; line-height: 1.2;
|
||
}
|
||
.modal-header-brand .modal-subtitle {
|
||
font-size: .75rem; opacity: .75; margin-top: 1px;
|
||
}
|
||
|
||
/* ── Secciones ─────────────────────────────────── */
|
||
.form-section {
|
||
background: #f8faff;
|
||
border: 1px solid color-mix(in srgb, var(--brand) 12%, #e2e8f0);
|
||
border-radius: .6rem;
|
||
padding: .9rem 1rem .6rem;
|
||
margin-bottom: .8rem;
|
||
}
|
||
.form-section-label {
|
||
font-size: .65rem; font-weight: 800; text-transform: uppercase;
|
||
letter-spacing: .1em; color: var(--brand);
|
||
margin: 0 0 .65rem;
|
||
display: flex; align-items: center; gap: .4rem;
|
||
}
|
||
.form-section-label i { opacity: .7; }
|
||
|
||
/* Floating label overrides */
|
||
.form-floating > label { font-size: .8rem; color: #64748b; }
|
||
.form-floating > .form-control,
|
||
.form-floating > .form-select { font-size: .875rem; border-color: #dde3ed; border-radius: .45rem; }
|
||
.form-floating > .form-control:focus,
|
||
.form-floating > .form-select:focus {
|
||
border-color: var(--brand);
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 15%, transparent);
|
||
}
|
||
|
||
/* ── Modal footer ──────────────────────────────── */
|
||
.modal-footer-brand {
|
||
background: #f8faff;
|
||
border-top: 1px solid #e8edf5;
|
||
padding: .8rem 1.25rem;
|
||
display: flex; gap: .5rem; justify-content: flex-end;
|
||
}
|
||
.btn-brand-solid {
|
||
background: linear-gradient(135deg, var(--brand-dark), var(--brand));
|
||
color: #fff; border: none; border-radius: .45rem;
|
||
padding: .45rem 1.2rem; font-size: .85rem; font-weight: 600;
|
||
transition: opacity .15s;
|
||
}
|
||
.btn-brand-solid:hover { opacity: .88; color: #fff; }
|
||
|
||
/* ── Panel detalle ─────────────────────────────── */
|
||
.detail-header-band {
|
||
background: linear-gradient(135deg, var(--brand-dark) 0%, var(--brand) 100%);
|
||
color: #fff; border-radius: .5rem .5rem 0 0;
|
||
padding: .75rem 1rem;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
}
|
||
.detail-header-band .btn-close { filter: invert(1) brightness(2); }
|
||
|
||
/* ── Tabla ─────────────────────────────────────── */
|
||
.table-hover tbody tr:hover { background: color-mix(in srgb, var(--brand) 6%, #fff) !important; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<?php
|
||
$SIDEBAR_TITLE = 'Panel Lab';
|
||
$SIDEBAR_ICON = 'fas fa-flask';
|
||
require_once __DIR__ . '/shared/components/sidebar.php';
|
||
?>
|
||
|
||
<main class="main-content">
|
||
<header class="content-header d-flex align-items-center justify-content-between">
|
||
<div class="d-flex align-items-center gap-2">
|
||
<h1 class="mb-0"><i class="fas fa-users" style="color:var(--brand)"></i> Pacientes</h1>
|
||
<small class="text-muted d-none d-sm-inline"><?= htmlspecialchars($adminNombre) ?></small>
|
||
</div>
|
||
<button class="btn btn-sm" style="background:var(--brand);color:#fff" onclick="abrirFormulario()">
|
||
<i class="fas fa-plus me-1"></i> Nuevo
|
||
</button>
|
||
</header>
|
||
|
||
<div class="container-fluid py-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="input-group">
|
||
<span class="input-group-text bg-white border-end-0"><i class="fas fa-search text-muted"></i></span>
|
||
<input type="text" id="buscador" class="form-control border-start-0 ps-0"
|
||
placeholder="Nombre, documento o teléfono…"
|
||
oninput="debounce(cargarLista, 380)()">
|
||
</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 class="row g-3">
|
||
<!-- Lista -->
|
||
<div class="col-lg-7" id="lista-col">
|
||
<div class="card border-0 shadow-sm rounded-3">
|
||
<div class="card-body p-0">
|
||
<div class="table-responsive">
|
||
<table class="table table-hover table-pacientes mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th class="ps-3">Paciente</th>
|
||
<th>Documento</th>
|
||
<th>Ciudad</th>
|
||
<th>Origen</th>
|
||
<th>Registro</th>
|
||
<th>Órd.</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tabla-body">
|
||
<tr><td colspan="7" class="text-center py-4 text-muted">
|
||
<i class="fas fa-spinner fa-spin me-2"></i>Cargando...
|
||
</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="d-flex align-items-center justify-content-between px-3 py-2 border-top" id="paginacion"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Panel de detalle -->
|
||
<div class="col-lg-5" id="detail-panel">
|
||
<div class="card border-0 shadow-sm rounded-3 overflow-hidden">
|
||
<div class="detail-header-band">
|
||
<span class="fw-semibold" id="detail-nombre">Paciente</span>
|
||
<button class="btn-close" onclick="cerrarDetalle()"></button>
|
||
</div>
|
||
<div class="card-body" id="detail-body"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- Modal Formulario Paciente -->
|
||
<div class="modal fade" id="modalPaciente" tabindex="-1">
|
||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||
<div class="modal-content border-0 shadow-lg rounded-4 overflow-hidden">
|
||
|
||
<!-- Header -->
|
||
<div class="modal-header-brand d-flex align-items-center gap-3">
|
||
<div class="pac-avatar" id="modal-avatar">?</div>
|
||
<div class="flex-1">
|
||
<div class="modal-title" id="modal-titulo">Nuevo Paciente</div>
|
||
<div class="modal-subtitle" id="modal-subtitulo">Completa los datos del paciente</div>
|
||
</div>
|
||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
|
||
<div class="modal-body py-3 px-4">
|
||
<form id="form-paciente" autocomplete="off">
|
||
<input type="hidden" name="id" id="pac-id">
|
||
|
||
<!-- Datos personales -->
|
||
<div class="form-section">
|
||
<div class="form-section-label"><i class="fas fa-id-card"></i> Datos personales</div>
|
||
<div class="row g-2">
|
||
<div class="col-12">
|
||
<div class="form-floating">
|
||
<input type="text" class="form-control" name="nombre_completo" id="pac-nombre"
|
||
required placeholder="Nombre completo" tabindex="1"
|
||
oninput="actualizarAvatar(this.value)">
|
||
<label for="pac-nombre">Nombre completo <span class="text-danger">*</span></label>
|
||
</div>
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<div class="form-floating">
|
||
<select class="form-select" name="tipo_documento" id="pac-tipo-doc" tabindex="2">
|
||
<option value="CC">CC — Cédula</option>
|
||
<option value="CE">CE — Extranjería</option>
|
||
<option value="TI">TI — Tarjeta Identidad</option>
|
||
<option value="PA">PA — Pasaporte</option>
|
||
<option value="DE">DE — Doc. Extranjero</option>
|
||
<option value="NIT">NIT</option>
|
||
<option value="RC">RC — Reg. Civil</option>
|
||
</select>
|
||
<label for="pac-tipo-doc">Tipo documento</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<div class="form-floating">
|
||
<input type="text" class="form-control" name="numero_documento" id="pac-doc" placeholder="Número documento" tabindex="3">
|
||
<label for="pac-doc">Número documento</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<div class="form-floating">
|
||
<input type="date" class="form-control" name="fecha_nacimiento" id="pac-fnac" placeholder="Fecha nacimiento" tabindex="4">
|
||
<label for="pac-fnac">Fecha nacimiento</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Contacto -->
|
||
<div class="form-section">
|
||
<div class="form-section-label"><i class="fas fa-phone"></i> Contacto</div>
|
||
<div class="row g-2">
|
||
<div class="col-sm-5">
|
||
<label class="form-label small fw-semibold mb-1" style="color:#64748b">Teléfono</label>
|
||
<div class="input-group">
|
||
<select id="pac-tel-prefijo" class="form-select" tabindex="-1" style="max-width:82px;border-color:#dde3ed;border-radius:.45rem 0 0 .45rem;font-size:.875rem">
|
||
<option value="57" selected>🇨🇴 57</option>
|
||
<option value="58">🇻🇪 58</option>
|
||
<option value="1">🇺🇸 1</option>
|
||
</select>
|
||
<input type="tel" class="form-control" name="telefono" id="pac-tel"
|
||
placeholder="3001234567" tabindex="5" style="border-radius:0 .45rem .45rem 0"
|
||
onblur="verificarWhatsapp()">
|
||
</div>
|
||
<div id="wa-badge" class="mt-1" style="font-size:.78rem;min-height:18px"></div>
|
||
<input type="hidden" id="pac-user-id" name="user_id">
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<div class="form-floating">
|
||
<input type="email" class="form-control" name="email" id="pac-email" placeholder="Email" tabindex="6">
|
||
<label for="pac-email">Email</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-sm-3">
|
||
<div class="form-floating">
|
||
<select class="form-select" name="genero" id="pac-genero" tabindex="7">
|
||
<option value="">—</option>
|
||
<option value="M">Masculino</option>
|
||
<option value="F">Femenino</option>
|
||
<option value="O">Otro</option>
|
||
</select>
|
||
<label for="pac-genero">Género</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Ubicación y EPS -->
|
||
<div class="form-section">
|
||
<div class="form-section-label"><i class="fas fa-map-marker-alt"></i> Ubicación y EPS</div>
|
||
<div class="row g-2">
|
||
<div class="col-sm-4">
|
||
<div class="form-floating">
|
||
<input type="text" class="form-control" name="eps" id="pac-eps" placeholder="EPS" tabindex="8">
|
||
<label for="pac-eps">EPS</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<div class="form-floating">
|
||
<input type="text" class="form-control" name="ciudad" id="pac-ciudad" placeholder="Ciudad" tabindex="9" value="Cúcuta">
|
||
<label for="pac-ciudad">Ciudad</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<div class="form-floating">
|
||
<input type="text" class="form-control" name="barrio" id="pac-barrio" placeholder="Barrio" tabindex="10">
|
||
<label for="pac-barrio">Barrio</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-12">
|
||
<div class="form-floating">
|
||
<input type="text" class="form-control" name="direccion" id="pac-dir" placeholder="Dirección" tabindex="11">
|
||
<label for="pac-dir">Dirección</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-12">
|
||
<div class="form-floating">
|
||
<textarea class="form-control" name="notas_admin" id="pac-notas"
|
||
placeholder="Notas internas" tabindex="12" style="height:72px"></textarea>
|
||
<label for="pac-notas">Notas internas</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</form>
|
||
</div>
|
||
|
||
<div class="modal-footer-brand">
|
||
<button type="button" class="btn btn-sm btn-light px-3" data-bs-dismiss="modal">
|
||
<i class="fas fa-times me-1"></i>Cancelar
|
||
</button>
|
||
<button type="button" class="btn-brand-solid" onclick="guardarPaciente()">
|
||
<i class="fas fa-save me-1"></i> Guardar paciente
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||
<script>
|
||
let paginaActual = 1;
|
||
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 ──────────────────────────────────────────────────────────────────
|
||
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) {
|
||
paginaActual = pag;
|
||
const busq = document.getElementById('buscador').value.trim();
|
||
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 tbody = document.getElementById('tabla-body');
|
||
if (!d.data?.length) {
|
||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted">Sin resultados</td></tr>';
|
||
document.getElementById('paginacion').innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = d.data.map(p => `
|
||
<tr style="cursor:pointer" onclick="verDetalle(${p.id})">
|
||
<td class="ps-3">
|
||
<div class="fw-semibold">${esc(p.nombre_completo)}</div>
|
||
${p.telefono ? `<small class="text-muted">${esc(p.telefono)}</small>` : ''}
|
||
</td>
|
||
<td class="small">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</td>
|
||
<td class="small text-muted">${esc(p.ciudad||'—')}</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>
|
||
<button class="btn btn-sm btn-outline-secondary py-1 px-2"
|
||
onclick="event.stopPropagation();editarPaciente(${p.id})" title="Editar">
|
||
<i class="fas fa-pen fa-xs"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
const pag_html = [];
|
||
if (d.paginas > 1) {
|
||
pag_html.push(`<small class="text-muted">Mostrando ${((pag-1)*25)+1}–${Math.min(pag*25,d.total)} de ${d.total}</small>`);
|
||
pag_html.push(`<div class="btn-group btn-group-sm">`);
|
||
if (pag > 1) pag_html.push(`<button class="btn btn-outline-secondary" onclick="cargarLista(${pag-1})"><i class="fas fa-chevron-left"></i></button>`);
|
||
pag_html.push(`<button class="btn btn-secondary disabled">${pag}/${d.paginas}</button>`);
|
||
if (pag < d.paginas) pag_html.push(`<button class="btn btn-outline-secondary" onclick="cargarLista(${pag+1})"><i class="fas fa-chevron-right"></i></button>`);
|
||
pag_html.push(`</div>`);
|
||
} else {
|
||
pag_html.push(`<small class="text-muted">${d.total} paciente(s)</small>`);
|
||
}
|
||
document.getElementById('paginacion').innerHTML = pag_html.join('');
|
||
}
|
||
|
||
// ── Detalle ────────────────────────────────────────────────────────────────
|
||
async function verDetalle(id) {
|
||
const panel = document.getElementById('detail-panel');
|
||
panel.style.display = 'block';
|
||
document.getElementById('detail-body').innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-lg text-muted"></i></div>';
|
||
|
||
const r = await fetch(`api/lab/get_pacientes.php?id=${id}`);
|
||
const d = await r.json();
|
||
const p = d.data?.[0];
|
||
if (!p) return;
|
||
|
||
document.getElementById('detail-nombre').textContent = p.nombre_completo;
|
||
|
||
const r2 = await fetch(`api/lab/get_ordenes.php?paciente_id=${id}&limit=10&no_counts=1`);
|
||
const od = await r2.json();
|
||
const ords = od.data || [];
|
||
|
||
document.getElementById('detail-body').innerHTML = `
|
||
<dl class="row small mb-3">
|
||
<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">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.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>` : ''}
|
||
<dt class="col-5 text-muted">EPS</dt><dd class="col-7">${esc(p.eps||'—')}</dd>
|
||
<dt class="col-5 text-muted">Ciudad</dt><dd class="col-7">${esc(p.ciudad||'—')}</dd>
|
||
${p.barrio ? `<dt class="col-5 text-muted">Barrio</dt><dd class="col-7">${esc(p.barrio)}</dd>` : ''}
|
||
<dt class="col-5 text-muted">Dirección</dt><dd class="col-7">${esc(p.direccion||'—')}</dd>
|
||
${p.notas_admin ? `<dt class="col-5 text-muted">Notas</dt><dd class="col-7 text-muted fst-italic">${esc(p.notas_admin)}</dd>` : ''}
|
||
</dl>
|
||
<hr class="my-2">
|
||
<h6 class="fw-semibold mb-2 small"><i class="fas fa-file-medical me-2" style="color:var(--brand)"></i>Órdenes (${ords.length})</h6>
|
||
${ords.length ? `
|
||
<table class="table table-sm table-hover">
|
||
<thead class="table-light"><tr><th>#</th><th>Fecha</th><th>Estado</th></tr></thead>
|
||
<tbody>
|
||
${ords.map(o => `<tr onclick="location.href='lab_ordenes.php?id=${o.id}'" style="cursor:pointer">
|
||
<td>#${o.id}</td>
|
||
<td>${formatFecha(o.created_at)}</td>
|
||
<td><span class="badge bg-${colorEstado(o.estado)}">${esc(o.estado)}</span></td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>` : '<p class="text-muted small mb-2">Sin órdenes</p>'}
|
||
<button class="btn btn-sm w-100 mt-1" style="border:1px solid var(--brand);color:var(--brand)" onclick="editarPaciente(${p.id})">
|
||
<i class="fas fa-pen me-1"></i> Editar datos
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
function cerrarDetalle() {
|
||
document.getElementById('detail-panel').style.display = 'none';
|
||
}
|
||
|
||
// ── Formulario ─────────────────────────────────────────────────────────────
|
||
function actualizarAvatar(nombre) {
|
||
const av = document.getElementById('modal-avatar');
|
||
if (!av) return;
|
||
const partes = nombre.trim().split(/\s+/).filter(Boolean);
|
||
av.textContent = partes.length >= 2
|
||
? (partes[0][0] + partes[1][0]).toUpperCase()
|
||
: (partes[0]?.[0] || '?').toUpperCase();
|
||
}
|
||
|
||
function abrirFormulario(p = null) {
|
||
const form = document.getElementById('form-paciente');
|
||
form.reset();
|
||
document.getElementById('pac-id').value = p ? p.id : '';
|
||
document.getElementById('modal-titulo').textContent = p ? 'Editar Paciente' : 'Nuevo Paciente';
|
||
document.getElementById('modal-subtitulo').textContent = p
|
||
? (p.nombre_completo || 'Actualiza los datos')
|
||
: 'Completa los datos del paciente';
|
||
actualizarAvatar(p?.nombre_completo || '');
|
||
|
||
if (p) {
|
||
document.getElementById('pac-nombre').value = p.nombre_completo || '';
|
||
document.getElementById('pac-doc').value = p.numero_documento || '';
|
||
document.getElementById('pac-tipo-doc').value = p.tipo_documento || 'CC';
|
||
document.getElementById('pac-tel-prefijo').value = '57';
|
||
let telRaw = p.telefono || '';
|
||
if (/^1\d{10}$/.test(telRaw)) {
|
||
document.getElementById('pac-tel-prefijo').value = '1';
|
||
telRaw = telRaw.slice(1);
|
||
} else if (/^(57|58)\d{10}$/.test(telRaw)) {
|
||
document.getElementById('pac-tel-prefijo').value = telRaw.slice(0, 2);
|
||
telRaw = telRaw.slice(2);
|
||
}
|
||
document.getElementById('pac-tel').value = telRaw;
|
||
document.getElementById('pac-email').value = p.email || '';
|
||
document.getElementById('pac-fnac').value = p.fecha_nacimiento || '';
|
||
document.getElementById('pac-genero').value = p.genero || '';
|
||
document.getElementById('pac-eps').value = p.eps || '';
|
||
document.getElementById('pac-ciudad').value = p.ciudad || '';
|
||
document.getElementById('pac-barrio').value = p.barrio || '';
|
||
document.getElementById('pac-dir').value = p.direccion || '';
|
||
document.getElementById('pac-notas').value = p.notas_admin || '';
|
||
}
|
||
modal.show();
|
||
}
|
||
|
||
async function editarPaciente(id) {
|
||
const r = await fetch(`api/lab/get_pacientes.php?id=${id}`);
|
||
const d = await r.json();
|
||
if (d.data?.[0]) abrirFormulario(d.data[0]);
|
||
}
|
||
|
||
async function verificarWhatsapp() {
|
||
const prefijo = document.getElementById('pac-tel-prefijo').value || '57';
|
||
let tel = (document.getElementById('pac-tel').value || '').replace(/[^0-9]/g, '');
|
||
if (tel.length === 10) tel = prefijo + tel;
|
||
const badge = document.getElementById('wa-badge');
|
||
const hiddenUid = document.getElementById('pac-user-id');
|
||
if (!tel || tel.length < 10) { badge.innerHTML = ''; hiddenUid.value = ''; return; }
|
||
badge.innerHTML = '<span class="text-muted"><i class="fas fa-spinner fa-spin me-1"></i>Verificando WhatsApp…</span>';
|
||
try {
|
||
const r = await fetch(`api/lab/check_whatsapp.php?phone=${encodeURIComponent(tel)}`);
|
||
const d = await r.json();
|
||
if (d.ok && d.found) {
|
||
badge.innerHTML = '<span class="text-success"><i class="fab fa-whatsapp me-1"></i>Registrado en WhatsApp</span>';
|
||
hiddenUid.value = d.user_id;
|
||
} else {
|
||
badge.innerHTML = '<span class="text-muted"><i class="fas fa-times-circle me-1"></i>Sin WhatsApp registrado</span>';
|
||
hiddenUid.value = '';
|
||
}
|
||
} catch(_) { badge.innerHTML = ''; hiddenUid.value = ''; }
|
||
}
|
||
|
||
async function guardarPaciente() {
|
||
const form = document.getElementById('form-paciente');
|
||
if (!form.checkValidity()) { form.reportValidity(); return; }
|
||
|
||
const datos = Object.fromEntries(new FormData(form).entries());
|
||
if (!datos.id) delete datos.id;
|
||
|
||
const nombre = (datos.nombre_completo || '').trim();
|
||
if (!/^[\p{L}\s'\-\.]+$/u.test(nombre)) {
|
||
mostrarToast('El nombre solo debe contener letras, tildes y espacios.', 'danger'); return;
|
||
}
|
||
if (nombre.split(/\s+/).filter(Boolean).length < 2) {
|
||
mostrarToast('Ingresa nombre y apellido completos (mínimo 2 palabras).', 'danger'); return;
|
||
}
|
||
const docVal = (datos.numero_documento || '').trim();
|
||
const tipoDoc = document.getElementById('pac-tipo-doc')?.value || datos.tipo_documento || 'CC';
|
||
if (docVal && ['CC','TI','RC','CE'].includes(tipoDoc)) {
|
||
const docDigits = docVal.replace(/[^0-9]/g, '');
|
||
if (docDigits.length < 4 || docDigits.length > 12) {
|
||
mostrarToast('La cédula/TI debe tener entre 4 y 12 dígitos.', 'danger'); return;
|
||
}
|
||
}
|
||
const emailVal = (datos.email || '').trim();
|
||
if (emailVal && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailVal)) {
|
||
mostrarToast('El correo electrónico no es válido. Ej: nombre@dominio.com.', 'danger'); return;
|
||
}
|
||
|
||
const prefijo = document.getElementById('pac-tel-prefijo').value || '57';
|
||
let tel = (datos.telefono || '').replace(/[^0-9+]/g, '');
|
||
if (tel.startsWith('+')) tel = tel.slice(1);
|
||
if (tel.length === 10) tel = prefijo + tel;
|
||
datos.telefono = tel || null;
|
||
if (datos.telefono) {
|
||
const digits = datos.telefono.replace(/[^0-9]/g, '');
|
||
if (prefijo === '57') {
|
||
const sinPref = digits.replace(/^57/, '');
|
||
if (!/^3\d{9}$/.test(sinPref)) {
|
||
mostrarToast('El celular colombiano debe comenzar por 3 y tener 10 dígitos. Ej: 3001234567.', 'danger'); return;
|
||
}
|
||
} else if (digits.length < 6 || digits.length > 15) {
|
||
mostrarToast('El número telefónico no es válido (entre 6 y 15 dígitos).', 'danger'); return;
|
||
}
|
||
}
|
||
|
||
const r = await fetch('api/lab/save_paciente.php', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify(datos),
|
||
});
|
||
const d = await r.json();
|
||
|
||
if (d.success) {
|
||
modal.hide();
|
||
mostrarToast(d.message, 'success');
|
||
cargarLista(paginaActual);
|
||
} else {
|
||
mostrarToast(d.error || 'Error al guardar', 'danger');
|
||
}
|
||
}
|
||
|
||
// ── Utils ──────────────────────────────────────────────────────────────────
|
||
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||
const TIPO_DOC = {
|
||
CC:'CC', CE:'CE', TI:'TI', PA:'PA', DE:'DE', NIT:'NIT', RC:'RC',
|
||
};
|
||
const tipoDocLabel = t => TIPO_DOC[t] ? `<span class="badge bg-secondary" style="font-size:.7rem">${TIPO_DOC[t]}</span>` : esc(t||'—');
|
||
const formatFecha = s => s ? new Date(s).toLocaleDateString('es-CO') : '—';
|
||
const colorEstado = e => ({
|
||
pendiente:'warning', en_revision:'info', autorizada:'success',
|
||
rechazada:'danger', en_domicilio:'primary', completada:'secondary',
|
||
}[e] || 'secondary');
|
||
|
||
function debounce(fn, ms) {
|
||
let t;
|
||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||
}
|
||
|
||
function mostrarToast(msg, tipo = 'success') {
|
||
const div = document.createElement('div');
|
||
div.className = `alert alert-${tipo} alert-dismissible position-fixed bottom-0 end-0 m-3 shadow`;
|
||
div.style.zIndex = 9999;
|
||
div.innerHTML = `${esc(msg)}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||
document.body.appendChild(div);
|
||
setTimeout(() => div.remove(), 4000);
|
||
}
|
||
|
||
cargarLista();
|
||
cargarStats();
|
||
</script>
|
||
<script src="assets/js/lab-sidebar.js"></script>
|
||
</body>
|
||
</html>
|