Files
whatsapp/lab_pacientes.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 f363631b32 Turnero: mejoras UI, historial detallado, kiosko scanner, firma por turno
- Kiosko: detector scanner (gap < 80ms), iconos 90px, fa-person-pregnant, ticket Arial
- Recepción: vincular_paciente.php para persistir paciente_id antes de firmar consentimiento
- Historial y dashboard: recepción desk, empleado recepción/muestras, exámenes y comentarios
- display_global: turno más pequeño, lugar-nombre más grande
- lab_pacientes: modal compacta con form-sm
- Configuración: icono embarazada fa-person-pregnant en prioridades
- ver_formulario_enviado: firma por campo (fix doble firma)
- docker-compose.local.yml: override BD producción para dev local

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:07:09 -05:00

453 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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';
?>
<!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=12" rel="stylesheet">
<style>
.table-pacientes th { font-size:.8rem; text-transform:uppercase; letter-spacing:.04em; white-space:nowrap; }
.hist-badge { font-size:.72rem; }
#detail-panel { display:none; }
</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>
<h1><i class="fas fa-users text-primary"></i> Pacientes</h1>
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
</div>
<button class="btn btn-primary btn-sm" onclick="abrirFormulario()">
<i class="fas fa-plus me-1"></i> Nuevo Paciente
</button>
</header>
<div class="container-fluid py-3">
<!-- Buscador -->
<div class="row mb-3">
<div class="col-md-5">
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" id="buscador" class="form-control" placeholder="Nombre, documento o teléfono…" oninput="debounce(cargarLista, 400)()">
</div>
</div>
</div>
<div class="row g-3">
<!-- Lista -->
<div class="col-lg-7" id="lista-col">
<div class="card border-0 shadow-sm">
<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>Paciente</th>
<th>Documento</th>
<th>Teléfono</th>
<th>EPS</th>
<th>Órdenes</th>
<th></th>
</tr>
</thead>
<tbody id="tabla-body">
<tr><td colspan="6" class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando...</td></tr>
</tbody>
</table>
</div>
<!-- Paginación -->
<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">
<div class="card-header bg-white border-0 d-flex align-items-center justify-content-between">
<h6 class="mb-0 fw-semibold" id="detail-nombre">Paciente</h6>
<button class="btn-close" onclick="cerrarDetalle()"></button>
</div>
<div class="card-body" id="detail-body"></div>
</div>
</div>
</div>
</div><!-- /container -->
</main>
<!-- Modal Formulario Paciente -->
<div class="modal fade" id="modalPaciente" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="modal-titulo"><i class="fas fa-user me-2 text-primary"></i>Nuevo Paciente</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body py-3">
<form id="form-paciente">
<input type="hidden" name="id" id="pac-id">
<div class="row g-2">
{{-- Nombre completo --}}
<div class="col-12">
<label class="form-label small fw-semibold mb-1">Nombre completo <span class="text-danger">*</span></label>
<input type="text" class="form-control form-control-sm" name="nombre_completo" id="pac-nombre" required placeholder="Ej: Juan Pérez">
</div>
{{-- Documento --}}
<div class="col-4">
<label class="form-label small fw-semibold mb-1">Tipo doc.</label>
<select class="form-select form-select-sm" name="tipo_documento" id="pac-tipo-doc">
<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>
</div>
<div class="col-4">
<label class="form-label small fw-semibold mb-1">Número documento</label>
<input type="text" class="form-control form-control-sm" name="numero_documento" id="pac-doc" placeholder="123456789">
</div>
<div class="col-4">
<label class="form-label small fw-semibold mb-1">Fecha nacimiento</label>
<input type="date" class="form-control form-control-sm" name="fecha_nacimiento" id="pac-fnac">
</div>
{{-- Contacto --}}
<div class="col-5">
<label class="form-label small fw-semibold mb-1">Teléfono</label>
<div class="input-group input-group-sm">
<select id="pac-tel-prefijo" class="form-select form-select-sm" style="max-width:72px">
<option value="57" selected>🇨🇴 57</option>
<option value="58">🇻🇪 58</option>
<option value="1">🇺🇸 1</option>
</select>
<input type="tel" class="form-control form-control-sm" name="telefono" id="pac-tel" placeholder="3001234567">
</div>
</div>
<div class="col-4">
<label class="form-label small fw-semibold mb-1">Email</label>
<input type="email" class="form-control form-control-sm" name="email" id="pac-email" placeholder="correo@ejemplo.com">
</div>
<div class="col-3">
<label class="form-label small fw-semibold mb-1">Género</label>
<select class="form-select form-select-sm" name="genero" id="pac-genero">
<option value="">—</option>
<option value="M">Masculino</option>
<option value="F">Femenino</option>
<option value="O">Otro</option>
</select>
</div>
{{-- Ubicación --}}
<div class="col-4">
<label class="form-label small fw-semibold mb-1">EPS</label>
<input type="text" class="form-control form-control-sm" name="eps" id="pac-eps" placeholder="EPS del paciente">
</div>
<div class="col-4">
<label class="form-label small fw-semibold mb-1">Ciudad</label>
<input type="text" class="form-control form-control-sm" name="ciudad" id="pac-ciudad">
</div>
<div class="col-4">
<label class="form-label small fw-semibold mb-1">Barrio</label>
<input type="text" class="form-control form-control-sm" name="barrio" id="pac-barrio">
</div>
<div class="col-12">
<label class="form-label small fw-semibold mb-1">Dirección</label>
<input type="text" class="form-control form-control-sm" name="direccion" id="pac-dir">
</div>
<div class="col-12">
<label class="form-label small fw-semibold mb-1">Notas internas</label>
<textarea class="form-control form-control-sm" name="notas_admin" id="pac-notas" rows="2" placeholder="Observaciones o notas adicionales…"></textarea>
</div>
</div>
</form>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-sm btn-primary" onclick="guardarPaciente()">
<i class="fas fa-save me-1"></i> Guardar
</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');
// ── Lista ──────────────────────────────────────────────────────────────────
async function cargarLista(pag = 1) {
paginaActual = pag;
const busq = document.getElementById('buscador').value.trim();
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(busq)}&page=${pag}&limit=25`);
const d = await r.json();
const tbody = document.getElementById('tabla-body');
if (!d.data?.length) {
tbody.innerHTML = '<tr><td colspan="6" 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>
<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>` : ''}
</td>
<td>${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</td>
<td>${esc(p.telefono||'—')}</td>
<td>${esc(p.eps||'—')}</td>
<td><span class="badge bg-primary hist-badge">${p.total_ordenes||0}</span></td>
<td>
<button class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation();editarPaciente(${p.id})" title="Editar">
<i class="fas fa-pen"></i>
</button>
</td>
</tr>
`).join('');
// Paginación
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) {
document.getElementById('detail-panel').style.display = 'block';
document.getElementById('detail-body').innerHTML = '<div class="text-center py-3"><i class="fas fa-spinner fa-spin"></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>
${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>
<h6 class="fw-semibold mb-2"><i class="fas fa-file-medical me-2 text-primary"></i>Órdenes médicas (${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">Sin órdenes</p>'}
<button class="btn btn-sm btn-outline-primary w-100 mt-2" 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 abrirFormulario(p = null) {
const form = document.getElementById('form-paciente');
form.reset();
document.getElementById('pac-id').value = p ? p.id : '';
document.getElementById('modal-titulo').innerHTML = p
? `<i class="fas fa-pen me-2"></i>Editar Paciente`
: `<i class="fas fa-plus me-2"></i>Nuevo Paciente`;
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 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;
// ── Validaciones de formato ───────────────────────────────────────────
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;
}
// ─────────────────────────────────────────────────────────────────────
// Normalizar teléfono: anteponer prefijo seleccionado
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;
// Validar formato: colombiano (57) exige iniciar en 3 con 10 dígitos; otros países solo longitud
if (datos.telefono) {
const digits = datos.telefono.replace(/[^0-9]/g, '');
const esColombia = prefijo === '57';
if (esColombia) {
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 => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
const TIPO_DOC = {
CC: 'CC — Cédula de Ciudadanía',
CE: 'CE — Cédula de Extranjería',
TI: 'TI — Tarjeta de Identidad',
PA: 'PA — Pasaporte',
DE: 'DE — Documento Extranjero',
NIT: 'NIT',
RC: 'RC — Registro Civil',
};
const tipoDocLabel = t => TIPO_DOC[t] || 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`;
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);
}
// Init
cargarLista();
</script>
<script src="assets/js/lab-sidebar.js"></script>
</body>
</html>