Files
whatsapp/lab_pacientes.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 a5d42a54be fix: lab_pacientes.php — corregir comentarios Blade y mejorar diseño modal
- Eliminar comentarios {{-- --}} de Blade que se renderizaban como texto visible
- Inyectar --brand/--brand-dark desde doc_color de BD (igual que Layout.php)
- Modal header con gradiente brand igual que login/sidebar
- Secciones de formulario con separadores visuales (Datos, Contacto, Ubicación)
- Panel detalle con header brand en lugar de card-header blanco
- Actualizar version CSS a v=13

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

522 lines
26 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';
// 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 con gradiente brand */
.modal-header-brand {
background: linear-gradient(135deg, var(--brand-dark) 0%, var(--brand) 100%);
color: #fff;
border-radius: .5rem .5rem 0 0;
padding: 1rem 1.25rem;
}
.modal-header-brand .modal-title { color: #fff; font-size: .95rem; font-weight: 700; }
.modal-header-brand .btn-close { filter: invert(1) brightness(2); }
/* Secciones del formulario */
.form-section-label {
font-size: .68rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--brand);
margin: .75rem 0 .4rem;
padding-bottom: .25rem;
border-bottom: 1px solid color-mix(in srgb, var(--brand) 20%, transparent);
display: flex;
align-items: center;
gap: .4rem;
}
/* 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); }
/* Row hover en 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">
<!-- Buscador -->
<div class="row mb-3">
<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>
<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>Teléfono</th>
<th>EPS</th>
<th>Órd.</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>
<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 rounded-3 overflow-hidden">
<div class="modal-header-brand">
<h6 class="modal-title" id="modal-titulo"><i class="fas fa-user me-2"></i>Nuevo Paciente</h6>
<button type="button" class="btn-close" 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-label"><i class="fas fa-id-card"></i> Datos personales</div>
<div class="row g-2">
<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: María García López">
</div>
<div class="col-sm-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-sm-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-sm-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>
</div>
<!-- Contacto -->
<div class="form-section-label mt-3"><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">Teléfono</label>
<div class="input-group input-group-sm">
<select id="pac-tel-prefijo" class="form-select form-select-sm" style="max-width:76px">
<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-sm-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-sm-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>
</div>
<!-- Ubicación -->
<div class="form-section-label mt-3"><i class="fas fa-map-marker-alt"></i> Ubicación y EPS</div>
<div class="row g-2">
<div class="col-sm-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-sm-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-sm-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 border-top">
<button type="button" class="btn btn-sm btn-light" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-sm" style="background:var(--brand);color:#fff" 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 class="ps-3">
<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 class="small">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</td>
<td class="small">${esc(p.telefono||'—')}</td>
<td class="small">${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 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>
${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 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-user-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;
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 => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[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();
</script>
<script src="assets/js/lab-sidebar.js"></script>
</body>
</html>