Turnero: mejoras UI, historial detallado, kiosko scanner, firma por turno

- Kiosko: detector scanner (gap < 80ms), iconos 90px, fa-person-pregnant, ticket Arial
- Recepción: vincular_paciente.php para persistir paciente_id antes de firmar consentimiento
- Historial y dashboard: recepción desk, empleado recepción/muestras, exámenes y comentarios
- display_global: turno más pequeño, lugar-nombre más grande
- lab_pacientes: modal compacta con form-sm
- Configuración: icono embarazada fa-person-pregnant en prioridades
- ver_formulario_enviado: firma por campo (fix doble firma)
- docker-compose.local.yml: override BD producción para dev local

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-22 22:07:09 -05:00
co-authored by Claude Sonnet 4.6
parent ebbf4ac6cf
commit f363631b32
12 changed files with 285 additions and 98 deletions
+28 -2
View File
@@ -124,13 +124,20 @@ $stmtTurnos = $pdo->prepare(
t.creado_at, t.inicio_recepcion_at, t.fin_recepcion_at,
t.inicio_lugar_at, t.fin_lugar_at,
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color,
l.nombre AS lugar_nombre,
l.nombre AS lugar_nombre,
s.nombre_completo AS paciente_bd,
rd.nombre AS recepcion_desk_nombre,
ur.full_name AS atendido_recepcion_nombre,
ul.full_name AS atendido_lugar_nombre,
ts.observaciones AS notas,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_lugares rd ON rd.id = t.recepcion_desk_id
LEFT JOIN admin_users ur ON ur.id = t.atendido_recepcion_por
LEFT JOIN admin_users ul ON ul.id = t.atendido_lugar_por
LEFT JOIN turnero_solicitudes ts ON ts.turno_id = t.id
LEFT JOIN lab_pacientes s ON s.id = ts.paciente_id
WHERE t.sesion_id = ?
@@ -139,6 +146,25 @@ $stmtTurnos = $pdo->prepare(
$stmtTurnos->execute([$sesionId]);
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
// Enriquecer con exámenes y comentarios
foreach ($turnos as &$turno) {
$stmtEx = $pdo->prepare(
"SELECT DISTINCT et.nombre FROM turnero_examen_items tei
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
WHERE tei.solicitud_id = (SELECT id FROM turnero_solicitudes WHERE turno_id = ? LIMIT 1)"
);
$stmtEx->execute([$turno['id']]);
$turno['examenes'] = $stmtEx->fetchAll(PDO::FETCH_ASSOC);
$stmtCom = $pdo->prepare(
"SELECT usuario_nombre, comentario, tipo, creado_at
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at ASC"
);
$stmtCom->execute([$turno['id']]);
$turno['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
}
unset($turno);
// ── 6. Consentimientos del día ────────────────────────────────
$stmtcons = $pdo->prepare(
"SELECT tc.estado, COUNT(*) AS cnt
+6
View File
@@ -108,6 +108,9 @@ $sqlTurnos = "
p.color AS prioridad_color,
l.nombre AS lugar_nombre,
s2.nombre_completo AS paciente_bd,
rd.nombre AS recepcion_desk_nombre,
ur.full_name AS atendido_recepcion_nombre,
ul.full_name AS atendido_lugar_nombre,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at,
COALESCE(t.inicio_recepcion_at, t.fin_recepcion_at)) / 60.0, 1)
AS espera_min,
@@ -118,6 +121,9 @@ $sqlTurnos = "
JOIN turnero_sesiones s ON s.id = t.sesion_id
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_lugares rd ON rd.id = t.recepcion_desk_id
LEFT JOIN admin_users ur ON ur.id = t.atendido_recepcion_por
LEFT JOIN admin_users ul ON ul.id = t.atendido_lugar_por
LEFT JOIN turnero_solicitudes ts2 ON ts2.turno_id = t.id
LEFT JOIN lab_pacientes s2 ON s2.id = ts2.paciente_id
$whereStr
+22
View File
@@ -0,0 +1,22 @@
<?php
/**
* POST { turno_id, paciente_id }
* Vincula un paciente al turno para que los consentimientos puedan crearse.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$d = inputJson();
$turnoId = (int)($d['turno_id'] ?? 0);
$pacienteId = (int)($d['paciente_id'] ?? 0);
if (!$turnoId || !$pacienteId) jsonError('turno_id y paciente_id requeridos.');
$pdo = db();
$stmt = $pdo->prepare('UPDATE turnero_turnos SET paciente_id = ? WHERE id = ?');
$stmt->execute([$pacienteId, $turnoId]);
if (!$stmt->rowCount()) jsonError('Turno no encontrado.', 404);
jsonOk(['turno_id' => $turnoId, 'paciente_id' => $pacienteId]);
+2 -1
View File
@@ -623,7 +623,8 @@ $tab = $_GET['tab'] ?? 'lugares';
<select id="edit-pr-icono" class="form-select form-select-sm">
<option value="">— Sin icono —</option>
<option value="fas fa-child">👶 Niños (fas fa-child)</option>
<option value="fas fa-heart"> Embarazada (fas fa-heart)</option>
<option value="fa-solid fa-person-pregnant">🤰 Embarazada (fa-person-pregnant)</option>
<option value="fas fa-heart">❤ Embarazada alt (fas fa-heart)</option>
<option value="fas fa-person-cane">🦯 Adulto mayor (fas fa-person-cane)</option>
<option value="fas fa-wheelchair">♿ Discapacidad (fas fa-wheelchair)</option>
<option value="fas fa-user">👤 Paciente general (fas fa-user)</option>
+70 -1
View File
@@ -137,6 +137,7 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
<th>Espera</th>
<th>Servicio</th>
<th>Entrada</th>
<th></th>
</tr>
</thead>
<tbody id="tbodyTurnos">
@@ -324,7 +325,8 @@ function renderTabla(turnos) {
const hora = t.creado_at ? new Date(t.creado_at.replace(' ', 'T')).toLocaleTimeString('es-CO', {hour:'2-digit',minute:'2-digit'}) : '—';
const espMin = t.espera_min !== null ? t.espera_min + ' m' : '—';
const srvMin = t.servicio_min !== null ? t.servicio_min + ' m' : '—';
return `<tr>
const rowId = `dash-det-${t.id}`;
return `<tr style="cursor:pointer" onclick="toggleDashDetalle('${rowId}', this)">
<td><span class="fw-bold" style="color:${escHtml(t.prioridad_color || '#333')}">${escHtml(t.codigo)}</span></td>
<td><span class="estado-badge" style="background:${escHtml(t.prioridad_color+'22'||'#eee')};color:${escHtml(t.prioridad_color||'#333')}">${escHtml(t.prioridad_codigo)}</span></td>
<td>${nombrePac}</td>
@@ -333,10 +335,77 @@ function renderTabla(turnos) {
<td>${espMin}</td>
<td>${srvMin}</td>
<td>${hora}</td>
<td><i class="fas fa-chevron-down text-muted" style="font-size:.65rem"></i></td>
</tr>
<tr id="${rowId}" style="display:none;background:#f8fafc">
<td colspan="9" style="padding:0">
<div style="padding:12px 16px;border-top:1px solid #e2e8f0">
${renderDashDetalle(t)}
</div>
</td>
</tr>`;
}).join('');
}
function toggleDashDetalle(rowId, tr) {
const det = document.getElementById(rowId);
const icon = tr.querySelector('.fa-chevron-down, .fa-chevron-up');
if (!det) return;
const open = det.style.display !== 'none';
det.style.display = open ? 'none' : '';
if (icon) icon.className = open ? 'fas fa-chevron-down text-muted' : 'fas fa-chevron-up text-muted';
if (icon) icon.style.fontSize = '.65rem';
}
function renderDashDetalle(t) {
const e = s => escHtml(s || '—');
const fmt = dt => dt ? new Date(dt.replace(' ','T')).toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'}) : '—';
const chips = [
{ lbl:'Recepción (desk)', val: e(t.recepcion_desk_nombre) },
{ lbl:'Atendió recepción', val: e(t.atendido_recepcion_nombre) },
{ lbl:'Atendió muestras', val: e(t.atendido_lugar_nombre) },
{ lbl:'Inicio recepción', val: fmt(t.inicio_recepcion_at) },
{ lbl:'Fin recepción', val: fmt(t.fin_recepcion_at) },
{ lbl:'Inicio servicio', val: fmt(t.inicio_lugar_at) },
{ lbl:'Fin servicio', val: fmt(t.fin_lugar_at) },
];
let grid = `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-bottom:10px">
${chips.map(c=>`<div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:7px 11px">
<div style="font-size:.63rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8">${c.lbl}</div>
<div style="font-size:.82rem;font-weight:600;color:#1e293b">${c.val}</div>
</div>`).join('')}
</div>`;
// Notas
if (t.notas) {
grid += `<div style="margin-bottom:8px;font-size:.82rem"><span style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8">📝 Notas: </span>${e(t.notas)}</div>`;
}
// Exámenes
if (t.examenes && t.examenes.length) {
grid += `<div style="margin-bottom:8px">
<span style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8"><i class="fas fa-vials me-1"></i>Exámenes (${t.examenes.length}): </span>
${t.examenes.map(ex=>`<span class="badge" style="background:#dcfce7;color:#166534;margin:1px 2px">${e(ex.nombre)}</span>`).join('')}
</div>`;
}
// Comentarios
if (t.comentarios && t.comentarios.length) {
grid += `<div style="border-top:1px solid #e2e8f0;padding-top:8px">
<div style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;margin-bottom:5px"><i class="fas fa-comments me-1"></i>Comentarios</div>
${t.comentarios.map(c=>`<div style="font-size:.8rem;padding:4px 0;border-bottom:1px solid #f1f5f9">
<strong>${e(c.usuario_nombre)}</strong>
<span style="color:#94a3b8;font-size:.72rem;margin-left:6px">${fmt(c.creado_at)}</span>
<div style="color:#475569">${e(c.comentario)}</div>
</div>`).join('')}
</div>`;
}
return grid;
}
function filtrarTabla() {
const q = document.getElementById('filtroBusqueda').value.toLowerCase().trim();
if (!q) { renderTabla(_turnos); return; }
+3 -3
View File
@@ -129,7 +129,7 @@ $_hasVideo = (bool)$_tvVideo;
margin-bottom: .2rem;
}
.turno-activo .codigo {
font-size: clamp(5rem, 22vw, 16rem);
font-size: clamp(4rem, 14vw, 10rem);
font-weight: 900; line-height: 1;
letter-spacing: -4px;
color: var(--brand);
@@ -160,13 +160,13 @@ $_hasVideo = (bool)$_tvVideo;
100% { transform: translate(-50%, -50%) scale(.75); opacity: .15; }
}
.turno-activo .lugar-label {
font-size: clamp(1.35rem, 3vw, 2.15rem);
font-size: clamp(1.5rem, 3vw, 2.5rem);
font-weight: 600;
color: #94a3b8;
margin-top: .3rem;
}
.turno-activo .lugar-nombre {
font-size: clamp(1.75rem, 4vw, 3.05rem);
font-size: clamp(3rem, 10vw, 8rem);
font-weight: 800;
color: #1e293b;
margin-top: .1rem;
+42 -8
View File
@@ -95,6 +95,12 @@ Layout::open('Historial de Turnos', 'fas fa-history');
.detail-item .d-lbl { font-size:.67rem; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; }
.detail-item .d-val { font-size:.85rem; font-weight:600; color:#1e293b; }
/* ── Exámenes y comentarios ── */
.detail-extra { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
.detail-extra .d-lbl { font-size:.67rem; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
.detail-comment { padding:5px 0; border-bottom:1px solid #f1f5f9; font-size:.82rem; }
.detail-comment:last-child { border-bottom:none; }
/* ── Documentos firmados ── */
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
.docs-lbl { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
@@ -448,15 +454,42 @@ function renderDetalle(t) {
return d.toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'});
};
const items = [
{ lbl:'Creado', val: fmt(t.creado_at) },
{ lbl:'Inicio recepción', val: fmt(t.inicio_recepcion_at) },
{ lbl:'Fin recepción', val: fmt(t.fin_recepcion_at) },
{ lbl:'Inicio servicio', val: fmt(t.inicio_lugar_at) },
{ lbl:'Fin servicio', val: fmt(t.fin_lugar_at) },
{ lbl:'Código completo', val: esc(t.codigo) },
{ lbl:'Número en cola', val: t.numero ?? '—' },
{ lbl:'Teléfono', val: esc(t.paciente_cel || '—') },
{ lbl:'Creado', val: fmt(t.creado_at) },
{ lbl:'Inicio recepción', val: fmt(t.inicio_recepcion_at) },
{ lbl:'Fin recepción', val: fmt(t.fin_recepcion_at) },
{ lbl:'Inicio servicio', val: fmt(t.inicio_lugar_at) },
{ lbl:'Fin servicio', val: fmt(t.fin_lugar_at) },
{ lbl:'Código completo', val: esc(t.codigo) },
{ lbl:'Número en cola', val: t.numero ?? '—' },
{ lbl:'Teléfono', val: esc(t.paciente_cel || '—') },
{ lbl:'Recepción (desk)', val: esc(t.recepcion_desk_nombre || '—') },
{ lbl:'Atendió recepción', val: esc(t.atendido_recepcion_nombre || '—') },
{ lbl:'Atendió muestras', val: esc(t.atendido_lugar_nombre || '—') },
];
// Exámenes
let examHtml = '';
if (t.examenes && t.examenes.length) {
examHtml = `<div class="detail-extra">
<div class="d-lbl"><i class="fas fa-vials me-1 text-success"></i>Exámenes (${t.examenes.length})</div>
<div class="d-val">${t.examenes.map(e => `<span class="badge bg-success-subtle text-success me-1 mb-1">${esc(e.nombre)}</span>`).join('')}</div>
</div>`;
}
// Comentarios
let comHtml = '';
if (t.comentarios && t.comentarios.length) {
comHtml = `<div class="detail-extra">
<div class="d-lbl"><i class="fas fa-comments me-1 text-primary"></i>Comentarios</div>
<div>${t.comentarios.map(c => `
<div class="detail-comment">
<span class="fw-semibold">${esc(c.usuario_nombre)}</span>
<span class="text-muted small ms-1">${c.creado_at ? new Date(c.creado_at.replace(' ','T')).toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'}) : ''}</span>
<div class="text-secondary">${esc(c.comentario)}</div>
</div>`).join('')}
</div>
</div>`;
}
return `<div class="detail-grid">
${items.map(it=>`
<div class="detail-item">
@@ -464,6 +497,7 @@ function renderDetalle(t) {
<div class="d-val">${it.val}</div>
</div>`).join('')}
</div>
${examHtml}${comHtml}
<div class="docs-section" id="docs-${t.id}">
<div class="docs-lbl"><i class="fas fa-file-signature me-1"></i>Documentos</div>
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
+17 -13
View File
@@ -207,10 +207,10 @@ unset($p);
.btn-prioridad:active { transform: scale(.95); filter: brightness(.93); }
.btn-prioridad:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,.13); }
.btn-prioridad .icon-wrap {
width: 62px; height: 62px;
border-radius: 14px;
width: 90px; height: 90px;
border-radius: 16px;
display: flex; align-items: center; justify-content: center;
font-size: 2.04rem;
font-size: 3rem;
color: #fff;
margin-bottom: .2rem;
}
@@ -265,7 +265,7 @@ unset($p);
/* ── Print: ticket para impresora térmica SRP-B300 ── */
@media print {
@page { margin: 1mm 2mm; size: 80mm auto; }
body { background: #fff !important; font-family: 'Courier New', Courier, 'Lucida Console', monospace !important; -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
body { background: #fff !important; font-family: Arial, Helvetica, sans-serif !important; -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; -webkit-font-smoothing: antialiased !important; }
.kiosko-topbar, .kiosko-footer, .spinner-overlay, .toast-error,
.btn-nuevo, #screen-prio, #screen-datos { display: none !important; }
#screen-ticket { display: block !important; opacity: 1 !important; position: static !important; }
@@ -278,13 +278,13 @@ unset($p);
border-top: none !important; border-radius: 0 !important;
text-align: center !important;
}
.ticket-label { font-size: 8pt !important; letter-spacing: 0.03em !important; margin-bottom: 0 !important; padding-top: 2pt !important; }
.ticket-label { font-size: 10pt !important; font-weight: 600 !important; letter-spacing: 0.03em !important; margin-bottom: 0 !important; padding-top: 2pt !important; }
.ticket-codigo { font-size: 22pt !important; font-weight: 700 !important; letter-spacing: 0 !important; line-height: 1 !important; padding: 0 !important; }
.ticket-prio-badge { font-size: 8pt !important; padding: 0 !important; margin: 0 !important; background: transparent !important; color: #000 !important; }
.ticket-prio-badge { font-size: 10pt !important; font-weight: 600 !important; padding: 0 !important; margin: 0 !important; background: transparent !important; color: #000 !important; }
.ticket-pos { margin: 2pt 0 !important; padding: 0 !important; border-radius: 0 !important; background: transparent !important; }
.ticket-pos .lbl { font-size: 7pt !important; letter-spacing: 0.03em !important; }
.ticket-pos .num { font-size: 11pt !important; font-weight: 700 !important; color: #000 !important; }
.ticket-instruc { font-size: 7pt !important; margin: 2pt 0 !important; line-height: 1.15 !important; padding-bottom: 2pt !important; }
.ticket-pos .lbl { font-size: 9pt !important; font-weight: 600 !important; letter-spacing: 0.03em !important; }
.ticket-pos .num { font-size: 12pt !important; font-weight: 700 !important; color: #000 !important; }
.ticket-instruc { font-size: 9pt !important; font-weight: 500 !important; margin: 2pt 0 !important; line-height: 1.3 !important; padding-bottom: 2pt !important; }
.ticket-codigo, .ticket-label, .ticket-prio-badge,
.ticket-pos, .ticket-pos .lbl, .ticket-pos .num,
.ticket-instruc { color: #000 !important; }
@@ -517,16 +517,20 @@ unset($p);
inpCedula.focus();
inpCedula.value = '';
// Detector de scanner: cuando se pega/escribe rápido una cédula válida
// Detector de scanner: auto-submit solo si los dígitos llegan rápido (< 80ms entre cada uno)
let _timerAutoSubmit = null;
let _lastKeyAt = 0;
inpCedula.addEventListener('input', () => {
clearTimeout(_timerAutoSubmit);
const now = Date.now();
const gap = now - _lastKeyAt;
_lastKeyAt = now;
const val = inpCedula.value.trim();
if (/^\d{5,15}$/.test(val)) {
// Cédula válida: auto-submit en 300ms (tiempo para que termine el scanner)
// Scanner envía dígitos en ráfaga; teclado humano siempre > 80ms entre teclas
if (/^\d{5,15}$/.test(val) && gap < 80) {
_timerAutoSubmit = setTimeout(() => confirmarTurno(), 300);
}
}, { once: false });
});
}
function volverPrioridades() { mostrar('screen-prio'); }
+9 -12
View File
@@ -846,6 +846,14 @@ async function buscarPaciente() {
function seleccionarPaciente(pac) {
pacienteActivo = pac;
// Persistir vínculo en BD para que create_consent_token pueda verificar paciente_id
if (turnoActivo?.id && pac?.id) {
fetch(API + 'vincular_paciente.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, paciente_id: pac.id }),
}).catch(() => {});
}
document.getElementById('bloque-pac-no-vinculado').classList.add('d-none');
document.getElementById('bloque-pac-seleccionado').classList.remove('d-none');
document.getElementById('lbl-pac-nombre').textContent =
@@ -959,17 +967,6 @@ function renderHistorialTimeline(turnos, total) {
</div>`;
}
// Consentimientos
let consStr = '';
if (t.consentimientos && t.consentimientos.length) {
consStr = `<div style="margin-top:6px;font-size:.75rem">
<strong style="color:#64748b">Consentimientos:</strong>
<div>${t.consentimientos.map(c =>
`<span style="display:inline-block;margin-right:6px">${CONS_ICON[c.estado]||'?'} ${escHtml(c.nombre||'Consentimiento')}</span>`
).join('')}</div>
</div>`;
}
// Comentarios
let comStr = '';
if (t.comentarios && t.comentarios.length) {
@@ -988,7 +985,7 @@ function renderHistorialTimeline(turnos, total) {
<div class="timeline-codigo" style="color:${color}">${escHtml(t.codigo)}</div>
<div class="timeline-lugar">${lugar}</div>
<span class="timeline-eb" style="${eStyle}">${escHtml(eLbl)}</span>${mins}
${examStr}${consStr}${comStr}
${examStr}${comStr}
</div>
</div>`;
}).join('');