Historial y bandeja: mostrar qué pasó con cada turno

Para saber quién retiró un formulario de un turno había que consultar la base
a mano. La información existía, pero repartida en cinco tablas y sin ninguna
pantalla que la juntara.

get_traza_turno.php arma una sola línea de tiempo con el recorrido del turno
(con quién atendió en cada punto), los documentos enviados y firmados, los
comentarios del personal —donde caen las ausencias con su motivo—, las
muestras recibidas y rechazadas, y las acciones administrativas auditadas.
Se muestra en el detalle desplegable de Historial y en el panel de Bandeja.

Del chat de WhatsApp solo se indica cuántos mensajes hubo ese día, con enlace
a la conversación. Volcar los mensajes sería esparcir datos personales del
paciente por una pantalla de consulta.

Y la parte que faltaba: cinco acciones no dejaban ningún rastro. Ahora se
auditan cambiar_estado, resetear_consentimiento, resetear_toma,
vincular_paciente y cancelar_toma_pendiente — las que borran una firma,
descartan datos de un protocolo o mandan la atención a otra historia clínica.
La auditoría va en try/catch: si falla, no tumba la operación.

La respuesta declara en `sinRastro` lo que el sistema todavía no registra,
para que la ausencia de un evento no se lea como prueba de que no ocurrió.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-19 12:33:55 -05:00
co-authored by Claude Opus 5
parent d4674f087d
commit 487673dc0f
8 changed files with 456 additions and 5 deletions
+47 -1
View File
@@ -251,6 +251,9 @@ Layout::open('Bandeja del día', 'fas fa-layer-group');
.bnd-com-user { font-weight: 600; color: #1e293b; }
.bnd-com-date { font-size: 0.7rem; color: #94a3b8; margin-left: auto; }
.bnd-com-text { color: #334155; }
.bnd-traza { border-left:2px solid #e2e8f0; margin-left:4px; padding-left:10px; }
.bnd-traza-fila { display:flex; align-items:flex-start; gap:8px; padding:3px 0; font-size:.8rem; color:#334155; }
.bnd-traza-hora { color:#94a3b8; font-variant-numeric:tabular-nums; min-width:42px; }
/* Agregar comentario */
.bnd-add-com { display: flex; gap: 8px; align-items: flex-end; }
.bnd-add-com textarea {
@@ -702,7 +705,50 @@ function _renderDetalle(d) {
<button onclick="_bndEnviarCom(${t.id})"><i class="fas fa-paper-plane"></i> Enviar</button>
</div></div>`;
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secRel + secC + secCom;
const secTraza = `<div class="bnd-sec">
<div class="bnd-sec-title"><i class="fas fa-clock-rotate-left"></i> Qué pasó con este turno</div>
<div id="bnd-traza"><span style="color:#94a3b8;font-size:.82rem">
<i class="fas fa-spinner fa-spin"></i> Cargando…</span></div>
</div>`;
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secRel + secC + secCom + secTraza;
_bndCargarTraza(t.id);
}
// ── Bitácora del turno ────────────────────────────────────────
// Los comentarios ya salen arriba; aquí va el recorrido completo, incluidas las
// acciones administrativas (por ejemplo el retiro de un formulario, con motivo).
const BND_TRAZA_ICO = {
turno:'fa-plus-circle', llamado:'fa-bullhorn', atencion:'fa-user-check',
muestra:'fa-vial', fin:'fa-flag-checkered', doc:'fa-paper-plane',
doc_ok:'fa-file-signature', comentario:'fa-comment',
alerta:'fa-triangle-exclamation', admin:'fa-user-shield',
};
const BND_TRAZA_COLOR = { alerta:'#dc2626', doc_ok:'#16a34a', comentario:'#2563eb', fin:'#0f766e', admin:'#7c3aed' };
async function _bndCargarTraza(turnoId) {
const cont = document.getElementById('bnd-traza');
if (!cont) return;
const esc = x => String(x ?? '').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
try {
const j = await fetch(`modules/turnero/api/get_traza_turno.php?turno_id=${turnoId}`).then(r=>r.json());
if (!j.ok || !j.eventos?.length) {
cont.innerHTML = '<span style="color:#94a3b8;font-size:.82rem">Sin registros</span>'; return;
}
const hora = c => c ? new Date(c.replace(' ','T')).toLocaleTimeString('es-CO',{hour:'2-digit',minute:'2-digit'}) : '';
cont.innerHTML = '<div class="bnd-traza">' + j.eventos.map(e => `
<div class="bnd-traza-fila">
<span class="bnd-traza-hora">${hora(e.cuando)}</span>
<i class="fas ${BND_TRAZA_ICO[e.tipo]||'fa-circle'}"
style="color:${BND_TRAZA_COLOR[e.tipo]||'#94a3b8'};width:14px;font-size:.72rem;margin-top:2px"></i>
<div style="flex:1">${esc(e.texto)}
${e.quien ? `<span style="color:#64748b;font-style:italic"> ${esc(e.quien)}</span>` : ''}
${e.extra ? `<div style="color:#b45309;font-size:.74rem">${esc(e.extra)}</div>` : ''}
</div>
</div>`).join('') + '</div>';
} catch (_) {
cont.innerHTML = '<span style="color:#94a3b8;font-size:.82rem">Error al cargar</span>';
}
}
// ── Marcar visto ──────────────────────────────────────────────
+80
View File
@@ -110,6 +110,14 @@ Layout::open('Historial de Turnos', 'fas fa-history');
/* ── Documentos firmados ── */
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
.traza { border-left:2px solid #e2e8f0; margin:2px 0 0 6px; padding-left:10px; }
.traza-fila { display:flex; align-items:flex-start; gap:8px; padding:3px 0; font-size:.78rem; }
.traza-hora { color:#94a3b8; font-variant-numeric:tabular-nums; min-width:42px; }
.traza-ico { width:14px; text-align:center; margin-top:2px; font-size:.72rem; }
.traza-txt { flex:1; color:#334155; }
.traza-quien { color:#64748b; font-style:italic; }
.traza-extra { color:#b45309; font-size:.74rem; margin-top:1px; }
.traza-nota { color:#94a3b8; font-size:.7rem; margin-top:6px; font-style:italic; }
.docs-lbl { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
.doc-pill { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:99px;
font-size:.73rem; font-weight:600; margin:2px 2px; text-decoration:none; transition:opacity .15s; }
@@ -594,6 +602,10 @@ function renderDetalle(t) {
</div>`).join('')}
</div>
${relHtml}${examHtml}${comHtml}
<div class="docs-section" id="traza-${t.id}">
<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
</div>
<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>
@@ -614,11 +626,79 @@ function toggleDetalle(rowId, btn, turnoId) {
docsEl.dataset.loaded = '1';
cargarDocumentosTurno(turnoId, docsEl);
}
const trazaEl = document.getElementById(`traza-${turnoId}`);
if (trazaEl && !trazaEl.dataset.loaded) {
trazaEl.dataset.loaded = '1';
cargarTrazaTurno(turnoId, trazaEl);
}
}
}
const BASE_URL_ROOT = '<?= BASE_URL ?>';
// ── Bitácora del turno ────────────────────────────────────────
// Junta en una sola línea de tiempo lo que antes estaba repartido: el recorrido
// del turno, los documentos, los comentarios del personal, las muestras y las
// acciones administrativas auditadas.
const TRAZA_ICONO = {
turno:'fa-plus-circle', llamado:'fa-bullhorn', atencion:'fa-user-check',
muestra:'fa-vial', fin:'fa-flag-checkered', doc:'fa-paper-plane',
doc_ok:'fa-file-signature', comentario:'fa-comment', alerta:'fa-triangle-exclamation',
admin:'fa-user-shield',
};
const TRAZA_COLOR = {
alerta:'#dc2626', doc_ok:'#16a34a', comentario:'#2563eb',
fin:'#0f766e', admin:'#7c3aed',
};
async function cargarTrazaTurno(turnoId, container) {
const titulo = '<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>';
try {
const res = await fetch(`${API}get_traza_turno.php?turno_id=${turnoId}`);
const json = await res.json();
if (!json.ok) { container.innerHTML = titulo + '<span class="docs-empty">No se pudo cargar</span>'; return; }
const ev = json.eventos || [];
if (!ev.length) { container.innerHTML = titulo + '<span class="docs-empty">Sin registros</span>'; return; }
const hora = c => c ? new Date(c.replace(' ','T')).toLocaleTimeString('es-CO',{hour:'2-digit',minute:'2-digit'}) : '';
const filas = ev.map(e => {
const color = TRAZA_COLOR[e.tipo] || '#94a3b8';
const ico = TRAZA_ICONO[e.tipo] || 'fa-circle';
const quien = e.quien ? `<span class="traza-quien">${esc(e.quien)}</span>` : '';
const extra = e.extra ? `<div class="traza-extra">${esc(e.extra)}</div>` : '';
return `<div class="traza-fila">
<span class="traza-hora">${hora(e.cuando)}</span>
<i class="fas ${ico} traza-ico" style="color:${color}"></i>
<div class="traza-txt"><span>${esc(e.texto)}</span> ${quien}${extra}</div>
</div>`;
}).join('');
// El chat no se vuelca aquí: son datos personales del paciente y esta es
// una pantalla de consulta. Se indica que existe y se enlaza.
const chat = json.chat
? `<div class="traza-fila">
<span class="traza-hora"></span>
<i class="fab fa-whatsapp traza-ico" style="color:#25d366"></i>
<div class="traza-txt">
<a href="erp.php?m=turnero&v=chat&user_id=${json.chat.user_id}" target="_blank">
${json.chat.mensajes} mensaje(s) de WhatsApp ese día — ver conversación
</a>
</div>
</div>` : '';
// Se dice qué NO quedó registrado, para que la ausencia de un evento no
// se lea como prueba de que no ocurrió.
const nota = (json.sinRastro && json.sinRastro.length)
? `<div class="traza-nota">El sistema aún no deja rastro de: ${json.sinRastro.map(esc).join(' · ')}.</div>`
: '';
container.innerHTML = titulo + `<div class="traza">${filas}${chat}</div>` + nota;
} catch (_) {
container.innerHTML = titulo + '<span class="docs-empty">Error al cargar</span>';
}
}
async function cargarDocumentosTurno(turnoId, container) {
try {
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);