feat(turnero): modal completo de paciente en recepción

- Reemplaza window.open a lab_pacientes.php por modal Bootstrap inline
- Modal incluye campos completos: datos personales, contacto (con verificador WA), ubicación y EPS
- Botón Ver/Editar en paciente vinculado carga datos desde API y abre modal en modo edición
- Botón Cambiar desvincula y enfoca el buscador
- Al crear: seleccionarPaciente() auto-vincula al turno y cierra modal
- Al editar: actualiza labels en pantalla sin perder el turno activo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-02 23:02:24 -05:00
co-authored by Claude Sonnet 4.6
parent 062f565ab5
commit 6adbcc3059
+370 -14
View File
@@ -419,9 +419,14 @@ document.addEventListener('DOMContentLoaded', function() {
<div class="text-muted small" id="lbl-pac-doc">—</div>
<div class="text-muted small" id="lbl-pac-cel"></div>
</div>
<button class="btn btn-link btn-sm text-secondary p-0" onclick="desvincularPaciente()">
<i class="fas fa-times"></i>
</button>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm" onclick="abrirModalPaciente(pacienteActivo?.id)" title="Ver / Editar paciente">
<i class="fas fa-pen fa-xs"></i>
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="cambiarPaciente()" title="Buscar otro paciente">
<i class="fas fa-exchange-alt fa-xs"></i>
</button>
</div>
</div>
<!-- Historial colapsable del paciente -->
@@ -566,6 +571,165 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
<!-- ═══ Modal Paciente (crear / ver / editar) ════════════════════════════ -->
<div class="modal fade" id="modalPacienteRec" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg overflow-hidden" style="border-radius:1rem">
<!-- Header con avatar -->
<div id="mpac-header"
style="background:linear-gradient(135deg,#1043a0 0%,#1565c0 100%);
color:#fff;padding:1.3rem 1.5rem 1rem;
display:flex;align-items:center;gap:.9rem">
<div id="mpac-avatar"
style="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">?</div>
<div class="flex-grow-1">
<div style="font-size:1rem;font-weight:700" id="mpac-titulo">Nuevo Paciente</div>
<div style="font-size:.75rem;opacity:.75" id="mpac-subtitulo">Completa los datos del paciente</div>
</div>
<button type="button" class="btn-close btn-close-white ms-auto" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body py-3 px-4">
<form id="form-paciente-rec" autocomplete="off">
<input type="hidden" id="mpac-id">
<!-- Datos personales -->
<div class="ficha-section mb-3">
<h6><i class="fas fa-id-card me-1"></i>Datos personales</h6>
<div class="row g-2">
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-nombre"
required placeholder="Nombre completo"
oninput="mpacAvatar(this.value)">
<label>Nombre completo <span class="text-danger">*</span></label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<select class="form-select form-select-sm" id="mpac-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>
<label>Tipo documento</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-doc" placeholder="Número documento">
<label>Número documento</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="date" class="form-control form-control-sm" id="mpac-fnac" placeholder="Fecha nacimiento">
<label>Fecha nacimiento</label>
</div>
</div>
</div>
</div>
<!-- Contacto -->
<div class="ficha-section mb-3">
<h6><i class="fas fa-phone me-1"></i>Contacto</h6>
<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 input-group-sm">
<select id="mpac-tel-prefijo" class="form-select" style="max-width:82px">
<option value="57" selected>🇨🇴 57</option>
<option value="58">🇻🇪 58</option>
<option value="1">🇺🇸 1</option>
</select>
<input type="tel" class="form-control" id="mpac-tel"
placeholder="3001234567" onblur="mpacVerificarWA()">
</div>
<div id="mpac-wa-badge" style="font-size:.78rem;min-height:18px;margin-top:3px"></div>
<input type="hidden" id="mpac-user-id">
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="email" class="form-control form-control-sm" id="mpac-email" placeholder="Email">
<label>Email</label>
</div>
</div>
<div class="col-sm-3">
<div class="form-floating">
<select class="form-select form-select-sm" id="mpac-genero">
<option value="">—</option>
<option value="M">Masculino</option>
<option value="F">Femenino</option>
<option value="O">Otro</option>
</select>
<label>Género</label>
</div>
</div>
</div>
</div>
<!-- Ubicación y EPS -->
<div class="ficha-section mb-2">
<h6><i class="fas fa-map-marker-alt me-1"></i>Ubicación y EPS</h6>
<div class="row g-2">
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-eps" placeholder="EPS">
<label>EPS</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-ciudad" placeholder="Ciudad" value="Cúcuta">
<label>Ciudad</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-barrio" placeholder="Barrio">
<label>Barrio</label>
</div>
</div>
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-dir" placeholder="Dirección">
<label>Dirección</label>
</div>
</div>
<div class="col-12">
<div class="form-floating">
<textarea class="form-control form-control-sm" id="mpac-notas"
placeholder="Notas internas" style="height:68px"></textarea>
<label>Notas internas</label>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer" style="background:#f8faff;border-top:1px solid #e8edf5;padding:.8rem 1.25rem">
<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 btn-sm btn-primary px-4" id="mpac-btn-guardar" onclick="mpacGuardar()">
<i class="fas fa-save me-1"></i>Guardar paciente
</button>
</div>
</div>
</div>
</div>
<div class="rec-toast" id="rec-toast">
<span class="ico" id="toast-ico"><i class="fas fa-check-circle"></i></span>
<span id="toast-msg"></span>
@@ -902,8 +1066,8 @@ async function buscarPaciente() {
const lista = document.getElementById('lista-pacientes-res');
const datos = json.data || json.registros || [];
if (!datos.length) {
lista.innerHTML = `<div class="text-muted small mt-1">Sin resultados.
<a href="#" onclick="abrirNuevoPaciente('${escHtml(q)}');return false">Crear nuevo</a></div>`;
lista.innerHTML = `<div class="text-muted small mt-1">Sin resultados.
<a href="#" onclick="abrirModalPaciente(null,'${escHtml(q).replace(/'/g,"\\'")}');return false">Crear nuevo</a></div>`;
return;
}
lista.innerHTML = datos.map(p => `
@@ -959,15 +1123,9 @@ function desvincularPaciente() {
document.getElementById('inp-buscar-pac').value = '';
}
function abrirNuevoPaciente(nombre) {
const ventana = window.open('<?= BASE_URL ?>lab_pacientes.php?nuevo=1&nombre=' + encodeURIComponent(nombre), '_blank');
// Detectar cuando se cierra la ventana y refrescar la búsqueda
const chequeo = setInterval(() => {
if (ventana.closed) {
clearInterval(chequeo);
setTimeout(() => buscarPaciente(), 500); // Buscar nuevamente después de cerrar
}
}, 500);
function cambiarPaciente() {
desvincularPaciente();
setTimeout(() => document.getElementById('inp-buscar-pac').focus(), 80);
}
// ── Historial del paciente ──────────────────────────────────────
@@ -1648,6 +1806,204 @@ function mostrarLlamando(codigo) {
_iniciar();
})(DESK_ID, DESK_NOMBRE, 'recepcion', 'desk_id');
// ── Modal paciente (crear / ver / editar) ────────────────────
const _mpacModal = () => bootstrap.Modal.getOrCreateInstance(document.getElementById('modalPacienteRec'));
function mpacAvatar(nombre) {
const parts = nombre.trim().split(/\s+/).filter(Boolean);
document.getElementById('mpac-avatar').textContent = parts.length >= 2
? (parts[0][0] + parts[1][0]).toUpperCase()
: (parts[0]?.[0] || '?').toUpperCase();
}
async function abrirModalPaciente(id, nombrePrefill) {
// Reset form
document.getElementById('form-paciente-rec').reset();
document.getElementById('mpac-id').value = '';
document.getElementById('mpac-avatar').textContent = '?';
document.getElementById('mpac-wa-badge').innerHTML = '';
document.getElementById('mpac-user-id').value = '';
document.getElementById('mpac-ciudad').value = 'Cúcuta';
if (id) {
document.getElementById('mpac-titulo').textContent = 'Cargando…';
document.getElementById('mpac-subtitulo').textContent = '';
_mpacModal().show();
try {
const r = await fetch(`${API_PAC}?id=${id}`);
const d = await r.json();
const p = (d.data || d.registros || [])[0];
if (!p) { mostrarError('No se pudo cargar el paciente.'); return; }
mpacRellenar(p);
} catch (_) { mostrarError('Error al cargar paciente.'); }
} else {
document.getElementById('mpac-titulo').textContent = 'Nuevo Paciente';
document.getElementById('mpac-subtitulo').textContent = 'Completa los datos del paciente';
if (nombrePrefill) {
document.getElementById('mpac-nombre').value = nombrePrefill;
mpacAvatar(nombrePrefill);
}
_mpacModal().show();
setTimeout(() => document.getElementById('mpac-nombre').focus(), 300);
}
}
function mpacRellenar(p) {
document.getElementById('mpac-id').value = p.id;
document.getElementById('mpac-nombre').value = p.nombre_completo || '';
document.getElementById('mpac-doc').value = p.numero_documento || '';
document.getElementById('mpac-tipo-doc').value = p.tipo_documento || 'CC';
document.getElementById('mpac-fnac').value = (p.fecha_nacimiento || '').slice(0, 10);
document.getElementById('mpac-email').value = p.email || '';
document.getElementById('mpac-genero').value = p.genero || '';
document.getElementById('mpac-eps').value = p.eps || '';
document.getElementById('mpac-ciudad').value = p.ciudad || 'Cúcuta';
document.getElementById('mpac-barrio').value = p.barrio || '';
document.getElementById('mpac-dir').value = p.direccion || '';
document.getElementById('mpac-notas').value = p.notas_admin || '';
// Teléfono con prefijo
let tel = p.telefono || '';
const prefijos = ['57','58','1'];
let prefijo = '57';
for (const pf of prefijos) {
if (tel.startsWith(pf) && tel.length > pf.length) {
prefijo = pf; tel = tel.slice(pf.length); break;
}
}
document.getElementById('mpac-tel-prefijo').value = prefijo;
document.getElementById('mpac-tel').value = tel;
mpacAvatar(p.nombre_completo || '');
document.getElementById('mpac-titulo').textContent = 'Editar Paciente';
document.getElementById('mpac-subtitulo').textContent = p.nombre_completo || '';
}
async function mpacVerificarWA() {
const prefijo = document.getElementById('mpac-tel-prefijo').value || '57';
let tel = (document.getElementById('mpac-tel').value || '').replace(/[^0-9]/g, '');
if (tel.length === 10) tel = prefijo + tel;
const badge = document.getElementById('mpac-wa-badge');
if (!tel || tel.length < 10) { badge.innerHTML = ''; return; }
badge.innerHTML = '<span class="text-muted"><i class="fas fa-spinner fa-spin me-1"></i>Verificando…</span>';
try {
const r = await fetch(`<?= BASE_URL ?>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>';
document.getElementById('mpac-user-id').value = d.user_id;
} else {
badge.innerHTML = '<span class="text-muted"><i class="fas fa-times-circle me-1"></i>Sin WhatsApp registrado</span>';
document.getElementById('mpac-user-id').value = '';
}
} catch(_) { badge.innerHTML = ''; }
}
async function mpacGuardar() {
const nombre = document.getElementById('mpac-nombre').value.trim();
if (!nombre) { mostrarError('El nombre es obligatorio.'); return; }
if (!/^[\p{L}\s'\-\.]+$/u.test(nombre)) {
mostrarError('El nombre solo debe contener letras, tildes y espacios.'); return;
}
if (nombre.split(/\s+/).filter(Boolean).length < 2) {
mostrarError('Ingresa nombre y apellido (mínimo 2 palabras).'); return;
}
const tipoDoc = document.getElementById('mpac-tipo-doc').value;
const docVal = document.getElementById('mpac-doc').value.trim();
if (docVal && ['CC','TI','RC','CE'].includes(tipoDoc)) {
const digits = docVal.replace(/[^0-9]/g, '');
if (digits.length < 4 || digits.length > 12) {
mostrarError('El documento debe tener entre 4 y 12 dígitos.'); return;
}
}
const emailVal = document.getElementById('mpac-email').value.trim();
if (emailVal && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailVal)) {
mostrarError('El correo electrónico no es válido.'); return;
}
const prefijo = document.getElementById('mpac-tel-prefijo').value || '57';
let tel = document.getElementById('mpac-tel').value.replace(/[^0-9]/g, '');
if (tel.length === 10) tel = prefijo + tel;
if (tel && prefijo === '57') {
const sinPref = tel.replace(/^57/, '');
if (!/^3\d{9}$/.test(sinPref)) {
mostrarError('El celular colombiano debe comenzar por 3 y tener 10 dígitos.'); return;
}
}
const id = document.getElementById('mpac-id').value;
const datos = {
nombre_completo: nombre,
tipo_documento: tipoDoc,
numero_documento: docVal || null,
fecha_nacimiento: document.getElementById('mpac-fnac').value || null,
telefono: tel || null,
email: emailVal || null,
genero: document.getElementById('mpac-genero').value || null,
eps: document.getElementById('mpac-eps').value.trim() || null,
ciudad: document.getElementById('mpac-ciudad').value.trim() || null,
barrio: document.getElementById('mpac-barrio').value.trim() || null,
direccion: document.getElementById('mpac-dir').value.trim() || null,
notas_admin: document.getElementById('mpac-notas').value.trim() || null,
user_id: document.getElementById('mpac-user-id').value || null,
};
if (id) datos.id = parseInt(id);
const btn = document.getElementById('mpac-btn-guardar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
try {
const r = await fetch('<?= BASE_URL ?>api/lab/save_paciente.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
});
const d = await r.json();
if (!d.success && !d.ok) {
mostrarError(d.error || 'Error al guardar el paciente.');
return;
}
_mpacModal().hide();
mostrarToast(d.message || 'Paciente guardado.', 'success');
if (id) {
// Edición: actualizar datos en pantalla si es el paciente activo
if (pacienteActivo && pacienteActivo.id == id) {
pacienteActivo.nombre_completo = nombre;
pacienteActivo.numero_documento = docVal;
pacienteActivo.tipo_documento = tipoDoc;
pacienteActivo.telefono = tel;
document.getElementById('lbl-pac-nombre').textContent =
(pacienteActivo.full_name || nombre).trim();
document.getElementById('lbl-pac-doc').textContent =
tipoDoc + ' ' + (docVal || '');
document.getElementById('lbl-pac-cel').textContent = tel || '';
}
} else {
// Creación: vincular al turno automáticamente
const nuevoPac = {
id: d.id || d.data?.id,
nombre_completo: nombre,
full_name: nombre,
tipo_documento: tipoDoc,
numero_documento: docVal,
telefono: tel,
};
seleccionarPaciente(nuevoPac);
}
} catch (err) {
mostrarError('Error de red: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar paciente';
}
}
// ── Toast ────────────────────────────────────────────────────
let toastTimer = null;
function mostrarToast(msg, type = 'info', duration = 2500) {