feat: módulo lab_examenes — CRUD catálogo de exámenes, items y tarifas

- Nuevo módulo lab_examenes con lista paginada, buscador y filtro por categoría
- Vista de detalle/edición con campos completos de exam_tipos (CUPS, protocolo,
  tipo muestra, nivel, abreviatura, seremite, ayuno, instrucciones)
- Sub-tabla inline de items de resultado (lab_items_resultado) con edición por fila
- Sub-tabla de precios por tarifa (lab_tarifas) con modal de edición
- APIs: list, get, save, save_item, save_tarifa, get_tarifas
- Script ETL Firebird→MySQL (scripts/etl_examenes.php) para la migración inicial

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-07 23:06:54 -05:00
co-authored by Claude Sonnet 4.6
parent 229977785b
commit fb0e77523c
11 changed files with 1285 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
<?php
require_once APP_ROOT . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
Layout::open('Catálogo de Exámenes', 'fas fa-flask');
$pdo = Database::getInstance()->getConnection();
$categorias = $pdo->query(
"SELECT DISTINCT categoria FROM exam_tipos WHERE categoria IS NOT NULL ORDER BY categoria"
)->fetchAll(PDO::FETCH_COLUMN);
$API = BASE_URL . 'modules/lab_examenes/api/';
$DETAIL = BASE_URL . 'erp.php?m=lab_examenes&v=examen&id=';
?>
<style>
body { background:#f1f5f9 }
.page-header { background:#fff; border-bottom:1px solid #e2e8f0; padding:14px 24px;
display:flex; align-items:center; gap:12px; flex-wrap:wrap }
.page-header h1 { font-size:1.18rem; font-weight:700; color:#1e293b; margin:0; flex:1 }
.content-wrap { max-width:1200px; margin:24px auto; padding:0 16px 48px }
.card-box { background:#fff; border:1px solid #e2e8f0; border-radius:12px; overflow:hidden }
.toolbar { padding:12px 16px; display:flex; gap:10px; align-items:center;
border-bottom:1px solid #e2e8f0; flex-wrap:wrap }
.toolbar input, .toolbar select { font-size:.85rem }
table { width:100%; border-collapse:collapse; font-size:.88rem }
thead th { background:#f8fafc; padding:9px 14px; font-size:.7rem; text-transform:uppercase;
letter-spacing:.07em; color:#64748b; border-bottom:1px solid #e2e8f0; white-space:nowrap }
tbody td { padding:9px 14px; border-bottom:1px solid #f1f5f9; color:#1e293b; vertical-align:middle }
tbody tr:last-child td { border-bottom:none }
tbody tr:hover td { background:#f8fafc }
.badge-cat { background:#eff6ff; color:#1d4ed8; border-radius:20px; padding:2px 9px;
font-size:.72rem; font-weight:600; white-space:nowrap }
.badge-remite { background:#dcfce7; color:#166534; border-radius:20px; padding:2px 9px; font-size:.72rem }
.badge-off { background:#f1f5f9; color:#94a3b8; border-radius:20px; padding:2px 9px; font-size:.72rem }
.pag-bar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.5rem;
padding:10px 16px; border-top:1px solid #e2e8f0; background:#f8fafc }
.pag-info { font-size:.8rem; color:#64748b }
.pag-btn { min-width:32px; height:32px; padding:0 8px; border:1px solid #e2e8f0; border-radius:7px;
background:#fff; font-size:.82rem; color:#374151; cursor:pointer;
display:inline-flex; align-items:center; justify-content:center }
.pag-btn:hover:not(:disabled) { background:#eff6ff; border-color:#bfdbfe; color:#1d4ed8 }
.pag-btn.active { background:#2563eb; color:#fff; border-color:#2563eb }
.pag-btn:disabled { opacity:.4; cursor:not-allowed }
.toggle-activo { cursor:pointer; font-size:1.05rem }
</style>
<div class="page-header">
<i class="fas fa-flask" style="font-size:1.3rem;color:#6366f1"></i>
<h1>Catálogo de Exámenes</h1>
<a href="<?= BASE_URL ?>erp.php?m=lab_examenes&v=examen" class="btn btn-primary btn-sm ms-auto">
<i class="fas fa-plus me-1"></i>Nuevo examen
</a>
</div>
<div class="content-wrap">
<div class="card-box">
<div class="toolbar">
<input type="search" id="inp-q" class="form-control form-control-sm"
placeholder="Buscar por nombre, código o CUPS…" style="max-width:280px" oninput="buscar()">
<select id="sel-cat" class="form-select form-select-sm" style="max-width:220px" onchange="cargar(1)">
<option value="">Todas las categorías</option>
<?php foreach ($categorias as $c): ?>
<option value="<?= htmlspecialchars($c) ?>"><?= htmlspecialchars($c) ?></option>
<?php endforeach; ?>
</select>
<span class="text-muted small ms-auto" id="lbl-total"></span>
</div>
<div style="overflow-x:auto">
<table>
<thead>
<tr>
<th>Código</th>
<th>Nombre</th>
<th>Categoría</th>
<th>CUPS</th>
<th>Protocolo</th>
<th>Se remite</th>
<th>Activo</th>
<th></th>
</tr>
</thead>
<tbody id="tbl-body">
<tr><td colspan="8" class="text-center text-muted py-4">Cargando…</td></tr>
</tbody>
</table>
</div>
<div class="pag-bar" id="pag-bar" style="display:none">
<span class="pag-info" id="pag-info"></span>
<div id="pag-ctrl" style="display:flex;gap:4px"></div>
</div>
</div>
</div>
<script>
const API = '<?= $API ?>';
const DETAIL = '<?= $DETAIL ?>';
let _page=1, _limit=50, _total=0, _q='', _timer;
document.addEventListener('DOMContentLoaded', () => cargar(1));
function buscar() {
clearTimeout(_timer);
_timer = setTimeout(() => cargar(1), 260);
}
async function cargar(page=_page) {
_page = page;
_q = document.getElementById('inp-q').value.trim();
const cat = document.getElementById('sel-cat').value;
const p = new URLSearchParams({page, limit:_limit});
if (_q) p.set('q', _q);
if (cat) p.set('categoria', cat);
const r = await fetch(API+'list.php?'+p).then(r=>r.json());
if (!r.ok) return;
_total = r.total;
renderTabla(r.data);
renderPag(Math.ceil(_total/_limit));
const d=(_page-1)*_limit+1, h=Math.min(_page*_limit,_total);
document.getElementById('lbl-total').textContent = _total ? `${d}${h} de ${_total}` : '0 exámenes';
}
function renderTabla(rows) {
const b = document.getElementById('tbl-body');
if (!rows.length) { b.innerHTML='<tr><td colspan="8" class="text-center text-muted py-4">Sin resultados.</td></tr>'; return; }
b.innerHTML = rows.map(r => `
<tr>
<td><code class="text-primary">${esc(r.codigo)}</code></td>
<td><a href="${DETAIL+r.id}" class="text-decoration-none fw-semibold text-dark">${esc(r.nombre)}</a></td>
<td>${r.categoria ? `<span class="badge-cat">${esc(r.categoria)}</span>` : '<span class="text-muted">—</span>'}</td>
<td><small class="text-muted">${esc(r.cups||'—')}</small></td>
<td><small class="text-muted">${esc(r.cod_protocolo||'—')}</small></td>
<td>${+r.seremite ? '<span class="badge-remite">Sí</span>' : '<span class="badge-off">No</span>'}</td>
<td><span class="toggle-activo" title="Clic para cambiar" onclick="toggleActivo(${r.id},${r.activo})">${+r.activo?'✅':'⬜'}</span></td>
<td><a href="${DETAIL+r.id}" class="btn btn-outline-primary btn-sm py-0 px-2"><i class="fas fa-pencil-alt"></i></a></td>
</tr>`).join('');
}
async function toggleActivo(id, actual) {
const r = await fetch(API+'save.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({id, activo: actual=='1'||actual===1 ? 0 : 1})
}).then(r=>r.json());
if (r.ok) cargar(_page);
}
function renderPag(total) {
const bar = document.getElementById('pag-bar');
const ctrl = document.getElementById('pag-ctrl');
if (total<=1) { bar.style.display='none'; return; }
bar.style.display='flex';
document.getElementById('pag-info').textContent = `Página ${_page} de ${total}`;
const pages = new Set([1,total,_page]);
for(let i=_page-2;i<=_page+2;i++) if(i>0&&i<=total) pages.add(i);
const sorted=[...pages].sort((a,b)=>a-b);
let h=`<button class="pag-btn" onclick="cargar(${_page-1})" ${_page===1?'disabled':''}><i class="fas fa-chevron-left"></i></button>`;
let prev=0;
for(const p of sorted){
if(prev&&p-prev>1) h+=`<span style="padding:0 4px;line-height:32px;font-size:.82rem;color:#94a3b8">…</span>`;
h+=`<button class="pag-btn ${p===_page?'active':''}" onclick="cargar(${p})">${p}</button>`;
prev=p;
}
h+=`<button class="pag-btn" onclick="cargar(${_page+1})" ${_page===total?'disabled':''}><i class="fas fa-chevron-right"></i></button>`;
ctrl.innerHTML=h;
}
function esc(s){if(!s)return'';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
</script>
<?php Layout::close(); ?>