Files
whatsapp/lab_ordenes.php
T
Lizandro Guarnizo 8956de8eaa fix: ruta doble en local_file al mostrar imagen de orden médica
La BD guarda local_file con 'uploads/media/...' incluido.
El código le añadía otro 'uploads/media/' dando ruta doble.

- lab_ordenes.php: helper _resolveImg() detecta si el path
  ya empieza con 'uploads/' y no añade el prefijo de nuevo
- enfermero_portal.php: nueva función resImg() con misma lógica,
  usada en _cardHTML() al renderizar la imagen de la orden
2026-03-12 22:23:15 -05:00

456 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 Órdenes Médicas — Módulo Administrativo de Laboratorio
*/
require_once 'config/config.php';
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';
$ordenIdParam = (int)($_GET['id'] ?? 0);
$estadoParam = $_GET['estado'] ?? '';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Órdenes Médicas — 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>
.orden-row { cursor: pointer; }
.flujo-estado { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.flujo-paso { padding: 3px 10px; border-radius: 20px; font-size: .75rem; border: 1px solid #dee2e6; color: #6c757d; }
.flujo-paso.activo { background: var(--bs-primary); color: #fff; border-color: var(--bs-primary); font-weight: 600; }
.flujo-paso.completado { background: #d1e7dd; color: #0a3622; border-color: #a3cfbb; }
.img-orden { max-width: 100%; border-radius: 8px; border: 1px solid #dee2e6; cursor: zoom-in; }
.historial-item { font-size: .8rem; padding: 6px 0; border-bottom: 1px solid #f0f0f0; }
.historial-item:last-child { border-bottom: none; }
</style>
</head>
<body>
<nav class="sidebar">
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
<ul class="sidebar-menu">
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
<li><a href="lab_ordenes.php" class="nav-link active"><i class="fas fa-file-medical"></i> Órdenes</a></li>
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
<li><a href="lab_enfermeras.php" class="nav-link"><i class="fas fa-user-nurse"></i> Enfermeras</a></li>
<li><a href="lab_formularios.php" class="nav-link"><i class="fas fa-wpforms"></i> Formularios</a></li>
<li><a href="lab_reportes.php" class="nav-link"><i class="fas fa-chart-bar"></i> Reportes</a></li>
<li><a href="lab_configuracion.php" class="nav-link"><i class="fas fa-sliders-h"></i> Configuración</a></li>
<li><a href="lab_usuarios.php" class="nav-link"><i class="fas fa-users-cog"></i> Usuarios</a></li>
<li class="sidebar-divider"></li>
<li><a href="index.php" class="nav-link"><i class="fas fa-arrow-left"></i> Volver al Bot</a></li>
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
</ul>
</nav>
<main class="main-content">
<header class="content-header d-flex align-items-center justify-content-between">
<div>
<h1><i class="fas fa-file-medical text-primary"></i> Órdenes Médicas</h1>
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
</div>
</header>
<div class="container-fluid py-3">
<!-- Filtros + contadores -->
<div class="row g-2 mb-3" id="contadores-row">
<?php foreach(['pendiente'=>['warning','clock'],'en_revision'=>['info','magnifying-glass'],'autorizada'=>['success','check-circle'],'rechazada'=>['danger','times-circle'],'en_domicilio'=>['primary','house-medical'],'completada'=>['secondary','flag-checkered']] as $e=>[$color,$icon]): ?>
<div class="col-6 col-md-2">
<button class="btn btn-outline-<?=$color?> btn-sm w-100 filtro-estado <?= $estadoParam===$e?'active':'' ?>"
data-estado="<?=$e?>" onclick="filtrarEstado('<?=$e?>')">
<i class="fas fa-<?=$icon?> me-1"></i> <?=ucfirst(str_replace('_',' ',$e))?><br>
<strong id="cnt-<?=$e?>">—</strong>
</button>
</div>
<?php endforeach; ?>
</div>
<!-- Barra de búsqueda -->
<div class="row g-2 mb-3">
<div class="col-md-4">
<input type="text" id="buscador" class="form-control form-control-sm" placeholder="Buscar paciente…" oninput="debounce(cargarLista,400)()">
</div>
<div class="col-md-3">
<input type="date" id="filtro-desde" class="form-control form-control-sm" onchange="cargarLista()">
</div>
<div class="col-md-3">
<input type="date" id="filtro-hasta" class="form-control form-control-sm" onchange="cargarLista()">
</div>
<div class="col-md-2">
<button class="btn btn-sm btn-secondary w-100" onclick="limpiarFiltros()">
<i class="fas fa-eraser me-1"></i> Limpiar
</button>
</div>
</div>
<div class="row g-3">
<!-- Tabla de órdenes -->
<div class="col-lg-5">
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>#</th><th>Paciente</th><th>Fecha</th><th>Estado</th></tr>
</thead>
<tbody id="tabla-body">
<tr><td colspan="4" 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 de orden -->
<div class="col-lg-7" id="detail-panel">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 pb-0">
<div class="d-flex align-items-center justify-content-between">
<h6 class="mb-0 fw-semibold">Detalle de Orden <span id="detail-id" class="text-muted"></span></h6>
<button class="btn-close" onclick="cerrarDetalle()"></button>
</div>
<!-- Flujo de estados -->
<div class="flujo-estado mt-2" id="flujo-row"></div>
</div>
<div class="card-body" id="detail-body">
<div class="text-center py-5 text-muted">
<i class="fas fa-arrow-left me-2"></i>Selecciona una orden de la lista
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Modal Autorizar / Rechazar -->
<div class="modal fade" id="modalAccion" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modal-accion-titulo">Acción</h5>
<button class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="accion-id">
<input type="hidden" id="accion-tipo">
<div class="mb-3">
<label class="form-label" id="accion-label">Comentario</label>
<textarea class="form-control" id="accion-comentario" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-primary" id="btn-confirmar-accion" onclick="ejecutarAccion()">Confirmar</button>
</div>
</div>
</div>
</div>
<!-- Modal visor de imagen -->
<div class="modal fade" id="modalImagen" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content bg-dark">
<div class="modal-header border-0 py-2">
<button class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center p-0">
<img id="img-visor" src="" class="img-fluid" style="max-height:80vh;object-fit:contain">
</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;
let filtroEstado = '<?= htmlspecialchars($estadoParam) ?>';
let ordenSeleccionada = <?= $ordenIdParam ?: 'null' ?>;
const modalAccion = new bootstrap.Modal('#modalAccion');
const modalImg = new bootstrap.Modal('#modalImagen');
const FLUJO = ['pendiente','en_revision','autorizada','rechazada','en_domicilio','completada'];
const COLOR = { pendiente:'warning', en_revision:'info', autorizada:'success', rechazada:'danger', en_domicilio:'primary', completada:'secondary' };
// ── Lista ──────────────────────────────────────────────────────────────────
async function cargarLista(pag = 1) {
paginaActual = pag;
const params = new URLSearchParams({
page: pag, limit: 20,
busqueda: document.getElementById('buscador').value.trim(),
desde: document.getElementById('filtro-desde').value,
hasta: document.getElementById('filtro-hasta').value,
estado: filtroEstado,
});
const r = await fetch(`api/lab/get_ordenes.php?${params}`);
const d = await r.json();
// Actualizar contadores
if (d.contadores) {
Object.entries(d.contadores).forEach(([e, n]) => {
const el = document.getElementById(`cnt-${e}`);
if (el) el.textContent = n;
});
}
const tbody = document.getElementById('tabla-body');
if (!d.data?.length) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Sin resultados</td></tr>';
document.getElementById('paginacion').innerHTML = '';
return;
}
tbody.innerHTML = d.data.map(o => `
<tr class="orden-row ${ordenSeleccionada===o.id?'table-active':''}"
onclick="verOrden(${o.id})" id="row-${o.id}">
<td class="text-muted">#${o.id}</td>
<td>
<div class="fw-semibold">${esc(o.paciente_nombre)}</div>
<small class="text-muted">${esc(o.numero_documento||'')}</small>
</td>
<td>${formatFecha(o.created_at)}</td>
<td><span class="badge bg-${COLOR[o.estado]||'secondary'}">${esc(o.estado)}</span></td>
</tr>
`).join('');
renderPaginacion(d, pag);
if (ordenSeleccionada) verOrden(ordenSeleccionada);
}
function renderPaginacion(d, pag) {
const el = document.getElementById('paginacion');
if (d.paginas <= 1) { el.innerHTML = `<small class="text-muted">${d.total} orden(es)</small>`; return; }
el.innerHTML =
`<small class="text-muted">${((pag-1)*20)+1}${Math.min(pag*20,d.total)} de ${d.total}</small>
<div class="btn-group btn-group-sm">
${pag>1?`<button class="btn btn-outline-secondary" onclick="cargarLista(${pag-1})"><i class="fas fa-chevron-left"></i></button>`:''}
<button class="btn btn-secondary disabled">${pag}/${d.paginas}</button>
${pag<d.paginas?`<button class="btn btn-outline-secondary" onclick="cargarLista(${pag+1})"><i class="fas fa-chevron-right"></i></button>`:''}
</div>`;
}
// ── Detalle ────────────────────────────────────────────────────────────────
async function verOrden(id) {
ordenSeleccionada = id;
document.querySelectorAll('.orden-row').forEach(r => r.classList.remove('table-active'));
const rowEl = document.getElementById(`row-${id}`);
if (rowEl) rowEl.classList.add('table-active');
document.getElementById('detail-body').innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin"></i></div>';
document.getElementById('detail-id').textContent = `#${id}`;
const r = await fetch(`api/lab/get_ordenes.php?id=${id}&no_counts=1`);
const d = await r.json();
const o = d.orden;
if (!o) return;
// Flujo
const idx = FLUJO.indexOf(o.estado);
document.getElementById('flujo-row').innerHTML = FLUJO.map((e, i) => {
let cls = 'flujo-paso';
if (i < idx) cls += ' completado';
if (e === o.estado) cls += ' activo';
return `<span class="${cls}">${esc(e.replace('_',' '))}</span>${i < FLUJO.length-1 ? '<i class="fas fa-chevron-right text-muted" style="font-size:.65rem"></i>' : ''}`;
}).join('');
// Imagen de la orden
// local_file puede venir con la ruta ya incluida (ej. "uploads/media/2026/02/x.jpg")
// o solo con el nombre de archivo — normalizamos para evitar ruta duplicada
function _resolveImg(f) {
if (!f) return null;
if (/^https?:\/\//i.test(f)) return f;
if (f.startsWith('uploads/')) return f;
return `uploads/media/${f}`;
}
const imgSrc = _resolveImg(o.local_file) || _resolveImg(o.conv_local_file) || null;
// Botones de acción según estado
const botonesAccion = renderBotones(o);
document.getElementById('detail-body').innerHTML = `
<div class="row g-3">
<div class="col-md-6">
${imgSrc ? `
<div class="mb-3">
<label class="form-label small text-muted text-uppercase">Imagen de la orden</label>
<img src="${imgSrc}" class="img-orden" onclick="verImagen('${imgSrc}')" title="Ver imagen completa">
</div>` : '<div class="alert alert-light small text-muted"><i class="fas fa-image me-2"></i>Sin imagen adjunta</div>'}
<dl class="row small">
<dt class="col-5 text-muted">Paciente</dt>
<dd class="col-7 fw-semibold"><a href="lab_pacientes.php" onclick="event.preventDefault()">${esc(o.paciente_nombre)}</a></dd>
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${esc(o.numero_documento||'—')}</dd>
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(o.paciente_telefono||'—')}</dd>
<dt class="col-5 text-muted">EPS</dt><dd class="col-7">${esc(o.paciente_eps||'—')}</dd>
<dt class="col-5 text-muted">Médico</dt><dd class="col-7">${esc(o.medico_nombre||'—')}</dd>
<dt class="col-5 text-muted">Reg. médico</dt><dd class="col-7">${esc(o.medico_registro||'—')}</dd>
<dt class="col-5 text-muted">Fecha orden</dt><dd class="col-7">${esc(o.fecha_orden||'—')}</dd>
<dt class="col-5 text-muted">Ayuno</dt>
<dd class="col-7">${o.requiere_ayuno ? `<span class="badge bg-warning text-dark">${o.horas_ayuno||'?'}h ayuno</span>` : 'No requiere'}</dd>
</dl>
</div>
<div class="col-md-6">
${o.examenes_solicitados ? `
<div class="mb-3">
<label class="form-label small text-muted text-uppercase">Exámenes solicitados</label>
<div class="bg-light p-2 rounded small">${esc(o.examenes_solicitados).replace(/\n/g,'<br>')}</div>
</div>` : ''}
${o.indicaciones ? `
<div class="mb-3">
<label class="form-label small text-muted text-uppercase">Indicaciones</label>
<div class="bg-light p-2 rounded small">${esc(o.indicaciones).replace(/\n/g,'<br>')}</div>
</div>` : ''}
${o.comentario_revision ? `
<div class="mb-3">
<label class="form-label small text-muted text-uppercase">Comentario revisión</label>
<div class="alert alert-${o.estado==='rechazada'?'danger':'success'} py-2 small mb-0">${esc(o.comentario_revision)}</div>
</div>` : ''}
<!-- Botones de acción -->
<div class="d-flex gap-2 flex-wrap mb-3">${botonesAccion}</div>
<!-- Historial -->
<label class="form-label small text-muted text-uppercase">Historial de cambios</label>
<div class="border rounded p-2">
${(o.historial||[]).map(h => `
<div class="historial-item">
<span class="fw-semibold">${esc(h.admin_nombre||'Sistema')}</span>
&mdash; <span class="badge bg-${COLOR[h.accion]||'secondary'} text-uppercase">${esc(h.accion)}</span>
${h.comentario ? ` &mdash; <em class="text-muted">${esc(h.comentario)}</em>` : ''}
<br><span class="text-muted">${formatFecha(h.created_at)}</span>
</div>`).join('') || '<p class="text-muted small mb-0">Sin historial</p>'}
</div>
${o.estado === 'autorizada' ? `
<div class="mt-3">
<a href="lab_domicilios.php?orden_id=${o.id}&paciente_id=${o.paciente_id}" class="btn btn-success w-100 btn-sm">
<i class="fas fa-house-medical me-1"></i> Crear domicilio para esta orden
</a>
</div>` : ''}
</div>
</div>
`;
}
function renderBotones(o) {
const btns = [];
if (o.estado === 'pendiente')
btns.push(`<button class="btn btn-sm btn-info" onclick="accion(${o.id},'en_revision')"><i class="fas fa-magnifying-glass me-1"></i>Iniciar revisión</button>`);
if (o.estado === 'en_revision') {
btns.push(`<button class="btn btn-sm btn-success" onclick="accion(${o.id},'autorizada')"><i class="fas fa-check me-1"></i>Autorizar</button>`);
btns.push(`<button class="btn btn-sm btn-danger" onclick="accion(${o.id},'rechazada')"><i class="fas fa-times me-1"></i>Rechazar</button>`);
}
if (o.estado === 'en_domicilio')
btns.push(`<button class="btn btn-sm btn-secondary" onclick="accion(${o.id},'completada')"><i class="fas fa-flag-checkered me-1"></i>Completar</button>`);
return btns.join('');
}
function cerrarDetalle() {
ordenSeleccionada = null;
document.querySelectorAll('.orden-row').forEach(r => r.classList.remove('table-active'));
document.getElementById('detail-body').innerHTML = '<div class="text-center py-5 text-muted"><i class="fas fa-arrow-left me-2"></i>Selecciona una orden</div>';
document.getElementById('flujo-row').innerHTML = '';
document.getElementById('detail-id').textContent = '';
}
// ── Acciones ───────────────────────────────────────────────────────────────
function accion(id, tipo) {
document.getElementById('accion-id').value = id;
document.getElementById('accion-tipo').value = tipo;
document.getElementById('accion-comentario').value = '';
const titulos = {
en_revision: 'Iniciar revisión',
autorizada: 'Autorizar orden',
rechazada: 'Rechazar orden',
completada: 'Completar orden',
};
document.getElementById('modal-accion-titulo').textContent = titulos[tipo] || 'Confirmar acción';
document.getElementById('accion-label').textContent = tipo==='rechazada' ? 'Motivo del rechazo *' : 'Comentario (opcional)';
document.getElementById('accion-comentario').required = tipo === 'rechazada';
document.getElementById('btn-confirmar-accion').className = `btn btn-${tipo==='rechazada'?'danger':tipo==='autorizada'?'success':'primary'}`;
modalAccion.show();
}
async function ejecutarAccion() {
const id = parseInt(document.getElementById('accion-id').value);
const tipo = document.getElementById('accion-tipo').value;
const com = document.getElementById('accion-comentario').value.trim();
if (tipo === 'rechazada' && !com) {
document.getElementById('accion-comentario').focus();
return;
}
const r = await fetch('api/lab/autorizar_orden.php', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ id, accion: tipo, comentario: com }),
});
const d = await r.json();
modalAccion.hide();
if (d.success) {
mostrarToast(d.message, 'success');
await cargarLista(paginaActual);
if (ordenSeleccionada) verOrden(ordenSeleccionada);
} else {
mostrarToast(d.error || 'Error', 'danger');
}
}
function filtrarEstado(e) {
filtroEstado = (filtroEstado === e) ? '' : e;
document.querySelectorAll('.filtro-estado').forEach(b => {
b.classList.toggle('active', b.dataset.estado === filtroEstado);
});
cargarLista(1);
}
function limpiarFiltros() {
filtroEstado = '';
document.getElementById('buscador').value = '';
document.getElementById('filtro-desde').value = '';
document.getElementById('filtro-hasta').value = '';
document.querySelectorAll('.filtro-estado').forEach(b => b.classList.remove('active'));
cargarLista(1);
}
function verImagen(src) {
document.getElementById('img-visor').src = src;
modalImg.show();
}
// ── Utils ──────────────────────────────────────────────────────────────────
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
const formatFecha = s => s ? new Date(s).toLocaleString('es-CO', {day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}) : '—';
function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(()=>fn(...a),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);
}
cargarLista();
</script>
</body>
</html>