feat(recepcion): Tom Select para selección de exámenes con badge de consentimiento

Reemplaza la lista de checkboxes por Tom Select (sin jQuery):
- Búsqueda integrada mientras se escribe
- Exámenes agrupados por categoría (optgroup)
- Cada examen con consentimiento asociado muestra badge ⚠ Consentimiento
  en el dropdown y marcador visual en el tag seleccionado
- Solo entrega de muestras deshabilita el select automáticamente
- Remove_button plugin para quitar ítems individualmente
- Elimina filtrarExamenes / toggleCategoria / actualizarCatChk (ya innecesarios)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-04 15:54:36 -05:00
co-authored by Claude Sonnet 4.6
parent c566c1f33f
commit 56b8fc98df
+67 -93
View File
@@ -30,7 +30,13 @@ try {
$lugarMuestras = $lugaresDestino[0] ?? null;
$examenes = $pdo->query(
"SELECT id, codigo, nombre, categoria FROM exam_tipos WHERE activo = 1 ORDER BY categoria, nombre"
"SELECT et.id, et.codigo, et.nombre, et.categoria,
IF(COUNT(etc.formulario_id) > 0, 1, 0) AS tiene_consentimiento
FROM exam_tipos et
LEFT JOIN exam_tipo_consentimientos etc ON etc.exam_tipo_id = et.id
WHERE et.activo = 1
GROUP BY et.id
ORDER BY et.categoria, et.nombre"
)->fetchAll(PDO::FETCH_ASSOC);
// Agrupar exámenes por categoría
@@ -79,6 +85,8 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="<?= BASE_URL ?>assets/css/styles.css?v=12" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.bootstrap5.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/js/tom-select.complete.min.js"></script>
<style>
/* ── Layout de dos columnas ── */
.rec-layout {
@@ -172,16 +180,22 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
display: flex; align-items: center; justify-content: space-between;
}
/* ── Checkboxes de exámenes ── */
.exam-group-title { font-size: .75rem; color: #94a3b8;
text-transform: uppercase; letter-spacing: .08em; margin: .6rem 0 .3rem; }
.exam-cat-label { display: flex; align-items: center; gap: .35rem; cursor: pointer; }
.exam-cat-label input[type=checkbox] { cursor: pointer; }
.exam-check-item { display: flex; align-items: center; gap: .5rem;
padding: .3rem .4rem; border-radius: 6px; cursor: pointer;
transition: background .1s; font-size: .88rem; }
.exam-check-item:hover { background: #f1f5f9; }
.exam-check-item input[type=checkbox] { cursor: pointer; }
/* ── Tom Select — exámenes ── */
#wrap-examenes .ts-wrapper { font-size: .88rem; }
#wrap-examenes .ts-control { min-height: 44px; border-radius: 8px; }
#wrap-examenes .ts-dropdown { font-size: .88rem; }
#wrap-examenes .ts-dropdown .optgroup-header {
font-size: .7rem; font-weight: 700; color: #94a3b8;
text-transform: uppercase; letter-spacing: .08em;
}
.ts-consent-badge {
font-size: .65rem; font-weight: 700; border-radius: 4px;
padding: 1px 5px; margin-left: 5px;
background: #fef3c7; color: #92400e; border: 1px solid #fde68a;
white-space: nowrap;
}
.ts-consent-dot { margin-left: 3px; color: #d97706; font-size: .75rem; }
#wrap-examenes.disabled { opacity: .4; pointer-events: none; }
/* ── Consentimientos ── */
.consent-item {
@@ -322,7 +336,6 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
.btn-accion-sec.danger:hover { background: #fef2f2; }
/* ── Mejoras de touch target (tablet) ── */
.exam-check-item { min-height: 40px; }
.consent-item { min-height: 46px; }
.pac-resultado { min-height: 50px; }
.cola-card { min-height: 62px; }
@@ -574,43 +587,23 @@ document.addEventListener('DOMContentLoaded', function() {
<i class="fas fa-vial me-1"></i>Solo entrega de muestras
</label>
</div>
<div class="input-group input-group-sm mb-2">
<span class="input-group-text text-muted" style="background:#f8fafc"><i class="fas fa-search"></i></span>
<input type="text" id="inp-filtrar-exam" class="form-control"
placeholder="Filtrar exámenes…" autocomplete="off"
oninput="filtrarExamenes(this.value)">
<button class="btn btn-outline-secondary" type="button"
onclick="document.getElementById('inp-filtrar-exam').value='';filtrarExamenes('')"
title="Limpiar filtro">
<i class="fas fa-times"></i>
</button>
<div id="wrap-examenes">
<select id="sel-examenes" multiple placeholder="Buscar y seleccionar exámenes…">
<?php foreach ($examenesAgrupados as $cat => $items): ?>
<optgroup label="<?= htmlspecialchars($cat) ?>">
<?php foreach ($items as $ex): ?>
<option value="<?= (int)$ex['id'] ?>"
data-consent="<?= (int)$ex['tiene_consentimiento'] ?>">
<?= htmlspecialchars($ex['codigo']) ?> — <?= htmlspecialchars($ex['nombre']) ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
</select>
</div>
<div id="lista-examenes">
<?php foreach ($examenesAgrupados as $cat => $items): ?>
<div class="exam-group-title">
<label class="exam-cat-label">
<input type="checkbox" class="exam-cat-chk"
data-cat="<?= htmlspecialchars($cat) ?>"
onchange="toggleCategoria(this)">
<?= htmlspecialchars($cat) ?>
</label>
</div>
<?php foreach ($items as $ex): ?>
<label class="exam-check-item">
<input type="checkbox" class="exam-chk"
value="<?= (int)$ex['id'] ?>"
data-nombre="<?= htmlspecialchars($ex['nombre']) ?>"
data-cat="<?= htmlspecialchars($cat) ?>"
onchange="actualizarCatChk('<?= htmlspecialchars($cat, ENT_QUOTES) ?>')">
<span><strong><?= htmlspecialchars($ex['codigo']) ?></strong>
— <?= htmlspecialchars($ex['nombre']) ?></span>
</label>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
<div class="mt-2 d-flex gap-2 flex-wrap">
<button class="btn btn-outline-secondary btn-sm" onclick="toggleTodosExamenes(false)">
Limpiar
<div class="mt-2">
<button class="btn btn-outline-secondary btn-sm" onclick="examTS && examTS.clear()">
<i class="fas fa-times me-1"></i>Limpiar selección
</button>
</div>
</div>
@@ -905,14 +898,33 @@ const DESK_NOMBRE = '<?= addslashes($deskNombre ?? 'Recepción') ?>';
// ── Arranque ──────────────────────────────────────────────────
let pollingConsentimientosId = null;
let examTS;
document.addEventListener('DOMContentLoaded', () => {
examTS = new TomSelect('#sel-examenes', {
plugins: ['remove_button'],
maxOptions: null,
render: {
option(data, escape) {
const consent = data.element?.dataset.consent === '1';
return `<div class="d-flex align-items-center justify-content-between">
<span>${escape(data.text)}</span>
${consent ? '<span class="ts-consent-badge"><i class="fas fa-file-signature me-1"></i>Consentimiento</span>' : ''}
</div>`;
},
item(data, escape) {
const consent = data.element?.dataset.consent === '1';
return `<div>${escape(data.text)}${consent ? '<span class="ts-consent-dot" title="Requiere consentimiento"> ⚠</span>' : ''}</div>`;
}
}
});
cargarCola();
pollingColaId = setInterval(cargarCola, 3000);
document.getElementById('inp-buscar-pac')
.addEventListener('keydown', e => { if (e.key === 'Enter') buscarPaciente(); });
document.getElementById('sel-lugar')
.addEventListener('change', onLugarChange);
// Refrescar consentimientos al volver a la pestaña (firma en otra pestaña)
document.addEventListener('visibilitychange', () => {
if (!document.hidden && turnoActivo) refrescarConsentimientosLugar();
});
@@ -1443,7 +1455,7 @@ async function guardarSolicitud() {
return;
}
}
const examIds = Array.from(document.querySelectorAll('.exam-chk:checked')).map(c => parseInt(c.value));
const examIds = examTS ? examTS.getValue().map(v => parseInt(v)) : [];
if (!soloMuestras && !examIds.length) { mostrarError('Seleccione al menos un examen.'); return; }
const btn = document.getElementById('btn-guardar');
@@ -1864,16 +1876,15 @@ function escJs(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'
function toggleSoloMuestras() {
const solo = document.getElementById('chk-solo-muestras').checked;
document.querySelectorAll('.exam-chk').forEach(c => { c.checked = false; c.disabled = solo; });
document.getElementById('lista-examenes').style.opacity = solo ? '0.4' : '';
const wrap = document.getElementById('wrap-examenes');
if (examTS) { examTS.clear(); solo ? examTS.disable() : examTS.enable(); }
wrap.classList.toggle('disabled', solo);
}
function resetCheckboxes() {
document.getElementById('chk-solo-muestras').checked = false;
document.querySelectorAll('.exam-chk').forEach(c => { c.checked = false; c.disabled = false; });
document.getElementById('lista-examenes').style.opacity = '';
const fi = document.getElementById('inp-filtrar-exam');
if (fi) { fi.value = ''; filtrarExamenes(''); }
if (examTS) { examTS.clear(); examTS.enable(); }
document.getElementById('wrap-examenes').classList.remove('disabled');
document.getElementById('sel-pago').value = '';
document.getElementById('inp-total').value = '';
document.getElementById('inp-obs').value = '';
@@ -1881,43 +1892,6 @@ function resetCheckboxes() {
resetMedico();
}
function filtrarExamenes(q) {
const query = q.toLowerCase().trim();
document.querySelectorAll('#lista-examenes .exam-check-item').forEach(item => {
item.style.display = (!query || item.textContent.toLowerCase().includes(query)) ? '' : 'none';
});
document.querySelectorAll('#lista-examenes .exam-group-title').forEach(title => {
let next = title.nextElementSibling;
let anyVisible = false;
while (next && !next.classList.contains('exam-group-title')) {
if (next.style.display !== 'none') anyVisible = true;
next = next.nextElementSibling;
}
title.style.display = anyVisible ? '' : 'none';
});
}
function toggleTodosExamenes(val) {
document.querySelectorAll('.exam-chk').forEach(c => c.checked = val);
document.querySelectorAll('.exam-cat-chk').forEach(c => c.checked = val);
}
function toggleCategoria(chk) {
const cat = chk.dataset.cat;
document.querySelectorAll(`.exam-chk[data-cat="${CSS.escape(cat)}"]`).forEach(c => {
if (c.closest('.exam-check-item')?.style.display !== 'none') c.checked = chk.checked;
});
}
function actualizarCatChk(cat) {
const catChk = document.querySelector(`.exam-cat-chk[data-cat="${CSS.escape(cat)}"]`);
if (!catChk) return;
const items = Array.from(document.querySelectorAll(`.exam-chk[data-cat="${CSS.escape(cat)}"]`))
.filter(c => c.closest('.exam-check-item')?.style.display !== 'none');
const checked = items.filter(c => c.checked).length;
catChk.checked = checked === items.length && items.length > 0;
catChk.indeterminate = checked > 0 && checked < items.length;
}
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str)));