Files
whatsapp/lab_pacientes.php
T
Lizandro GuarnizoandClaude Opus 5 2b1f08b03f Evitar teléfonos falsos en la historia clínica, y pedir el número a quien lo oculta
El arreglo anterior dejó un defecto: obtenerOCrearDesdeWhatsapp() copiaba
users.phone_number al teléfono de la ficha, y para quien oculta su número ahí
va el BSUID. normalizarTelefono() le quitaba el punto y las letras, así que
"CO.1761088155094242" quedaba guardado como "1761088155094242": un número de
16 dígitos, falso, con apariencia de real, dentro de una historia clínica y
sin que nadie lo notara. Ahora esa ficha se crea sin teléfono, que es la
verdad: no lo tenemos.

- esBsuid() queda definido una sola vez, en config.php, que es donde lo ven
  tanto el servicio de WhatsApp como las clases del laboratorio.
- La lista y el detalle de pacientes dicen "Solo por WhatsApp" en vez de
  mostrar el identificador crudo: recepción necesita entender por qué no
  puede llamar, no ver un código.
- Al crear la ficha desde una conversación se le pide el número con el botón
  de Meta, una sola vez (users.contacto_pedido_at). El texto es editable
  desde configuración. Solo se marca como pedido si Meta aceptó el envío,
  para poder reintentar si falló.
- tests/test_bsuid.php: 30 verificaciones sobre identidad, envío, vinculación
  y creación de fichas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:26:31 -05:00

783 lines
39 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; }
/* Drawer de detalle (fixed overlay) */
#detail-backdrop {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,.25);
z-index: 1049;
}
#detail-panel {
position: fixed;
top: 0; right: 0;
height: 100vh;
width: 420px;
z-index: 1050;
background: #fff;
box-shadow: -4px 0 24px rgba(0,0,0,.18);
transform: translateX(110%);
transition: transform .25s cubic-bezier(.4,0,.2,1);
display: flex; flex-direction: column;
}
#detail-panel.open { transform: translateX(0); }
@media (max-width: 480px) { #detail-panel { width: 100vw; } }
#detail-body { flex: 1; overflow-y: auto; padding: 1rem; }
/* ── 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;
padding: .75rem 1rem;
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0;
}
.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('lab')">
<div class="fw-bold fs-4 text-primary" id="stat-rips">—</div>
<div class="text-muted small"><i class="fas fa-flask me-1 text-primary"></i>Importados Lab</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="lab">Resultados Lab</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-12" 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>Email</th>
<th>Ciudad</th>
<th>EPS</th>
<th>Origen</th>
<th>Registro</th>
<th>Órd.</th>
<th></th>
</tr>
</thead>
<tbody id="tabla-body">
<tr><td colspan="10" 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>
</div>
</div>
</main>
<!-- Backdrop para el drawer de detalle -->
<div id="detail-backdrop" onclick="cerrarDetalle()"></div>
<!-- Drawer de detalle (fixed overlay) -->
<div id="detail-panel">
<div class="detail-header-band">
<span class="fw-semibold" id="detail-nombre">Paciente</span>
<button class="btn-close" onclick="cerrarDetalle()"></button>
</div>
<div id="detail-body"></div>
</div>
<!-- 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 = {
lab: '<span class="badge" style="background:#e3f2fd;color:#1565c0;font-size:.7rem"><i class="fas fa-flask me-1"></i>Resultados Lab</span>',
whatsapp: '<span class="badge" style="background:#e8f5e9;color:#2e7d32;font-size:.7rem"><i class="fab fa-whatsapp me-1"></i>WhatsApp</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>',
rips: '<span class="badge" style="background:#e3f2fd;color:#1565c0;font-size:.7rem"><i class="fas fa-flask me-1"></i>Resultados Lab</span>',
};
function origenBadge(origen) {
return _origenBadge[origen] || _origenBadge.manual;
}
function fmtFecha(str) {
if (!str) return '—';
return str.slice(0, 10).split('-').reverse().join('/');
}
// Quien oculta su número en WhatsApp llega identificado con un BSUID
// ("CO.1761088155094242"), que se guarda en phone_number pero no es un teléfono.
const esBsuid = v => /^[A-Z]{2}\.\d+$/.test(String(v || ''));
/**
* Celda de contacto. Si de esta persona no tenemos teléfono porque lo tiene
* oculto, se dice así en vez de mostrar el identificador crudo: recepción
* necesita entender por qué no puede llamarla, no ver un código.
*/
function celdaTelefono(p) {
if (esBsuid(p.phone_number)) {
return (p.telefono ? esc(p.telefono) : '<span class="text-muted">Sin teléfono</span>')
+ '<br><i class="fab fa-whatsapp text-success"></i> '
+ '<small class="text-muted" title="Tiene el número oculto en WhatsApp. '
+ 'Se le puede escribir por el chat, pero no llamar.">Solo por WhatsApp</small>';
}
return esc(p.telefono || '—')
+ (p.phone_number
? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>`
: '');
}
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="10" 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.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
</td>
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
<td class="small">${celdaTelefono(p)}</td>
<td class="small text-muted">${esc(p.email||'—')}</td>
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
<td class="small text-muted">${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>
<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.classList.add('open');
document.getElementById('detail-backdrop').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">${
p.telefono ? esc(p.telefono)
: (esBsuid(p.phone_number)
? '<span class="text-muted">Sin teléfono — lo tiene oculto en WhatsApp</span>'
: '—')
}</dd>
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${
!p.phone_number ? '—'
: (esBsuid(p.phone_number)
? '<i class="fab fa-whatsapp text-success"></i> Se le puede escribir por el chat, pero no llamar'
: `<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').classList.remove('open');
document.getElementById('detail-backdrop').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 => ({'<':'&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();
cargarStats();
</script>
<script src="assets/js/lab-sidebar.js"></script>
</body>
</html>