19 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Fable 5 1ba6805799 Voz del televisor: cazado en vivo el atasco del motor, y cómo impedirlo
Emulación en vivo con el usuario escuchando (23/08, 12:36-12:44): rellamados
reales a los dos turnos en recepción, instrumentando la pestaña. El fallo
apareció a la primera y quedó fotografiado:

    speaking: true          ← el motor JURA estar hablando
    'start' nunca disparó   ← pero no suena, y no hay error alguno
    usuario: «salió pero no habló»

Es el motor de voz durmiéndose tras el ocio: el primer speak() de ahí en
adelante queda atascado — finge hablar sin producir audio. El vigilante lo
toleraba porque speaking=true parece legítimo, y el keepalive pause/resume ni
lo prevenía ni podía correr (solo actúa con el motor en reposo). Por eso «el
primero no habla y el segundo sí»: el cancel() del segundo desatascaba.

Dos piezas, ambas validadas en vivo antes de entrar al repositorio:

1. PREVENIR: el keepalive ahora habla de verdad — una letra a volumen cero
   cada 25 s con el motor ocioso. Trabajo inaudible que lo mantiene despierto.
   En la ronda de validación, el llamado tras 30 s de ocio (el caso que
   siempre fallaba) habló completo a la primera, y los cuatro de la ronda
   también, sin un solo reintento.

2. CURAR: el vigilante distingue ahora tres estados en vez de dos:
   pending sin speaking = voz remota descargando (prórroga de 6 s);
   speaking sin 'start' = ATASCO (una locución que de verdad suena dispara
   'start' de inmediato): se invalida la generación, cancel(), y se rehabla
   tras 500 ms de respiro — rehablar de inmediato se volvía a atascar.
   La bitácora lo registra como sin_start con atasco:1.

La primera versión del desatascador (probada en vivo) reintentaba por partida
doble: el cancel() dispara 'error' sobre la misma locución y ese handler
también reintentaba. Se invalida la generación ANTES de cancelar.

Suite: 24 verificaciones, incluyendo el atasco simulado (exactamente 2 speak,
un aviso, sin fugas) y el keepalive (existe, es inaudible, no interfiere).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:46:48 -05:00
Lizandro GuarnizoandClaude Fable 5 c9b3ebc179 Pantalla TV en ventana de PC: despertador SSE, y el SSE estaba mudo
El televisor no es un equipo dedicado: es una ventana de un computador de uso
mixto. Si esa ventana queda tapada o minimizada, Chrome congela sus relojes a
~1 tic por minuto, el sondeo se paraliza y los llamados intermedios no suenan
(reproducido y documentado en la bitácora el 23/08 a las 12:11).

Los eventos de red no se congelan. La pantalla se suscribe ahora al canal SSE
que ya existía —todos los caminos de llamado actualizan sse_ping_at: llamar,
rellamar, cambiar_estado y llamar_desde_espera— y cada cola_update dispara un
refresco inmediato, aunque la pestaña esté de fondo. El sondeo de 1 s queda
de respaldo, y el vigía de congelamiento anota en la bitácora si ambos fallan.

Al verificar en producción (sin dar por hecho que el canal servía), el SSE
resultó estar MUDO: cero bytes en 17 segundos, ni el keep-alive. Causa: el
arranque del ERP deja un búfer de salida activo —los jsonOk() de _helpers
hacen ob_clean(), que lo confirma— y sse_turno.php usaba flush() a secas, que
no lo atraviesa: todo lo emitido quedaba atrapado. Se drena el búfer al
iniciar el stream (ob_end_clean + ob_implicit_flush). Verificado ejecutándolo
contra la base real: emite cola_update de inmediato.

La suite vigila que la suscripción exista (17 verificaciones de voz).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:26:40 -05:00
Lizandro GuarnizoandClaude Fable 5 95547bc4f3 Arnés de pruebas: proveer document al código extraído
El vigía de congelamiento escucha visibilitychange, y el arnés no proveía
document: la suite entera se caía al cargar. Fue mi error de proceso además
del de código: empujé el commit anterior sin mirar el resultado de la suite,
que es exactamente lo que la suite existe para impedir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:14:58 -05:00
Lizandro GuarnizoandClaude Fable 5 6e5aa16c29 Bitácora TV: registrar cuando la pantalla estuvo congelada u oculta
Caso real resuelto con la bitácora (23/08, 12:11): tres rellamados de E001,
el del medio no sonó. No fue la voz: la pestaña que anunciaba estaba en
segundo plano —era el Mac de pruebas, no el televisor; lo delata la voz
Paulina— y Chrome frena los relojes de las pestañas ocultas a ~1 tic por
minuto. En ese hueco el llamado intermedio fue invisible: cuando la pestaña
volvió a consultar, el servidor ya solo mostraba el estado más reciente.

Para que ese estado deje de confundirse con un fallo de la voz, la pantalla
registra ahora el evento 'lag' cuando detecta un hueco de más de 10 s entre
tics o cuando pasa a segundo plano, y la tarjeta de Actividad lo traduce como
«Pantalla congelada» con su explicación.

En el televisor real —siempre visible, en primer plano— este estado no debe
aparecer; si aparece, es la señal de que alguien lo dejó oculto o el equipo
se suspendió.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:14:19 -05:00
Lizandro GuarnizoandClaude Fable 5 ec9f9f3a7d Segunda pasada adversarial: doble anuncio, fuga entre fichas, y el simulador
Revisión de lo entregado ayer sin dar nada por hecho. Aparecieron dos
defectos reales y un vacío en las pruebas que los dejaba pasar.

1. DOBLE ANUNCIO (voz). En Chrome, cancel() dispara 'error' (interrupted)
   sobre la locución vieja, y ese handler volvía a lanzar el reintento: dos
   voces superpuestas diciendo lo mismo, intermitente — otra fuente del
   "entrecortado". Cada locución toma ahora un token de generación; si al
   dispararse un evento ya no es la vigente (la superó un reintento o el
   anuncio siguiente), sus handlers solo sueltan el anclaje y callan. De paso
   la bitácora deja de registrar 'end' falsos de locuciones canceladas.

2. FUGA ENTRE FICHAS (RIPS). La respuesta del sondeo puede llegar después de
   que la recepcionista cambió de paciente: los exámenes de uno se cargaban
   en la ficha del siguiente. Ahora se compara la cédula de la respuesta con
   la de quien está en pantalla y, si no coincide, se descarta y se detiene.

3. EL SIMULADOR MENTÍA POR OMISIÓN. No imitaba que cancel() interrumpe con
   'error', por eso el defecto 1 pasó las pruebas. Ahora sí lo hace, y además
   modela los dos modos reales de fallo de Chrome: el speak descartado en
   silencio (se reintenta) y la voz eternamente en pending (se tolera: es
   indistinguible de una voz remota lenta, y cortarla fue el defecto del
   entrecortado original).

Batería completa: 62 verificaciones en 4 suites, todas en verde.
  test_tv_voz.js       16   incluye: exactamente 1 reintento, sin fantasmas
  test_rips_sondeo.js   9   incluye: el guardián de ficha existe y corre antes
  test_rips_ventana.php 6
  test_bsuid.php       31

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:05:54 -05:00
Lizandro GuarnizoandClaude Fable 5 9ebe6990a5 Pruebas de voz: cubrir también el camino feliz
La suite probaba los fallos (recolector, motor pausado, bloqueo, voz lenta)
pero no que HABLE BIEN: qué texto dice exactamente, con qué voz, y cómo
pronuncia. Un anuncio que suena pero dice «MaríA GóMez» o elige la voz remota
teniendo la local también es un fallo, y no estaba vigilado.

Cuatro casos nuevos: el texto deletrea el código y cierra con el destino, las
tildes capitalizan bien, se prefiere la voz local sobre la remota es-CO, y sin
nombre de paciente igual habla. 13 verificaciones en total.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:47:59 -05:00
Lizandro GuarnizoandClaude Fable 5 26efe10ae6 Actividad: tarjeta de salud de la voz del televisor
La bitácora turnero_tv_log solo se podía consultar con SQL, así que en la
práctica nadie la iba a mirar. La pantalla de Actividad (Reportes → Actividad)
muestra ahora, en lenguaje llano: cuántos llamados con voz hubo en 7 días, qué
porcentaje habló completo, cuántos salieron mudos y cuántos se recuperaron con
reintento, más los últimos fallos con fecha, turno y voz usada.

La tarjeta no aparece mientras no haya datos (recién desplegado), para no
mostrar una sección vacía.

De paso: el ancla del parche se comió el async de exportarCSV; restaurado y
verificado bloque por bloque.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:36:33 -05:00
Lizandro GuarnizoandClaude Fable 5 a28ae82402 Voz del televisor y demora de RIPS: análisis de fondo de los dos "a veces"
── Voz que a veces no suena o se corta ──

Dos causas nuevas, ambas intermitentes por naturaleza:

1. Defecto conocido de Chrome: si nada referencia la SpeechSynthesisUtterance,
   el recolector de basura puede llevársela A MITAD DE FRASE. El audio se corta
   en seco y 'end' no llega. Depende de cuándo pase el recolector, así que
   nunca se reproduce a voluntad. Cada locución queda anclada en un Set hasta
   terminar (_uttAncladas).

2. Tras un cancel(), en Windows y ChromeOS el motor puede quedar en pausa:
   speak() encola y jamás suena, sin error alguno. resume() antes de cada
   speak() lo destranca, y sobre un motor sano no hace nada.

Y porque lo intermitente no se depura mirando la pantalla, queda una bitácora:
cada intento de hablar registra voz elegida, si es local, cuánto tardó en
arrancar y cómo terminó — en localStorage.tvVozLog (últimos 200) y en la tabla
turnero_tv_log vía sendBeacon (endpoint log_tv.php, insert-only, eventos de
lista cerrada). La próxima vez que reporten "ayer a las 10 no sonó", se
consulta la tabla y se ve qué pasó exactamente.

── Servicios de RIPS que "se demoran" ──

Medido en datos, no en hipótesis: la facturación en el sistema del laboratorio
ocurre MIENTRAS la recepcionista atiende. La consulta a RIPS se hacía UNA sola
vez, al vincular al paciente — y en 7 días, 547 de 549 registros llegaron
DESPUÉS de esa única consulta. Nadie volvía a preguntar: eso es lo que se
percibía como lentitud de RIPS. (El scheduler en sí tarda segundos; de paso:
el reloj del equipo legado está ~1 hora atrasado, visible en hora_recepcion.)

Ahora la ficha sondea cada 10 s hasta 10 minutos y se detiene al encontrar,
al limpiar la ficha o al agotar los intentos. Si la recepcionista ya marcó
exámenes a mano, no se le pisan: se le ofrece el banner en vez de autocargar.

── Pruebas (22, todas contra el código real extraído de las vistas) ──

  scripts/test_tv_voz.js        9  anclaje GC, resume(), bloqueo, voz lenta, bitácora
  scripts/test_rips_sondeo.js   7  reintentos, paradas, tope, sin duplicar
  scripts/test_rips_ventana.php 6  ventana SQL en tabla TEMPORARY, sin tocar datos

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:11:40 -05:00
Lizandro GuarnizoandClaude Opus 5 8218daaff7 Historial: abrir el detalle al hacer clic en cualquier parte de la fila
Antes había que apuntarle a una flecha de pocos píxeles al final de la fila.
Ahora sirve toda la fila, como ya funciona el listado de pacientes.

Con dos resguardos, porque la fila lleva controles y datos que se copian:
no abre si el clic cayó sobre un botón o un enlace —el de cambiar estado
tiene lo suyo que hacer— ni si el usuario venía seleccionando texto, que en
esta pantalla es frecuente para copiar un nombre o un código.

La flecha se conserva: sigue siendo la señal visual de que la fila se abre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:35:47 -05:00
Lizandro GuarnizoandClaude Opus 5 83b3f900e8 Historial: la columna de exámenes muestra el conteo, no la lista
La celda estaba limitada a 140px y llevaba una etiqueta por examen. Con 9,5
exámenes de promedio por turno —y 981 de 1.722 turnos por encima de seis, uno
con 94— cada fila ocupaba cinco renglones o más y la tabla quedaba ilegible.

Ahora la celda dice «9 exámenes» y la lista completa aparece al pasar el
cursor. Los nombres ya estaban en el detalle desplegable, así que no se pierde
información: se deja de repetir en un espacio donde no cabe.

Las exportaciones a Excel y CSV arman su propia lista y no cambian.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:30:37 -05:00
Lizandro GuarnizoandClaude Opus 5 51b22c4dfc Dashboard: que cada indicador diga qué está midiendo
Las etiquetas eran tan cortas que se prestaban para entender otra cosa.
«Servicio prom. en lugar» y «Total prom. puerta a puerta» parecían medir lo
mismo, y no cuadraban entre sí: 16,3 de espera más 7,4 de servicio no dan los
36,6 del total.

Sí cuadran, pero el recorrido tiene cuatro tramos y el tablero solo mostraba
dos. Los otros dos —6,4 min dentro de recepción y 8,4 esperando el puesto—
suman casi quince minutos que no aparecían por ningún lado.

Cada casilla lleva ahora una línea que explica de dónde sale el número, y el
total dice explícitamente por qué es mayor que la suma de las anteriores. Las
etiquetas pasan a lenguaje llano: «Espera para recepción» en vez de «Espera
prom.», «No se presentaron» en vez de «Ausentes».

En la de tiempo en el puesto se aclara que los protocolos prolongados cuentan
hasta la primera toma, para que nadie lea ese número como si midiera lo que
dura una curva completa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:23:59 -05:00
Lizandro GuarnizoandClaude Opus 5 336836f06d Dashboard: los protocolos prolongados se cuentan hasta la primera toma
Una curva de glucosa dura dos horas, pero de esas el puesto solo trabaja unos
minutos al principio y otros al final: el resto el paciente está esperando el
examen. El promedio contaba esas horas como tiempo de atención.

Ahora, cuando el turno tiene un formulario de tomas prolongadas, el servicio
se corta en la primera toma (toma_inicio_at). Sin protocolo, nada cambia.

El efecto es grande y explica por qué los números no cuadraban:

  promedio general      21,9 min → 7,4 min
  Omaira Limas          50,9 min → 22,6 min
  Luisa Gil             32,0 min → 7,9 min
  Yesica Morales        23,5 min → 6,5 min

Lo importante no es que el número baje sino que era injusto: quien atendía
protocolos prolongados aparecía como la más lenta cuando estaba haciendo el
mismo trabajo que las demás. El promedio corregido coincide con el de los
turnos que nunca tuvieron protocolo (8,2 min), que es la comprobación de que
el cálculo ahora mide lo que dice medir.

Se aplica a los tres promedios: general, por puesto y por profesional. El
detalle de cada turno sigue mostrando el tiempo real transcurrido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:18:04 -05:00
Lizandro GuarnizoandClaude Opus 5 b0266f4b8b Bitácora: atribuir bien las firmas y registrar quién llama
Tres correcciones sobre lo reportado en E042.

1. La bitácora daba por hecho que firmado_at era del paciente, y lo anunciaba
   así en documentos que el paciente nunca firma: el F-LAB-08 solo lo firma el
   profesional y el F-LAB-28 lleva una firma por toma. Ahora mira qué firma hay
   realmente guardada; si no hay ninguna dice «completado» o «protocolo
   cerrado», según el caso.

2. Los protocolos prolongados desglosan cada toma con su hora y su firmante,
   que es el dato que interesa: en E042 se ve que el minuto 0 lo firmó Yesica y
   el minuto 150 Omaira. La firma general no distinguía eso.

3. firmar_profesional_consentimiento.php aceptaba el nombre del firmante que
   mandaba el navegador, y ese valor se carga al abrir la página: si el
   personal cambiaba de turno sin recargar, la firma final quedaba a nombre de
   quien abrió el formulario. Ahora se resuelve desde la sesión en el servidor,
   igual que ya hacía guardar_toma.php para cada toma.

Además, llamar y rellamar no dejaban rastro —el turno solo guarda la hora del
último llamado, que el rellamado sobrescribe—, así que la bitácora mostraba
«Llamado a X» sin autor. Ahora se auditan ambos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:10:48 -05:00
Lizandro GuarnizoandClaude Opus 5 ba35b24b30 Pantalla de Actividad: poder filtrar también el turnero
La bitácora del turnero ya registra en lab_actividad_admin, pero los filtros
de esa pantalla solo listaban los módulos viejos: para ver un cambio de estado
o el retiro de un formulario había que buscarlo entre todo lo demás.

Se agregan los módulos turnero y turnero_config, y las cinco acciones nuevas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:48:56 -05:00
Lizandro GuarnizoandClaude Opus 5 26bec35c36 Ficha del paciente: poder corregir el sexo
El F-LAB-08 muestra «Sexo» tomándolo de la ficha, pero el modal de edición no
tenía ese campo: si estaba mal, desde el puesto no había forma de corregirlo
pese a que el endpoint ya aceptaba el dato.

Va en la parte sin fricción, junto a EPS. Corregir un sexo mal digitado es
frecuente y no cambia de quién es la historia clínica, que es lo que protege
el bloqueo de identidad.

Las tres opciones corresponden al enum('M','F','O') de la columna.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:43:10 -05:00
Lizandro GuarnizoandClaude Opus 5 2162ca7d68 Cargar datos de la visita anterior: los datos entraban pero no se veían
El botón sí traía las respuestas —comprobado con C028, que encuentra su visita
C014 y devuelve 12 campos—, pero los asignaba a mano con el.value y el.checked.
Eso no dispara ningún evento, y las secciones condicionales del F-LAB-08 (tipo
de dolor, tipo de cáncer, grupo sanguíneo) se despliegan escuchando 'change' e
'input'. Los datos entraban al formulario y las secciones seguían ocultas, así
que desde afuera parecía que el botón no hacía nada.

Ahora se disparan input y change en cada campo tocado, y el mensaje dice
cuántos datos se cargaron en vez de un texto fijo: si son cero, lo dice, en
lugar de afirmar que cargó.

Aparte: el botón «Corregir datos» del formulario quedó demasiado grande en el
cambio anterior. Vuelve a ser discreto —enlace subrayado de 10px— pero con
texto, que era lo que le faltaba para encontrarlo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:41:19 -05:00
Lizandro GuarnizoandClaude Opus 5 b63807c39a Formulario del turnero: que se vea el botón para corregir al paciente
El botón existía pero era un lápiz gris de 11px sin texto ni contorno: en
siete días ningún bacteriólogo editó una ficha, lo que sugiere que no lo
encontraban. Ahora lleva la etiqueta «Corregir datos» y contorno, junto al
primer dato traído de la ficha —que en el F-LAB-08 es el nombre, al inicio
del bloque de datos del paciente.

Sigue apareciendo una sola vez y solo mientras el formulario no esté firmado:
no se debe alterar la ficha desde un documento ya firmado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:00:46 -05:00
Lizandro GuarnizoandClaude Opus 5 0ea4237490 Chat del turnero: abrir una conversación desde la URL
La bitácora del turno enlaza al chat del paciente con ?user_id=N, pero la
vista no leía ese parámetro: abría el chat en la lista general y había que
buscar la conversación a mano, que es justo lo que el enlace debía evitar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 13:34:48 -05:00
Lizandro GuarnizoandClaude Opus 5 487673dc0f 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>
2026-08-19 12:33:55 -05:00
26 changed files with 1578 additions and 67 deletions
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* GET /api/lab/get_tv_log.php — Bitácora de la voz del televisor.
* Para la pantalla de Actividad: resumen de salud + últimos fallos, en llano.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
$pdo = db();
// Resumen de los últimos 7 días por evento
$resumen = $pdo->query(
"SELECT evento, COUNT(*) n FROM turnero_tv_log
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY evento"
)->fetchAll(PDO::FETCH_KEY_PAIR);
// Últimos incidentes (lo normal —start/end— no se lista: solo lo que falló)
$stmt = $pdo->query(
"SELECT evento, detalle, created_at FROM turnero_tv_log
WHERE evento IN ('mudo', 'sin_start', 'error', 'lag')
ORDER BY id DESC LIMIT 30"
);
$incidentes = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$d = json_decode($r['detalle'] ?? '{}', true) ?: [];
$incidentes[] = [
'cuando' => $r['created_at'],
'evento' => $r['evento'],
'turno' => $d['cod'] ?? null,
'voz' => $d['voz'] ?? null,
];
}
$anuncios = (int)($resumen['start'] ?? 0);
$mudos = (int)($resumen['mudo'] ?? 0);
$avisos = (int)($resumen['sin_start'] ?? 0) + (int)($resumen['error'] ?? 0);
jsonOk([
'anuncios_7d' => $anuncios,
'completos_7d' => (int)($resumen['end'] ?? 0),
'mudos_7d' => $mudos,
'avisos_7d' => $avisos,
'incidentes' => $incidentes,
]);
+66
View File
@@ -70,6 +70,8 @@ require_once __DIR__ . '/shared/components/sidebar.php';
<option value="enfermeras">Enfermeras</option> <option value="enfermeras">Enfermeras</option>
<option value="formularios">Formularios</option> <option value="formularios">Formularios</option>
<option value="asignaciones">Asignaciones</option> <option value="asignaciones">Asignaciones</option>
<option value="turnero">Turnero</option>
<option value="turnero_config">Turnero · configuración</option>
</select> </select>
</div> </div>
<div class="col-auto"> <div class="col-auto">
@@ -85,6 +87,11 @@ require_once __DIR__ . '/shared/components/sidebar.php';
<option value="enviar">enviar</option> <option value="enviar">enviar</option>
<option value="reprogramar">reprogramar</option> <option value="reprogramar">reprogramar</option>
<option value="cambiar_estado">cambiar_estado</option> <option value="cambiar_estado">cambiar_estado</option>
<option value="quitar_consentimiento">quitar_consentimiento</option>
<option value="resetear_consentimiento">resetear_consentimiento</option>
<option value="resetear_toma">resetear_toma</option>
<option value="cancelar_toma_pendiente">cancelar_toma_pendiente</option>
<option value="vincular_paciente">vincular_paciente</option>
</select> </select>
</div> </div>
<div class="col"> <div class="col">
@@ -104,6 +111,18 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</div> </div>
<!-- Tabla --> <!-- Tabla -->
<!-- Salud de la voz del televisor: alimentada por turnero_tv_log.
Responde de un vistazo «¿la pantalla está llamando bien?» sin
tener que consultar la base a mano. -->
<div class="card border-0 shadow-sm mb-3" id="card-voz-tv" style="display:none">
<div class="card-body py-3">
<h6 class="mb-2"><i class="fas fa-volume-up text-primary me-1"></i>
Voz del televisor — últimos 7 días</h6>
<div class="d-flex gap-4 flex-wrap mb-2" id="voz-tv-resumen"></div>
<div id="voz-tv-incidentes" class="small"></div>
</div>
</div>
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div id="act-spinner" class="text-center py-5 text-muted"> <div id="act-spinner" class="text-center py-5 text-muted">
@@ -270,6 +289,53 @@ function verDetalle(i) {
new bootstrap.Modal(document.getElementById('modalDetalle')).show(); new bootstrap.Modal(document.getElementById('modalDetalle')).show();
} }
// ── Salud de la voz del televisor ────────────────────────────
const VOZ_EVENTO_TXT = {
mudo: ['🔇 No sonó', 'text-danger', 'El llamado salió sin voz: ni el reintento habló'],
sin_start: ['⚠️ Arrancó con reintento','text-warning','La voz elegida no arrancó; habló la del navegador'],
error: ['⚠️ Error del motor', 'text-warning', 'El motor de voz falló a mitad; se reintentó'],
lag: ['⏸ Pantalla congelada', 'text-warning', 'La pestaña estuvo oculta o el equipo suspendido: en ese lapso no ve llamados. En el televisor debe estar siempre visible'],
};
async function cargarVozTV() {
try {
const r = await fetch('api/lab/get_tv_log.php');
const d = await r.json();
if (!d.ok) return;
// Sin datos todavía (recién desplegado): la tarjeta no aparece
if (!d.anuncios_7d && !d.incidentes.length) return;
document.getElementById('card-voz-tv').style.display = '';
const pct = d.anuncios_7d ? Math.round(d.completos_7d / d.anuncios_7d * 100) : 0;
document.getElementById('voz-tv-resumen').innerHTML = `
<div><span class="fw-bold fs-5">${d.anuncios_7d}</span>
<span class="text-muted small">llamados con voz</span></div>
<div><span class="fw-bold fs-5 ${pct >= 97 ? 'text-success' : 'text-warning'}">${pct}%</span>
<span class="text-muted small">hablados completos</span></div>
<div><span class="fw-bold fs-5 ${d.mudos_7d ? 'text-danger' : 'text-success'}">${d.mudos_7d}</span>
<span class="text-muted small">salieron mudos</span></div>
<div><span class="fw-bold fs-5 ${d.avisos_7d ? 'text-warning' : 'text-success'}">${d.avisos_7d}</span>
<span class="text-muted small">se recuperaron con reintento</span></div>`;
const inc = document.getElementById('voz-tv-incidentes');
if (!d.incidentes.length) {
inc.innerHTML = '<span class="text-success"><i class="fas fa-check-circle me-1"></i>Sin fallos registrados</span>';
return;
}
inc.innerHTML = '<div class="text-muted mb-1">Últimos fallos:</div>' +
d.incidentes.slice(0, 10).map(i => {
const [txt, cls, ayuda] = VOZ_EVENTO_TXT[i.evento] || [i.evento, '', ''];
return `<div class="py-1 border-bottom" title="${ayuda}">
<span class="text-muted">${(i.cuando || '').slice(0, 16)}</span>
· turno <b>${i.turno || '—'}</b>
· <span class="${cls}">${txt}</span>
${i.voz ? `<span class="text-muted"> (voz: ${i.voz})</span>` : ''}
</div>`;
}).join('');
} catch (_) {}
}
cargarVozTV();
async function exportarCSV() { async function exportarCSV() {
const params = new URLSearchParams({ const params = new URLSearchParams({
desde: document.getElementById('act-desde').value, desde: document.getElementById('act-desde').value,
+20
View File
@@ -0,0 +1,20 @@
-- 20260823_tv_voz_log.sql
--
-- Bitácora de la voz del televisor. Los fallos son intermitentes ("a veces no
-- suena, a veces entrecortado") y nadie puede depurarlos mirando la pantalla:
-- hay que registrar cada intento de hablar CUANDO ocurre, con qué voz, si
-- arrancó, cuánto tardó y cómo terminó. Con esto, la próxima vez que reporten
-- "ayer a las 10 no sonó" se consulta esta tabla y se ve exactamente qué pasó.
--
-- La escribe modules/turnero/api/log_tv.php vía sendBeacon desde la pantalla.
CREATE TABLE IF NOT EXISTS turnero_tv_log (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
evento VARCHAR(20) NOT NULL COMMENT 'start, end, error, sin_start, mudo',
detalle VARCHAR(500) NULL COMMENT 'JSON: código del turno, voz, ms, duración',
ip VARCHAR(45) NULL,
user_agent VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_evento_fecha (evento, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+22
View File
@@ -22,6 +22,7 @@
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST'); requireMethod('POST');
requireTurnero(); requireTurnero();
@@ -233,6 +234,27 @@ try {
notificarSSE((int) $turnoActualizado['sesion_id']); notificarSSE((int) $turnoActualizado['sesion_id']);
// Queda constancia de quién cambió el estado a mano. Sin esto, en el
// historial aparecía un turno cancelado o ausente sin ninguna huella
// de quién lo hizo ni desde qué estado.
try {
(new ActividadAdmin())->registrar(
adminId(),
'turnero',
'cambiar_estado',
$turnoId,
[
'turno_id' => $turnoId,
'turno_codigo' => $turnoActualizado['codigo'] ?? null,
'estado_previo' => $estadoActual,
'estado_nuevo' => $nuevoEstado,
]
);
} catch (\Throwable $e) {
// La auditoría nunca debe tumbar la operación
error_log('[cambiar_estado] auditoría: ' . $e->getMessage());
}
jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'"); jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'");
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -5,6 +5,7 @@
* Body: { consentimiento_id } * Body: { consentimiento_id }
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST'); requireMethod('POST');
requireTurnero(); requireTurnero();
@@ -30,4 +31,35 @@ $stmt->execute([$turnoId, $consentId]);
if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404); if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404);
// Cancelar una toma pendiente cierra un protocolo que quedó a medias en otra
// visita: conviene saber quién lo dio por terminado.
try {
$inf = $pdo->prepare(
"SELECT tc.turno_id, t.codigo, f.nombre AS formulario
FROM turnero_consentimientos tc
JOIN turnero_turnos t ON t.id = tc.turno_id
JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.id = ? LIMIT 1"
);
$inf->execute([$consentId]);
$d = $inf->fetch(PDO::FETCH_ASSOC) ?: [];
(new ActividadAdmin())->registrar(
adminId(),
'turnero',
'cancelar_toma_pendiente',
$consentId,
[
// Se registra bajo el turno donde se hizo la cancelación, que es
// donde alguien la va a buscar en el historial.
'turno_id' => $turnoId,
'turno_origen_id' => isset($d['turno_id']) ? (int)$d['turno_id'] : null,
'turno_origen' => $d['codigo'] ?? null,
'formulario_nombre' => $d['formulario'] ?? null,
]
);
} catch (\Throwable $e) {
error_log('[cancelar_toma_pendiente] auditoría: ' . $e->getMessage());
}
jsonOk(['cancelado' => true]); jsonOk(['cancelado' => true]);
@@ -19,9 +19,8 @@ $svg = $body['svg'] ?? '';
$soloPro = !empty($body['solo_profesional']); $soloPro = !empty($body['solo_profesional']);
// Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula) // Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula)
$datosResp = null; $datosResp = null;
if (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']) && !empty($body['datos_respuestas'])) { $datosArr = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
$datosResp = json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE); ? $body['datos_respuestas'] : [];
}
if (!$turnoId) jsonError('turno_id requerido.'); if (!$turnoId) jsonError('turno_id requerido.');
if (!$formularioId) jsonError('formulario_id requerido.'); if (!$formularioId) jsonError('formulario_id requerido.');
@@ -32,6 +31,32 @@ if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
$pdo = db(); $pdo = db();
// Quién firma se resuelve aquí y no se toma del navegador. El cliente manda el
// nombre que cargó al abrir la página, y si el personal cambia de turno sin
// recargar, la firma quedaba a nombre de quien abrió el formulario en vez de
// quien la está haciendo. La sesión del servidor es la única fuente confiable.
// (guardar_toma.php ya lo hacía así para cada toma; esto alinea la firma final.)
$__uid = adminId();
if ($__uid) {
$stmtPro = $pdo->prepare(
"SELECT au.full_name, au.cedula AS au_cedula,
le.nombre_completo AS pro_nombre, le.numero_documento AS pro_documento
FROM admin_users au
LEFT JOIN lab_enfermeras le ON le.id = au.enfermera_id
WHERE au.id = ? LIMIT 1"
);
$stmtPro->execute([$__uid]);
$pro = $stmtPro->fetch(PDO::FETCH_ASSOC) ?: [];
$proNombre = ($pro['pro_nombre'] ?? null) ?: ($pro['full_name'] ?? null);
$proCedula = ($pro['au_cedula'] ?? null) ?: ($pro['pro_documento'] ?? null);
if ($proNombre) $datosArr['_pro_nombre'] = $proNombre;
if ($proCedula) $datosArr['_pro_cedula'] = $proCedula;
$datosArr['_pro_id'] = $__uid;
}
if (!empty($datosArr)) {
$datosResp = json_encode($datosArr, JSON_UNESCAPED_UNICODE);
}
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"SELECT tc.id, tc.estado, t.sesion_id "SELECT tc.id, tc.estado, t.sesion_id
FROM turnero_consentimientos tc FROM turnero_consentimientos tc
+47 -16
View File
@@ -57,36 +57,51 @@ $sesionId = (int)$sesion['id'];
$stmtRes = $pdo->prepare( $stmtRes = $pdo->prepare(
"SELECT "SELECT
COUNT(*) AS total, COUNT(*) AS total,
SUM(estado IN ('finalizado','en_servicio')) AS atendidos, SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera, SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
SUM(estado = 'ausente') AS ausentes, SUM(t.estado = 'ausente') AS ausentes,
SUM(estado = 'cancelado') AS cancelados, SUM(t.estado = 'cancelado') AS cancelados,
ROUND( ROUND(
AVG( AVG(
CASE CASE
WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL WHEN t.inicio_recepcion_at IS NOT NULL AND t.creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0 THEN TIMESTAMPDIFF(SECOND, t.creado_at, t.inicio_recepcion_at) / 60.0
END END
), 1 ), 1
) AS tiempo_espera_promedio_min, ) AS tiempo_espera_promedio_min,
-- En los protocolos prolongados la atención se corta en la PRIMERA toma:
-- lo que sigue son horas de espera del examen (una curva de glucosa son
-- 2 horas), no tiempo de trabajo. Contarlas triplicaba el promedio —21,9
-- min contra 7,4— y hacía ver los puestos mucho más lentos de lo que son.
ROUND( ROUND(
AVG( AVG(
CASE CASE
WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL WHEN t.inicio_lugar_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0 AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
END END
), 1 ), 1
) AS tiempo_servicio_promedio_min, ) AS tiempo_servicio_promedio_min,
ROUND( ROUND(
AVG( AVG(
CASE CASE
WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL WHEN t.creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) / 60.0 AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.creado_at,
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
END END
), 1 ), 1
) AS tiempo_total_promedio_min ) AS tiempo_total_promedio_min
FROM turnero_turnos FROM turnero_turnos t
WHERE sesion_id = ?" -- Un turno tiene a lo sumo un protocolo prolongado, así que el join no duplica
LEFT JOIN (
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
GROUP BY tc.turno_id
) prot ON prot.turno_id = t.id
WHERE t.sesion_id = ?"
); );
$stmtRes->execute([$sesionId]); $stmtRes->execute([$sesionId]);
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC); $resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
@@ -115,12 +130,20 @@ $stmtLug = $pdo->prepare(
SUM(t.estado IN ('en_espera_lugar')) AS en_espera, SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
ROUND(AVG( ROUND(AVG(
CASE CASE
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL WHEN t.inicio_lugar_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0 AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
END END
), 1) AS tiempo_servicio_promedio_min ), 1) AS tiempo_servicio_promedio_min
FROM turnero_turnos t FROM turnero_turnos t
JOIN turnero_lugares l ON l.id = t.lugar_destino_id JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN (
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
GROUP BY tc.turno_id
) prot ON prot.turno_id = t.id
WHERE t.sesion_id = ? WHERE t.sesion_id = ?
GROUP BY l.id GROUP BY l.id
ORDER BY l.sort_order ASC, l.nombre ASC" ORDER BY l.sort_order ASC, l.nombre ASC"
@@ -164,12 +187,20 @@ $stmtBac = $pdo->prepare(
SUM(t.estado = 'ausente') AS ausentes, SUM(t.estado = 'ausente') AS ausentes,
ROUND(AVG( ROUND(AVG(
CASE CASE
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL WHEN t.inicio_lugar_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0 AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
END END
), 1) AS tiempo_promedio_min ), 1) AS tiempo_promedio_min
FROM turnero_turnos t FROM turnero_turnos t
JOIN admin_users u ON u.id = t.atendido_lugar_por JOIN admin_users u ON u.id = t.atendido_lugar_por
LEFT JOIN (
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
GROUP BY tc.turno_id
) prot ON prot.turno_id = t.id
WHERE t.sesion_id = ? WHERE t.sesion_id = ?
GROUP BY u.id, u.full_name, u.username GROUP BY u.id, u.full_name, u.username
ORDER BY finalizados DESC, total DESC" ORDER BY finalizados DESC, total DESC"
+263
View File
@@ -0,0 +1,263 @@
<?php
/**
* GET /modules/turnero/api/get_traza_turno.php?turno_id=N
*
* Bitácora de un turno: qué pasó, cuándo y quién lo hizo.
*
* La información ya existía, pero repartida en cinco tablas y sin ninguna
* pantalla que la juntara: para saber quién retiró un formulario había que
* consultar la base a mano. Aquí se unifica en una sola línea de tiempo.
*
* No inventa nada: cada evento sale de un dato guardado. Lo que el sistema no
* registró —ver `sinRastro` más abajo— simplemente no aparece.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
requireTurnero();
$turnoId = (int)($_GET['turno_id'] ?? 0);
if (!$turnoId) jsonError('turno_id requerido');
$pdo = db();
$t = $pdo->prepare(
"SELECT t.*, l.nombre AS lugar_nombre, d.nombre AS desk_nombre,
ur.full_name AS recepcionista, ul.full_name AS profesional
FROM turnero_turnos t
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_lugares d ON d.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
WHERE t.id = ? LIMIT 1"
);
$t->execute([$turnoId]);
$turno = $t->fetch(PDO::FETCH_ASSOC);
if (!$turno) jsonError('Turno no encontrado', 404);
$ev = [];
$add = function ($cuando, $tipo, $texto, $quien = null, $extra = null) use (&$ev) {
if (!$cuando) return;
$ev[] = [
'cuando' => $cuando,
'tipo' => $tipo, // la vista lo usa para el ícono y el color
'texto' => $texto,
'quien' => $quien ?: null,
'extra' => $extra ?: null,
];
};
// ── 1. Recorrido del turno ────────────────────────────────────────────────
$add($turno['creado_at'], 'turno', 'Turno creado');
$add($turno['llamado_recepcion_at'], 'llamado', 'Llamado a ' . ($turno['desk_nombre'] ?: 'recepción'));
$add($turno['inicio_recepcion_at'], 'atencion', 'Inicia atención en recepción', $turno['recepcionista']);
$add($turno['fin_recepcion_at'], 'atencion', 'Termina recepción');
$add($turno['llamado_lugar_at'], 'llamado', 'Llamado a ' . ($turno['lugar_nombre'] ?: 'toma de muestras'));
$add($turno['inicio_lugar_at'], 'atencion', 'Inicia toma de muestras', $turno['profesional']);
$add($turno['muestra_espera_at'], 'muestra', 'Pasa a espera de muestra');
$add($turno['fin_lugar_at'], 'fin', 'Finaliza la atención');
// ── 2. Documentos ─────────────────────────────────────────────────────────
$q = $pdo->prepare(
"SELECT tc.enviado_at, tc.firmado_at, tc.firmado_profesional_at, tc.estado,
tc.firma_svg, tc.firma_profesional_svg, tc.datos_respuestas,
f.nombre AS formulario, f.es_toma_progresiva
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.turno_id = ?"
);
$q->execute([$turnoId]);
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $c) {
$add($c['enviado_at'], 'doc', 'Enviado: ' . $c['formulario']);
// No todos los formularios los firma el paciente: el F-LAB-08 solo lo firma
// el profesional y el F-LAB-28 lleva una firma por toma. Se mira qué firma
// hay guardada en vez de suponer que firmado_at es del paciente, que era lo
// que antes hacía decir «firmado por el paciente» en documentos que el
// paciente nunca firmó.
$firmoPaciente = !empty($c['firma_svg']);
$firmoProfesional = !empty($c['firma_profesional_svg']);
if ($firmoPaciente) {
$add($c['firmado_at'], 'doc_ok', 'Firmado por el paciente: ' . $c['formulario']);
}
if ($firmoProfesional) {
$add($c['firmado_profesional_at'] ?: $c['firmado_at'], 'doc_ok',
'Firmado por el profesional: ' . $c['formulario']);
}
// Protocolo prolongado: cada toma lleva su hora y su firmante, que es el
// dato que de verdad interesa. La firma general no dice quién pinchó.
if (!empty($c['es_toma_progresiva'])) {
$dr = json_decode($c['datos_respuestas'] ?? '{}', true) ?: [];
$tomas = [];
foreach ($dr as $clave => $valor) {
// Las firmas de toma terminan en _f y traen su hora en _h
if (!preg_match('/^(_tm[a-z0-9]+)_f$/', $clave, $m)) continue;
if (!is_string($valor) || strlen($valor) < 10) continue;
$base = $m[1];
$tomas[] = [
'hora' => $dr[$base . '_h'] ?? null,
// _tm00 → «Minuto 0», _tm150 → «Minuto 150»
'etiqueta' => 'Minuto ' . (int) substr($base, 3),
'firmante' => $dr[$clave . '_pro_nombre'] ?? null,
];
}
// Se ordenan por hora para que la línea de tiempo quede coherente
usort($tomas, fn($a, $b) => strcmp((string)$a['hora'], (string)$b['hora']));
foreach ($tomas as $t) {
if (!$t['hora']) continue;
// La hora viene como "HH:MM"; se ancla al día del turno
$cuando = substr($turno['creado_at'], 0, 10) . ' ' . $t['hora'] . ':00';
$add($cuando, 'muestra',
'Toma tomada y firmada (' . $t['etiqueta'] . ')',
$t['firmante']);
}
}
if (!$firmoPaciente && !$firmoProfesional && $c['firmado_at'] && empty($c['es_toma_progresiva'])) {
$add($c['firmado_at'], 'doc_ok', 'Completado: ' . $c['formulario']);
}
if (!empty($c['es_toma_progresiva']) && $c['firmado_at']) {
$add($c['firmado_at'], 'fin', 'Protocolo cerrado: ' . $c['formulario']);
}
}
// ── 3. Comentarios del personal ───────────────────────────────────────────
// Aquí caen también las ausencias, que se guardan como comentario con su motivo.
$q = $pdo->prepare(
"SELECT creado_at, usuario_nombre, comentario, tipo
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at"
);
$q->execute([$turnoId]);
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $c) {
$add($c['creado_at'], 'comentario', $c['comentario'], $c['usuario_nombre'], $c['tipo']);
}
// ── 4. Muestras ───────────────────────────────────────────────────────────
$q = $pdo->prepare(
"SELECT m.recibida_at, m.tipo_muestra, m.estado, m.motivo_rechazo,
u.full_name AS quien
FROM turnero_muestras m
LEFT JOIN admin_users u ON u.id = m.recibida_por
WHERE m.recibida_en_turno_id = ?"
);
$q->execute([$turnoId]);
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $m) {
$texto = $m['estado'] === 'rechazada'
? 'Muestra RECHAZADA (' . $m['tipo_muestra'] . '): ' . ($m['motivo_rechazo'] ?: 'sin motivo')
: 'Muestra recibida (' . $m['tipo_muestra'] . ')';
$add($m['recibida_at'], $m['estado'] === 'rechazada' ? 'alerta' : 'muestra', $texto, $m['quien']);
}
// ── 5. Acciones administrativas auditadas ─────────────────────────────────
// El detalle es JSON; se filtra por turno en PHP para no depender de LIKE.
$q = $pdo->prepare(
"SELECT created_at, admin_nombre, accion, detalle
FROM lab_actividad_admin
WHERE modulo IN ('turnero','turnero_config') AND detalle LIKE ?"
);
$q->execute(['%"turno_id":' . $turnoId . '%']);
foreach ($q->fetchAll(PDO::FETCH_ASSOC) as $a) {
$d = json_decode($a['detalle'], true) ?: [];
if ((int)($d['turno_id'] ?? 0) !== $turnoId) continue; // evita coincidencias por prefijo
// Cada acción se traduce a algo que se entienda leyéndolo, no al nombre
// técnico del endpoint.
switch ($a['accion']) {
case 'quitar_consentimiento':
$add($a['created_at'], 'alerta',
'Formulario RETIRADO: ' . ($d['formulario_nombre'] ?? '?'),
$a['admin_nombre'],
'Motivo: ' . ($d['motivo'] ?? 'sin motivo')
. ' · estaba en «' . ($d['estado_previo'] ?? '?') . '»');
break;
case 'cambiar_estado':
$add($a['created_at'], 'admin',
'Estado cambiado a mano: ' . ($d['estado_previo'] ?? '?')
. ' → ' . ($d['estado_nuevo'] ?? '?'),
$a['admin_nombre']);
break;
case 'resetear_consentimiento':
$add($a['created_at'], 'alerta',
'Formulario REINICIADO: ' . ($d['formulario_nombre'] ?? '?'),
$a['admin_nombre'],
!empty($d['tenia_firma'])
? 'Se descartó una firma ya hecha'
: 'No tenía firma todavía');
break;
case 'resetear_toma':
$n = is_array($d['campos_olvidados'] ?? null) ? count($d['campos_olvidados']) : 0;
$add($a['created_at'], 'alerta',
'Toma olvidada en ' . ($d['formulario_nombre'] ?? 'protocolo'),
$a['admin_nombre'],
$n ? "Se descartaron $n dato(s) de la toma" : null);
break;
case 'cancelar_toma_pendiente':
$add($a['created_at'], 'alerta',
'Cancelada la toma pendiente de ' . ($d['turno_origen'] ?? 'otra visita'),
$a['admin_nombre'],
$d['formulario_nombre'] ?? null);
break;
case 'llamar_turno':
$add($a['created_at'], 'llamado', 'Llamó al paciente', $a['admin_nombre']);
break;
case 'rellamar':
$add($a['created_at'], 'llamado', 'Volvió a llamar al paciente', $a['admin_nombre']);
break;
case 'vincular_paciente':
$add($a['created_at'], 'admin',
'Paciente vinculado: ' . ($d['paciente_nombre'] ?? '#' . ($d['paciente_id'] ?? '?')),
$a['admin_nombre']);
break;
default:
$add($a['created_at'], 'admin', $a['accion'], $a['admin_nombre']);
}
}
usort($ev, fn($a, $b) => strcmp($a['cuando'], $b['cuando']));
// ── 6. Conversación de WhatsApp ───────────────────────────────────────────
// Solo el conteo del día y el enlace al chat: volcar los mensajes aquí sería
// esparcir datos personales del paciente por una pantalla de consulta.
$chat = null;
if (!empty($turno['paciente_id'])) {
$q = $pdo->prepare(
"SELECT u.id AS user_id,
(SELECT COUNT(*) FROM conversations c
WHERE c.user_id = u.id AND DATE(c.created_at) = DATE(?)) AS mensajes
FROM lab_pacientes p JOIN users u ON u.id = p.user_id
WHERE p.id = ? LIMIT 1"
);
$q->execute([$turno['creado_at'], (int)$turno['paciente_id']]);
$r = $q->fetch(PDO::FETCH_ASSOC);
if ($r && (int)$r['mensajes'] > 0) {
$chat = ['user_id' => (int)$r['user_id'], 'mensajes' => (int)$r['mensajes']];
}
}
jsonOk([
'turno' => [
'id' => (int)$turno['id'],
'codigo' => $turno['codigo'],
'estado' => $turno['estado'],
'paciente' => $turno['paciente_nombre'],
],
'eventos' => $ev,
'chat' => $chat,
// Se declara lo que el sistema NO registra, para que nadie lea la ausencia
// de un evento como prueba de que no ocurrió.
// Lo que el sistema sigue sin registrar. Se declara para que la ausencia de
// un evento no se lea como prueba de que no ocurrió.
'sinRastro' => [
'el llamado original de los turnos anteriores a hoy, que el rellamado sobrescribía',
],
]);
+15
View File
@@ -10,6 +10,7 @@
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST'); requireMethod('POST');
requireTurnero(); requireTurnero();
@@ -144,6 +145,20 @@ try {
$pdo->commit(); $pdo->commit();
notificarSSE((int) $turnoActualizado['sesion_id']); notificarSSE((int) $turnoActualizado['sesion_id']);
// Quién llamó no se guardaba en ninguna parte: el turno solo conserva la hora
// del último llamado, así que en la bitácora aparecía «Llamado a X» sin autor.
try {
(new ActividadAdmin())->registrar(
adminId(), 'turnero', 'llamar_turno', $turnoId,
[
'turno_id' => $turnoId,
'turno_codigo' => $turnoActualizado['codigo'] ?? null,
]
);
} catch (\Throwable $e) {
error_log('[llamar_turno] auditoría: ' . $e->getMessage());
}
jsonOk(['turno' => $turnoActualizado], "Turno {$turnoActualizado['codigo']} llamado"); jsonOk(['turno' => $turnoActualizado], "Turno {$turnoActualizado['codigo']} llamado");
} catch (\Throwable $e) { } catch (\Throwable $e) {
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* POST /modules/turnero/api/log_tv.php
* Recibe la bitácora de voz de la pantalla del televisor (via sendBeacon).
*
* SIN SESIÓN, a propósito: la pantalla es un kiosco público (display_global es
* ruta pública) y el envío es fire-and-forget. Por eso el endpoint es
* deliberadamente sordo y estrecho:
* - solo acepta los eventos de la lista, nada libre;
* - recorta el detalle a 500 caracteres;
* - no devuelve datos, solo un 204;
* - inserta y nada más: no lee, no borra, no actualiza.
*/
require_once __DIR__ . '/../../../config/config.php';
http_response_code(204);
header('Content-Type: text/plain');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') exit;
$raw = file_get_contents('php://input');
if ($raw === false || strlen($raw) > 2000) exit;
$d = json_decode($raw, true);
if (!is_array($d)) exit;
$evento = $d['e'] ?? '';
$PERMITIDOS = ['start', 'end', 'error', 'sin_start', 'mudo', 'lag'];
if (!in_array($evento, $PERMITIDOS, true)) exit;
unset($d['e'], $d['t']); // el timestamp lo pone la base, no el cliente
try {
$pdo = Database::getInstance()->getConnection();
$stmt = $pdo->prepare(
"INSERT INTO turnero_tv_log (evento, detalle, ip, user_agent) VALUES (?, ?, ?, ?)"
);
$stmt->execute([
$evento,
mb_substr(json_encode($d, JSON_UNESCAPED_UNICODE), 0, 500),
substr(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]), 0, 45),
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
]);
} catch (\Throwable $e) {
// La bitácora jamás debe afectar a la pantalla
error_log('[log_tv] ' . $e->getMessage());
}
+12
View File
@@ -9,6 +9,7 @@
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST'); requireMethod('POST');
requireTurnero(); requireTurnero();
@@ -48,4 +49,15 @@ $stmtT = $pdo->prepare(
$stmtT->execute([$turnoId]); $stmtT->execute([$turnoId]);
$turno = $stmtT->fetch(PDO::FETCH_ASSOC); $turno = $stmtT->fetch(PDO::FETCH_ASSOC);
// El rellamado pisa llamado_recepcion_at, así que sin esto no queda rastro de
// cuántas veces se llamó al paciente ni quién lo hizo.
try {
(new ActividadAdmin())->registrar(
adminId(), 'turnero', 'rellamar', $turnoId,
['turno_id' => $turnoId, 'turno_codigo' => $turno['codigo'] ?? null]
);
} catch (\Throwable $e) {
error_log('[rellamar] auditoría: ' . $e->getMessage());
}
jsonOk(['turno' => $turno]); jsonOk(['turno' => $turno]);
@@ -5,6 +5,7 @@
* Body JSON: { token: string } * Body JSON: { token: string }
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST'); requireMethod('POST');
requireTurnero(); requireTurnero();
@@ -14,9 +15,11 @@ if (!$token) jsonError('token requerido.');
$pdo = db(); $pdo = db();
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"SELECT tc.id, t.sesion_id "SELECT tc.id, tc.turno_id, tc.estado, tc.firmado_at, t.sesion_id, t.codigo,
f.nombre AS formulario
FROM turnero_consentimientos tc FROM turnero_consentimientos tc
JOIN turnero_turnos t ON t.id = tc.turno_id JOIN turnero_turnos t ON t.id = tc.turno_id
JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.token = ? LIMIT 1" WHERE tc.token = ? LIMIT 1"
); );
$stmt->execute([$token]); $stmt->execute([$token]);
@@ -34,4 +37,25 @@ $pdo->prepare(
)->execute([$token]); )->execute([$token]);
notificarSSE((int)$tc['sesion_id']); notificarSSE((int)$tc['sesion_id']);
// Esta acción descarta una firma ya hecha, así que tiene que dejar constancia:
// se anota qué documento era, en qué estado estaba y si tenía firma.
try {
(new ActividadAdmin())->registrar(
adminId(),
'turnero',
'resetear_consentimiento',
(int)$tc['id'],
[
'turno_id' => (int)$tc['turno_id'],
'turno_codigo' => $tc['codigo'],
'formulario_nombre' => $tc['formulario'],
'estado_previo' => $tc['estado'],
'tenia_firma' => !empty($tc['firmado_at']),
]
);
} catch (\Throwable $e) {
error_log('[resetear_consentimiento] auditoría: ' . $e->getMessage());
}
jsonOk([], 'Consentimiento reiniciado correctamente.'); jsonOk([], 'Consentimiento reiniciado correctamente.');
+25 -2
View File
@@ -5,6 +5,7 @@
* Body JSON: { token: string, campos: string[] } * Body JSON: { token: string, campos: string[] }
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST'); requireMethod('POST');
requireTurnero(); requireTurnero();
@@ -17,9 +18,11 @@ if (!is_array($campos) || empty($campos)) jsonError('campos requerido.');
$pdo = db(); $pdo = db();
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"SELECT tc.id, tc.datos_respuestas, tc.estado, t.sesion_id "SELECT tc.id, tc.turno_id, tc.datos_respuestas, tc.estado, t.sesion_id, t.codigo,
f.nombre AS formulario
FROM turnero_consentimientos tc FROM turnero_consentimientos tc
JOIN turnero_turnos t ON t.id = tc.turno_id JOIN turnero_turnos t ON t.id = tc.turno_id
JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.token = ? LIMIT 1" WHERE tc.token = ? LIMIT 1"
); );
$stmt->execute([$token]); $stmt->execute([$token]);
@@ -40,4 +43,24 @@ $pdo->prepare(
)->execute([json_encode($dr, JSON_UNESCAPED_UNICODE), $token]); )->execute([json_encode($dr, JSON_UNESCAPED_UNICODE), $token]);
notificarSSE((int)$tc['sesion_id']); notificarSSE((int)$tc['sesion_id']);
// Olvidar una toma borra datos de un protocolo prolongado en curso: se registra
// qué campos se descartaron, porque después no hay forma de saberlo.
try {
(new ActividadAdmin())->registrar(
adminId(),
'turnero',
'resetear_toma',
(int)$tc['id'],
[
'turno_id' => (int)$tc['turno_id'],
'turno_codigo' => $tc['codigo'],
'formulario_nombre' => $tc['formulario'],
'campos_olvidados' => $campos,
]
);
} catch (\Throwable $e) {
error_log('[resetear_toma] auditoría: ' . $e->getMessage());
}
jsonOk([], 'Toma olvidada.'); jsonOk([], 'Toma olvidada.');
+7
View File
@@ -32,6 +32,13 @@ header('Cache-Control: no-store, no-cache');
header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering
header('Connection: keep-alive'); header('Connection: keep-alive');
// El arranque del ERP deja un búfer de salida activo (los jsonOk() de
// _helpers hacen ob_clean(), que lo confirma). flush() a secas no lo
// atraviesa: TODO lo emitido quedaba atrapado y el cliente recibía cero
// bytes, para siempre. Por eso este endpoint parecía muerto en producción.
while (ob_get_level() > 0) { @ob_end_clean(); }
@ob_implicit_flush(true);
// ── Parámetros ──────────────────────────────────────────────── // ── Parámetros ────────────────────────────────────────────────
$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion'; $area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion';
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null; $lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
+19
View File
@@ -4,6 +4,7 @@
* Vincula un paciente al turno para que los consentimientos puedan crearse. * Vincula un paciente al turno para que los consentimientos puedan crearse.
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST'); requireMethod('POST');
requireTurnero(); requireTurnero();
@@ -27,4 +28,22 @@ $stmt->execute([$pacienteId, $nombreCompleto, $turnoId]);
if (!$stmt->rowCount()) jsonError('Turno no encontrado.', 404); if (!$stmt->rowCount()) jsonError('Turno no encontrado.', 404);
// Vincular a la persona equivocada manda la atención a otra historia clínica,
// así que queda registrado quién hizo la vinculación.
try {
(new ActividadAdmin())->registrar(
adminId(),
'turnero',
'vincular_paciente',
$turnoId,
[
'turno_id' => $turnoId,
'paciente_id' => $pacienteId,
'paciente_nombre' => $nombreCompleto,
]
);
} catch (\Throwable $e) {
error_log('[vincular_paciente] auditoría: ' . $e->getMessage());
}
jsonOk(['turno_id' => $turnoId, 'paciente_id' => $pacienteId]); jsonOk(['turno_id' => $turnoId, 'paciente_id' => $pacienteId]);
+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-user { font-weight: 600; color: #1e293b; }
.bnd-com-date { font-size: 0.7rem; color: #94a3b8; margin-left: auto; } .bnd-com-date { font-size: 0.7rem; color: #94a3b8; margin-left: auto; }
.bnd-com-text { color: #334155; } .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 */ /* Agregar comentario */
.bnd-add-com { display: flex; gap: 8px; align-items: flex-end; } .bnd-add-com { display: flex; gap: 8px; align-items: flex-end; }
.bnd-add-com textarea { .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> <button onclick="_bndEnviarCom(${t.id})"><i class="fas fa-paper-plane"></i> Enviar</button>
</div></div>`; </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 ────────────────────────────────────────────── // ── Marcar visto ──────────────────────────────────────────────
+4
View File
@@ -578,6 +578,10 @@ let state = {
// ── Init ───────────────────────────────────────────────────────────────────── // ── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadContacts(); loadContacts();
// Permite llegar directo a una conversación desde otra pantalla, por ejemplo
// desde la bitácora del turno en el historial: ?v=chat&user_id=123
const _uid = parseInt(new URLSearchParams(location.search).get('user_id') || '', 10);
if (_uid > 0) openChat(_uid);
document.getElementById('searchInput').addEventListener('input', debounce(() => { document.getElementById('searchInput').addEventListener('input', debounce(() => {
loadContacts(document.getElementById('searchInput').value.trim()); loadContacts(document.getElementById('searchInput').value.trim());
}, 300)); }, 300));
+25 -10
View File
@@ -47,6 +47,8 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
.kpi-card .kpi-val { font-size:2rem; font-weight:800; line-height:1.1; } .kpi-card .kpi-val { font-size:2rem; font-weight:800; line-height:1.1; }
.kpi-card .kpi-lbl { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#64748b; margin-top:4px; } .kpi-card .kpi-lbl { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#64748b; margin-top:4px; }
.kpi-card .kpi-sub { font-size:.75rem; color:#94a3b8; margin-top:2px; } .kpi-card .kpi-sub { font-size:.75rem; color:#94a3b8; margin-top:2px; }
.kpi-ayuda { font-size:.62rem; color:#94a3b8; line-height:1.3; margin-top:5px;
border-top:1px solid #f1f5f9; padding-top:5px; }
/* ── Charts / tables section ── */ /* ── Charts / tables section ── */
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; margin-bottom:20px; } .section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; margin-bottom:20px; }
@@ -484,14 +486,22 @@ function renderDashboard(json) {
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0; const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
document.getElementById('kpiGrid').innerHTML = ` document.getElementById('kpiGrid').innerHTML = `
${kpiCard(total, '📋 Total turnos', '')} ${kpiCard(total, '📋 Turnos del día', '',
${kpiCard(atendidos,'✅ Atendidos', pct + '%')} 'Todos los turnos generados hoy, sin importar cómo terminaron.')}
${kpiCard(enEspera, '⏳ En espera', '')} ${kpiCard(atendidos, '✅ Atendidos', pct + ' % del día',
${kpiCard(ausentes, '🚫 Ausentes', '')} 'Ya terminaron su atención, más los que están siendo atendidos en este momento.')}
${kpiCard(cancelados,'❌ Cancelados', '')} ${kpiCard(enEspera, '⏳ Esperando', '',
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera prom.', 'hasta recepción')} 'Todavía no los llaman, o ya salieron de recepción y esperan el puesto de toma.')}
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Servicio prom.', 'en lugar')} ${kpiCard(ausentes, '🚫 No se presentaron', '',
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total prom.', 'puerta a puerta')} 'Se les llamó y no llegaron. Recepción registra el motivo al marcarlos.')}
${kpiCard(cancelados, '❌ Cancelados', '',
'Turnos anulados antes de atenderlos.')}
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera para recepción', 'sacar el turno → lo llaman',
'Cuánto aguarda el paciente sentado desde que saca el turno hasta que recepción lo atiende.')}
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Tiempo en el puesto', 'toma de muestras',
'Lo que dura la toma. En protocolos prolongados (curvas) se cuenta hasta la primera toma: las horas de espera del examen no son trabajo del puesto.')}
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total en el laboratorio', 'llegada → salida',
'Todo el recorrido: espera + recepción + espera del puesto + toma. Por eso es mayor que la suma de las dos casillas anteriores.')}
`; `;
// ── Consentimientos ──────────────────────────────────── // ── Consentimientos ────────────────────────────────────
@@ -584,11 +594,16 @@ function renderPersonas(sectionId, gridId, filas, columnas) {
</div>`).join(''); </div>`).join('');
} }
function kpiCard(val, lbl, sub) { function kpiCard(val, lbl, sub, ayuda) {
return `<div class="kpi-card"> // `ayuda` explica de dónde sale el número. Las etiquetas cortas se prestan
// para malentendidos —«servicio» no es lo mismo que «tiempo en el
// laboratorio»— y quien lee el tablero no tiene por qué adivinarlo.
const t = ayuda ? ` title="${escHtml(ayuda)}"` : '';
return `<div class="kpi-card"${t}>
<div class="kpi-val">${val !== null && val !== undefined ? escHtml(String(val)) : '—'}</div> <div class="kpi-val">${val !== null && val !== undefined ? escHtml(String(val)) : '—'}</div>
<div class="kpi-lbl">${lbl}</div> <div class="kpi-lbl">${lbl}</div>
${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''} ${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''}
${ayuda ? `<div class="kpi-ayuda">${escHtml(ayuda)}</div>` : ''}
</div>`; </div>`;
} }
+144 -18
View File
@@ -633,15 +633,21 @@ function getVozES() {
} }
if ('speechSynthesis' in window) { if ('speechSynthesis' in window) {
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); }; window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
// Keepalive: Chrome suspende speechSynthesis tras un rato sin uso. // Keepalive con TRABAJO MUDO. El motor de voz (comprobado en macOS, y
// Solo debe correr con el sintetizador en reposo: pause() a mitad de una // reportado también en Windows) se duerme tras un rato de ocio, y el primer
// frase la corta, y como el anuncio dura más de 10 segundos, antes lo // speak() de ahí en adelante se ATASCA: speaking=true, cero audio, ni
// troceaba siempre. // 'start' ni 'error' jamás. pause()/resume() no lo mantiene despierto —
// se verificó en vivo el 23/08: el primer llamado tras el ocio salía solo
// con el pito. Lo único que lo mantiene despierto es hablar de verdad:
// una letra a volumen cero cada 25 s, inaudible, con el motor en reposo.
setInterval(() => { setInterval(() => {
if (!sonidoActivo) return;
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return; if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
window.speechSynthesis.pause(); const u = new SpeechSynthesisUtterance('a');
u.volume = 0; u.rate = 2;
window.speechSynthesis.resume(); window.speechSynthesis.resume();
}, 10000); window.speechSynthesis.speak(u);
}, 25000);
} }
// El letrero ya no depende de localStorage sino de si el navegador dejó sonar: // El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
// lo decide revisarBloqueo(), que corre al arrancar y en cada intento. // lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
@@ -651,6 +657,58 @@ if ('speechSynthesis' in window) {
// A nivel de archivo porque showAnnouncement() también lo necesita. // A nivel de archivo porque showAnnouncement() también lo necesita.
const BEEP_MS = 700; const BEEP_MS = 700;
// ── Anclaje contra el recolector de basura ──
// Defecto conocido de Chrome: si nada referencia la SpeechSynthesisUtterance,
// el recolector puede llevársela A MITAD DE FRASE. El audio se corta en seco y
// 'end' no llega nunca. Como depende de cuándo pase el recolector, ocurre "a
// veces" y nunca se reproduce a voluntad. Cada locución queda anclada aquí
// hasta que termina.
const _uttAncladas = new Set();
// Token de generación: cancel() dispara 'error' (interrupted) sobre la
// locución vieja, y sin este guardián ese handler volvía a lanzar el
// reintento: DOS voces superpuestas diciendo lo mismo, a veces. Cada decir()
// toma un número; si al disparársele un evento ya no es el vigente, la
// locución fue superada (por un reintento o por el anuncio siguiente) y sus
// handlers no deben hacer nada más que soltar el anclaje.
let _vozGen = 0;
// Vigía de congelamiento: Chrome frena los relojes de una pestaña oculta a ~1
// tic por minuto. En ese estado la pantalla NO VE los llamados intermedios y
// no anuncia — y desde afuera parece un fallo de la voz. Si entre dos tics
// pasan más de 10 s, se deja constancia con el tamaño del hueco.
let _ultimoTic = Date.now();
setInterval(() => {
const ahora = Date.now();
const hueco = ahora - _ultimoTic;
_ultimoTic = ahora;
if (hueco > 10000) vozLog('lag', { gap_s: Math.round(hueco / 1000) });
}, 2000);
document.addEventListener('visibilitychange', () => {
if (document.hidden) vozLog('lag', { oculta: 1 });
});
// ── Bitácora de la voz ──
// Los fallos intermitentes no se pueden depurar mirando la pantalla: hay que
// registrarlos cuando ocurren. Cada evento va a un anillo local (últimos 200,
// consultable con localStorage.tvVozLog en la consola del televisor) y al
// servidor con sendBeacon, que no bloquea ni exige respuesta.
function vozLog(evento, datos) {
const fila = Object.assign({ t: new Date().toISOString().slice(0, 19), e: evento }, datos || {});
try {
const ring = JSON.parse(localStorage.getItem('tvVozLog') || '[]');
ring.push(fila);
while (ring.length > 200) ring.shift();
localStorage.setItem('tvVozLog', JSON.stringify(ring));
} catch (_) {}
try {
navigator.sendBeacon(
'<?= defined('BASE_URL') ? BASE_URL : '/' ?>modules/turnero/api/log_tv.php',
new Blob([JSON.stringify(fila)], { type: 'application/json' })
);
} catch (_) {}
}
/** /**
* Dice el llamado en voz alta. * Dice el llamado en voz alta.
* *
@@ -685,25 +743,72 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
utt.lang = 'es-CO'; utt.lang = 'es-CO';
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1; utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
_uttAncladas.add(utt);
const soltar = () => _uttAncladas.delete(utt);
const pedido = Date.now();
const gen = ++_vozGen;
const vigente = () => gen === _vozGen;
let arranco = false; let arranco = false;
utt.addEventListener('start', () => { arranco = true; }); utt.addEventListener('start', () => {
utt.addEventListener('end', avisar); arranco = true;
utt.addEventListener('error', () => esReintento ? avisar() : decir(true)); if (!vigente()) return;
vozLog('start', { cod: codigo, ms: Date.now() - pedido,
voz: utt.voice ? utt.voice.name : '(navegador)',
local: utt.voice ? !!utt.voice.localService : null,
re: esReintento ? 1 : 0 });
});
utt.addEventListener('end', () => {
soltar();
if (!vigente()) return; // la canceló un reintento o el anuncio siguiente
vozLog('end', { cod: codigo, dur: Date.now() - pedido });
avisar();
});
utt.addEventListener('error', (ev) => {
soltar();
if (!vigente()) return; // 'interrupted' por un cancel nuestro: no reintentar
vozLog('error', { cod: codigo, err: ev.error || '?', re: esReintento ? 1 : 0 });
if (esReintento) { avisar(); return; }
// Invalidar ANTES de cancelar: el cancel dispara eventos sobre esta
// misma locución y sin esto el reintento salía por partida doble.
_vozGen++;
window.speechSynthesis.cancel();
setTimeout(() => decir(true), 500);
});
// Que speak() no lance nada no significa que vaya a sonar: si el // Que speak() no lance nada no significa que vaya a sonar: si el
// navegador bloquea el audio, o la voz no sirve, no pasa absolutamente // navegador bloquea el audio, o la voz no sirve, no pasa absolutamente
// nada y 'error' tampoco llega. Solo se nota porque 'start' no ocurre. // nada y 'error' tampoco llega. Solo se nota porque 'start' no ocurre.
// Rendirse con esta locución: romper el atasco y pasar al reintento
// (o, si ya era el reintento, declarar mudo el llamado y soltar el cartel).
const rendirse = () => {
soltar();
if (esReintento) {
vozLog('mudo', { cod: codigo });
avisar(); return;
}
// atasco=1: el motor JURA estar hablando (speaking) pero 'start'
// nunca llegó — el modo de fallo verificado en vivo el 23/08.
vozLog('sin_start', { cod: codigo,
atasco: window.speechSynthesis.speaking ? 1 : 0 });
_vozGen++; // los eventos del cancel no valen
window.speechSynthesis.cancel();
setTimeout(() => decir(true), 500); // respiro: rehablar YA se atasca
};
const margen = (esReintento ? 0 : BEEP_MS) + 2500; const margen = (esReintento ? 0 : BEEP_MS) + 2500;
setTimeout(() => { setTimeout(() => {
if (!vigente()) { soltar(); return; }
if (arranco) return; if (arranco) return;
// Antes de darla por fallida hay que preguntarle al sintetizador: // pending sin speaking = voz remota descargando: una prórroga de 6 s
// una voz remota tarda en arrancar porque se baja de internet, y // y solo entonces se da por perdida. Cortarla de una fue el defecto
// cancelarla aquí era cortarle la frase y repetirla con otra voz. // del entrecortado original.
// De ahí que a veces se oyera media frase y luego otra distinta. if (window.speechSynthesis.pending && !window.speechSynthesis.speaking) {
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return; setTimeout(() => { if (!vigente() || arranco) return; rendirse(); }, 6000);
if (esReintento) { avisar(); return; } return;
window.speechSynthesis.cancel(); }
decir(true); // segunda oportunidad, con la voz del navegador // speaking sin 'start' = ATASCO (finge hablar sin audio). Una locución
// que de verdad suena dispara 'start' de inmediato: romperlo.
rendirse();
}, margen); }, margen);
// Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un // Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un
@@ -721,8 +826,12 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
const voz = getVozES(); const voz = getVozES();
if (voz) utt.voice = voz; if (voz) utt.voice = voz;
} }
// Tras un cancel(), en Windows y ChromeOS el motor puede quedar en
// pausa: speak() encola y jamás suena, sin error alguno. resume()
// sobre un motor sano no hace nada, así que se llama siempre.
window.speechSynthesis.resume();
window.speechSynthesis.speak(utt); window.speechSynthesis.speak(utt);
}, esReintento ? 0 : BEEP_MS); }, esReintento ? 250 : BEEP_MS);
}; };
decir(false); decir(false);
@@ -957,6 +1066,23 @@ cargarSnapshot();
// 1 s en vez de 2: el llamado salía con hasta 2 s de retraso desde el clic // 1 s en vez de 2: el llamado salía con hasta 2 s de retraso desde el clic
setInterval(cargarSnapshot, 1000); setInterval(cargarSnapshot, 1000);
// ── Despertador SSE ──
// Esta pantalla corre en la ventana de un computador de uso mixto: puede
// quedar tapada o minimizada, y Chrome congela los relojes de las pestañas
// ocultas (~1 tic/min). El sondeo de arriba se paraliza y los llamados no
// suenan. Los eventos de red NO se congelan: el servidor emite cola_update en
// cada llamado y rellamado (sse_ping_at), y ese mensaje despierta la pantalla
// aunque esté de fondo. El sondeo queda como respaldo si el SSE se cae; si
// ambos fallan, el vigía de congelamiento lo deja anotado en la bitácora.
(function conectarSSE() {
if (!('EventSource' in window)) return;
try {
const es = new EventSource(BASE_API + 'sse_turno.php?area=recepcion');
es.addEventListener('cola_update', () => { try { cargarSnapshot(); } catch (_) {} });
// EventSource se reconecta solo tras un error; no hay nada que hacer aquí
} catch (_) {}
})();
document.addEventListener('visibilitychange', () => { if (!document.hidden) cargarSnapshot(); }); document.addEventListener('visibilitychange', () => { if (!document.hidden) cargarSnapshot(); });
window.addEventListener('focus', cargarSnapshot); window.addEventListener('focus', cargarSnapshot);
+110 -6
View File
@@ -110,6 +110,14 @@ Layout::open('Historial de Turnos', 'fas fa-history');
/* ── Documentos firmados ── */ /* ── Documentos firmados ── */
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; } .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; } .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; .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; } font-size:.73rem; font-weight:600; margin:2px 2px; text-decoration:none; transition:opacity .15s; }
@@ -472,11 +480,19 @@ function renderTabla(turnos) {
const bacterio = esc(t.atendido_lugar_nombre || '—'); const bacterio = esc(t.atendido_lugar_nombre || '—');
// Exámenes: badges con código // Exámenes: badges con código
const examsHtml = (t.examenes && t.examenes.length) // Solo el conteo: con 9,5 exámenes de promedio por turno, las etiquetas de
? t.examenes.map(e => `<span class="badge bg-success-subtle text-success me-1 mb-1" style="font-size:.65rem">${esc(e.codigo)}</span>`).join('') // código llenaban cinco renglones y la tabla quedaba ilegible. Los nombres
// completos ya están en el detalle desplegable, así que no se pierde nada.
const nExam = (t.examenes && t.examenes.length) ? t.examenes.length : 0;
const examsHtml = nExam
? `<span class="badge bg-success-subtle text-success" style="font-size:.68rem"
title="${esc(t.examenes.map(e => e.nombre || e.codigo).join(' · '))}">
${nExam} examen${nExam === 1 ? '' : 'es'}
</span>`
: '<span class="text-muted small">—</span>'; : '<span class="text-muted small">—</span>';
return `<tr data-id="${t.id}"> return `<tr data-id="${t.id}" style="cursor:pointer"
onclick="filaDetalle(event, '${rowId}', ${t.id})">
<td class="text-nowrap text-muted small">${esc(fecha)}</td> <td class="text-nowrap text-muted small">${esc(fecha)}</td>
<td><strong style="color:${priCls}">${esc(t.codigo)}</strong></td> <td><strong style="color:${priCls}">${esc(t.codigo)}</strong></td>
<td> <td>
@@ -496,19 +512,19 @@ function renderTabla(turnos) {
<div class="fw-semibold">${lugar}</div> <div class="fw-semibold">${lugar}</div>
<div class="text-muted" style="font-size:.72rem"><i class="fas fa-microscope me-1 opacity-50"></i>${bacterio}</div> <div class="text-muted" style="font-size:.72rem"><i class="fas fa-microscope me-1 opacity-50"></i>${bacterio}</div>
</td> </td>
<td style="max-width:140px">${examsHtml}</td> <td class="text-nowrap">${examsHtml}</td>
<td><span class="eb ${cls}">${lbl}</span></td> <td><span class="eb ${cls}">${lbl}</span></td>
<td class="text-nowrap">${espMin}</td> <td class="text-nowrap">${espMin}</td>
<td class="text-nowrap">${srvMin}</td> <td class="text-nowrap">${srvMin}</td>
<td class="text-nowrap">${totMin}</td> <td class="text-nowrap">${totMin}</td>
<td class="text-nowrap"> <td class="text-nowrap">
<button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1" <button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1"
onclick="toggleDetalle('${rowId}', this, ${t.id})" onclick="event.stopPropagation();toggleDetalle('${rowId}', this, ${t.id})"
title="Ver detalles"> title="Ver detalles">
<i class="fas fa-chevron-down" style="font-size:.65rem"></i> <i class="fas fa-chevron-down" style="font-size:.65rem"></i>
</button> </button>
<button class="btn btn-sm btn-outline-warning py-0 px-2" <button class="btn btn-sm btn-outline-warning py-0 px-2"
onclick="abrirModalEstado(${t.id}, '${t.estado}', '${nombre}')" onclick="event.stopPropagation();abrirModalEstado(${t.id}, '${t.estado}', '${nombre}')"
title="Cambiar estado"> title="Cambiar estado">
<i class="fas fa-exchange-alt" style="font-size:.65rem"></i> <i class="fas fa-exchange-alt" style="font-size:.65rem"></i>
</button> </button>
@@ -594,12 +610,32 @@ function renderDetalle(t) {
</div>`).join('')} </div>`).join('')}
</div> </div>
${relHtml}${examHtml}${comHtml} ${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-section" id="docs-${t.id}">
<div class="docs-lbl"><i class="fas fa-file-signature me-1"></i>Documentos</div> <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> <span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
</div>`; </div>`;
} }
/**
* Clic en cualquier parte de la fila: abre o cierra su detalle.
*
* Se ignora si el usuario estaba seleccionando texto —copiar un documento o un
* nombre es frecuente aquí y sería molesto que la fila se abriera al soltar— y
* si el clic cayó sobre un enlace o un botón, que tienen lo suyo que hacer.
*/
function filaDetalle(ev, rowId, turnoId) {
if (ev.target.closest('button, a, input, select')) return;
const sel = window.getSelection();
if (sel && sel.toString().length > 0) return;
const fila = document.querySelector(`tr[data-id="${turnoId}"]`);
const btn = fila ? fila.querySelector('button[title="Ver detalles"]') : null;
if (btn) toggleDetalle(rowId, btn, turnoId);
}
function toggleDetalle(rowId, btn, turnoId) { function toggleDetalle(rowId, btn, turnoId) {
const row = document.getElementById(rowId); const row = document.getElementById(rowId);
const icon = btn.querySelector('i'); const icon = btn.querySelector('i');
@@ -614,11 +650,79 @@ function toggleDetalle(rowId, btn, turnoId) {
docsEl.dataset.loaded = '1'; docsEl.dataset.loaded = '1';
cargarDocumentosTurno(turnoId, docsEl); 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 ?>'; 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) { async function cargarDocumentosTurno(turnoId, container) {
try { try {
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`); const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
+10
View File
@@ -1002,6 +1002,14 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<label class="mpac-lbl">EPS</label> <label class="mpac-lbl">EPS</label>
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-eps"> <input type="text" class="form-control form-control-sm mb-2" id="mpac-in-eps">
<label class="mpac-lbl">Sexo</label>
<select class="form-select form-select-sm mb-2" id="mpac-in-genero">
<option value="">— Sin registrar —</option>
<option value="M">Masculino</option>
<option value="F">Femenino</option>
<option value="O">Otro</option>
</select>
<!-- Datos de identidad: requieren un paso deliberado --> <!-- Datos de identidad: requieren un paso deliberado -->
<div id="mpac-identidad-lock" class="mpac-lock"> <div id="mpac-identidad-lock" class="mpac-lock">
<div class="mpac-lock-txt"> <div class="mpac-lock-txt">
@@ -2521,6 +2529,7 @@ function mpacEditar(abrir) {
document.getElementById('mpac-in-tel').value = p.telefono || p.celular || ''; document.getElementById('mpac-in-tel').value = p.telefono || p.celular || '';
document.getElementById('mpac-in-dir').value = p.direccion || ''; document.getElementById('mpac-in-dir').value = p.direccion || '';
document.getElementById('mpac-in-eps').value = p.eps || ''; document.getElementById('mpac-in-eps').value = p.eps || '';
document.getElementById('mpac-in-genero').value = p.genero || '';
document.getElementById('mpac-in-nombre').value = p.nombre_completo || ''; document.getElementById('mpac-in-nombre').value = p.nombre_completo || '';
document.getElementById('mpac-in-tipodoc').value = p.tipo_documento || 'CC'; document.getElementById('mpac-in-tipodoc').value = p.tipo_documento || 'CC';
document.getElementById('mpac-in-doc').value = p.numero_documento || p.documento || ''; document.getElementById('mpac-in-doc').value = p.numero_documento || p.documento || '';
@@ -2557,6 +2566,7 @@ async function mpacGuardarFicha() {
telefono: val('mpac-in-tel'), telefono: val('mpac-in-tel'),
direccion: val('mpac-in-dir'), direccion: val('mpac-in-dir'),
eps: val('mpac-in-eps'), eps: val('mpac-in-eps'),
genero: val('mpac-in-genero'),
}; };
// Los datos de identidad solo se envían si se desbloquearon // Los datos de identidad solo se envían si se desbloquearon
if (!document.getElementById('mpac-identidad').classList.contains('d-none')) { if (!document.getElementById('mpac-identidad').classList.contains('d-none')) {
+40 -3
View File
@@ -1553,7 +1553,7 @@ function abrirFicha(turno) {
.catch(() => {}); .catch(() => {});
} else if (turno.paciente_nombre && /^\d{5,15}$/.test(turno.paciente_nombre.trim())) { } else if (turno.paciente_nombre && /^\d{5,15}$/.test(turno.paciente_nombre.trim())) {
// El kiosko capturó una cédula como nombre — consultar RIPS de inmediato // El kiosko capturó una cédula como nombre — consultar RIPS de inmediato
consultarExamenesRips(turno.paciente_nombre.trim()); iniciarSondeoRips(turno.paciente_nombre.trim());
} }
// Cargar consentimientos del desk actual para este turno // Cargar consentimientos del desk actual para este turno
@@ -1673,7 +1673,7 @@ function seleccionarPaciente(pac) {
// Consultar exámenes recientes en RIPS (últimos 5 min) // Consultar exámenes recientes en RIPS (últimos 5 min)
const cedula = (pac.numero_documento || pac.documento || '').toString().trim(); const cedula = (pac.numero_documento || pac.documento || '').toString().trim();
if (cedula) consultarExamenesRips(cedula); if (cedula) iniciarSondeoRips(cedula);
filtrarExamenesPorGenero(); filtrarExamenesPorGenero();
} }
@@ -1696,6 +1696,31 @@ function filtrarExamenesPorGenero() {
// ── Exámenes desde RIPS ─────────────────────────────────────── // ── Exámenes desde RIPS ───────────────────────────────────────
let _ripsData = null; let _ripsData = null;
// ── Sondeo RIPS ──
// La facturación en el sistema del laboratorio ocurre MIENTRAS la
// recepcionista atiende, así que consultar una sola vez al vincular al
// paciente casi siempre llega temprano: en 7 días, 547 de 549 registros RIPS
// aparecieron DESPUÉS de esa única consulta, y nadie volvía a preguntar. Eso
// es lo que se percibía como "RIPS se demora". Se sondea cada 10 s hasta 10
// minutos, y se detiene al encontrar, al limpiar la ficha o al crear el turno.
let _ripsSondeoId = null;
let _ripsSondeoQuedan = 0;
function iniciarSondeoRips(cedula) {
detenerSondeoRips();
if (!cedula) return;
_ripsSondeoQuedan = 60; // 60 intentos × 10 s = 10 min
consultarExamenesRips(cedula); // el primero, de inmediato
_ripsSondeoId = setInterval(() => {
if (--_ripsSondeoQuedan <= 0 || _ripsData) { detenerSondeoRips(); return; }
consultarExamenesRips(cedula);
}, 10000);
}
function detenerSondeoRips() {
if (_ripsSondeoId) { clearInterval(_ripsSondeoId); _ripsSondeoId = null; }
}
async function consultarExamenesRips(cedula) { async function consultarExamenesRips(cedula) {
_ripsData = null; _ripsData = null;
document.getElementById('banner-rips').classList.add('d-none'); document.getElementById('banner-rips').classList.add('d-none');
@@ -1704,6 +1729,15 @@ async function consultarExamenesRips(cedula) {
const r = await fetch(`${BASE_WA}api/lab/get_examenes_rips.php?cedula=${encodeURIComponent(cedula)}`); const r = await fetch(`${BASE_WA}api/lab/get_examenes_rips.php?cedula=${encodeURIComponent(cedula)}`);
const d = await r.json(); const d = await r.json();
if (!d.ok || !d.encontrados?.length) return; if (!d.ok || !d.encontrados?.length) return;
// La respuesta pudo llegar DESPUÉS de que la recepcionista cambió de
// ficha: sin esta comprobación, los exámenes de un paciente se
// cargarían en la ficha del siguiente. Si la cédula ya no corresponde
// a quien está en pantalla, la respuesta se descarta.
const cedulaEnPantalla = (pacienteActivo?.numero_documento
|| pacienteActivo?.documento
|| turnoActivo?.paciente_nombre || '').toString().trim();
if (cedulaEnPantalla !== cedula) { detenerSondeoRips(); return; }
_ripsData = d; _ripsData = d;
const hora = d.hora ? ' (' + String(d.hora).slice(0, 5) + ')' : ''; const hora = d.hora ? ' (' + String(d.hora).slice(0, 5) + ')' : '';
const warn = document.getElementById('banner-rips-warn'); const warn = document.getElementById('banner-rips-warn');
@@ -1715,7 +1749,9 @@ async function consultarExamenesRips(cedula) {
} }
// Si los exámenes vienen del cache local (enviados por RIPS scheduler) se cargan // Si los exámenes vienen del cache local (enviados por RIPS scheduler) se cargan
// automáticamente. Si vienen del pull en vivo se muestra el banner para confirmación. // automáticamente. Si vienen del pull en vivo se muestra el banner para confirmación.
if (d.fuente === 'cache') { detenerSondeoRips();
const yaHayManuales = examTS && examTS.items && examTS.items.length > 0;
if (d.fuente === 'cache' && !yaHayManuales) {
await cargarExamenesRips(); await cargarExamenesRips();
} else { } else {
document.getElementById('banner-rips-txt').textContent = document.getElementById('banner-rips-txt').textContent =
@@ -1760,6 +1796,7 @@ async function cargarExamenesRips() {
} }
function descartarRips() { function descartarRips() {
detenerSondeoRips();
_ripsData = null; _ripsData = null;
document.getElementById('banner-rips').classList.add('d-none'); document.getElementById('banner-rips').classList.add('d-none');
} }
+118
View File
@@ -0,0 +1,118 @@
/**
* scripts/test_rips_sondeo.js — node scripts/test_rips_sondeo.js
*
* Pruebas del sondeo RIPS contra el código real de recepcion.php.
*
* El defecto que esto vigila: la consulta a RIPS se hacía UNA vez al vincular
* al paciente, y en 7 días 547 de 549 registros llegaron después de esa única
* consulta. El sondeo reintenta cada 10 s hasta 10 minutos y debe detenerse
* al encontrar, al limpiar la ficha, o al agotar los intentos.
*/
const fs = require('fs');
let fallos = 0;
const ok = (d, c) => { console.log((c ? ' ok ' : ' FALLA ') + d); if (!c) fallos++; };
const src = fs.readFileSync(__dirname + '/../modules/turnero/views/recepcion.php', 'utf8')
.replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"');
// Se extraen exactamente las tres piezas del control de sondeo
const m = src.match(/let _ripsSondeoId[\s\S]*?function detenerSondeoRips\(\) \{[\s\S]*?\n\}/);
if (!m) { console.log(' FALLA no se encontró el bloque de sondeo'); process.exit(1); }
function armar() {
let consultas = 0;
const intervalos = new Map();
let proximoId = 1;
const setIntervalFake = (fn, ms) => { const id = proximoId++; intervalos.set(id, { fn, ms }); return id; };
const clearIntervalFake = (id) => intervalos.delete(id);
const api = new Function(
'setInterval', 'clearInterval', 'INTERVALOS', 'CONTADOR',
'let _ripsData = null;' +
'const consultarExamenesRips = (c) => { CONTADOR.n++; };' +
m[0] + `;
return {
iniciar: iniciarSondeoRips,
detener: detenerSondeoRips,
tic() { for (const v of [...INTERVALOS.values()]) v.fn(); },
activo() { return _ripsSondeoId !== null; },
encontrado() { _ripsData = { encontrados: [1] }; },
};`
)(setIntervalFake, clearIntervalFake, intervalos, (globalThis.__c = { n: 0 }));
return { api, contador: globalThis.__c, intervalos };
}
// ── 1. Consulta de inmediato y reintenta ────────────────────────────────
{
const { api, contador } = armar();
api.iniciar('12345678');
ok('consulta de inmediato al vincular', contador.n === 1);
api.tic(); api.tic(); api.tic();
ok('reintenta en cada ciclo mientras no encuentra', contador.n === 4);
}
// ── 2. Al encontrar, deja de sondear ────────────────────────────────────
{
const { api, contador } = armar();
api.iniciar('12345678');
api.tic();
api.encontrado();
api.tic(); // este ciclo detecta _ripsData y se apaga
const n = contador.n;
api.tic(); api.tic();
ok('al encontrar los exámenes se apaga y no consulta más',
contador.n === n && !api.activo());
}
// ── 3. Limpiar la ficha lo detiene ──────────────────────────────────────
{
const { api, contador } = armar();
api.iniciar('12345678');
api.detener();
const n = contador.n;
api.tic(); api.tic();
ok('limpiar la ficha detiene el sondeo en seco', contador.n === n && !api.activo());
}
// ── 4. Tope de intentos: no sondea para siempre ─────────────────────────
{
const { api, contador } = armar();
api.iniciar('12345678');
for (let i = 0; i < 80; i++) api.tic();
ok('se rinde tras ~60 intentos (10 min), no queda sondeando eternamente',
contador.n <= 61 && !api.activo());
}
// ── 5. Vincular a otro paciente reinicia sin duplicar intervalos ────────
{
const { api, contador, intervalos } = armar();
api.iniciar('11111111');
api.iniciar('22222222');
ok('re-vincular no deja dos sondeos corriendo a la vez', intervalos.size === 1);
}
// ── 6. Sin cédula no arranca ────────────────────────────────────────────
{
const { api, contador } = armar();
api.iniciar('');
ok('sin cédula no consulta ni deja intervalos', contador.n === 0 && !api.activo());
}
// ── 7. Guardián de ficha: una respuesta tardía no aterriza en otro paciente ──
// La respuesta del fetch puede llegar DESPUÉS de que la recepcionista cambió
// de ficha. Este caso vigila que el código compare la cédula de la respuesta
// contra la de quien está en pantalla ANTES de cargar nada. Es estructural
// (el código real usa fetch y DOM), pero si alguien borra el guardián, falla.
{
const i = src.indexOf('get_examenes_rips.php?cedula=');
const j = src.indexOf('cedulaEnPantalla', i);
const k = src.indexOf("d.fuente === 'cache'", i);
ok('el guardián de cédula existe y corre ANTES de cargar los exámenes',
i > -1 && j > -1 && k > -1 && j < k);
ok('el guardián descarta y detiene el sondeo si la ficha cambió',
/cedulaEnPantalla !== cedula\) \{ detenerSondeoRips\(\); return; \}/.test(src));
}
console.log();
console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.');
process.exit(fallos === 0 ? 0 : 1);
+84
View File
@@ -0,0 +1,84 @@
<?php
/**
* scripts/test_rips_ventana.php — php scripts/test_rips_ventana.php
*
* Prueba la VENTANA de la caché RIPS (la consulta de get_examenes_rips.php)
* contra la base real, en una tabla TEMPORARY que se esfuma al desconectar:
* ni toca ni ensucia los datos de producción.
*
* Reglas que vigila:
* - un registro de hace 5 minutos se encuentra;
* - uno de hace más de 30 minutos NO (ventana);
* - uno ya asignado a un turno NO (turno_id);
* - uno de ayer NO (solo el día actual);
* - de dos del mismo documento gana el más reciente.
*/
require_once __DIR__ . '/../config/config.php';
$fallos = 0;
function ok(string $d, bool $c): void {
global $fallos;
echo ($c ? ' ok ' : ' FALLA ') . $d . "\n";
if (!$c) $fallos++;
}
$pdo = Database::getInstance()->getConnection();
$pdo->exec("CREATE TEMPORARY TABLE tmp_rips LIKE rips_examenes_pendientes");
$ins = $pdo->prepare(
"INSERT INTO tmp_rips (numero_documento, datos, recepcion_id, hora_recepcion, created_at, turno_id)
VALUES (?, ?, ?, ?, ?, ?)"
);
// (documento, minutos hacia atrás de created_at, turno_id)
$casos = [
['1111', 5, null], // reciente y libre → debe aparecer
['2222', 45, null], // fuera de la ventana de 30 min
['3333', 5, 999], // ya usado por un turno
['4444', 1500, null], // de ayer
['5555', 20, null], // mismo doc, dos registros…
['5555', 3, null], // …gana el más reciente (marcado v2)
];
foreach ($casos as $i => [$doc, $min, $turno]) {
$ins->execute([
$doc,
json_encode([['cod_examen' => 'T' . $i, 'v' => $i === 5 ? 'v2' : 'v1']]),
1000 + $i,
'08:00:00',
date('Y-m-d H:i:s', time() - $min * 60),
$turno,
]);
}
// La MISMA consulta de get_examenes_rips.php, apuntada a la tabla temporal
$q = $pdo->prepare(
"SELECT datos FROM tmp_rips
WHERE numero_documento = ?
AND DATE(created_at) = CURDATE()
AND created_at >= NOW() - INTERVAL 30 MINUTE
AND turno_id IS NULL
ORDER BY created_at DESC
LIMIT 1"
);
$buscar = function (string $doc) use ($q) {
$q->execute([$doc]);
$r = $q->fetch(PDO::FETCH_ASSOC);
return $r ? json_decode($r['datos'], true) : null;
};
ok('un registro de hace 5 minutos se encuentra', $buscar('1111') !== null);
ok('uno de hace 45 minutos queda fuera de la ventana', $buscar('2222') === null);
ok('uno ya asignado a un turno no se reutiliza', $buscar('3333') === null);
ok('uno de ayer no aparece aunque el documento coincida', $buscar('4444') === null);
$r = $buscar('5555');
ok('con dos del mismo documento gana el más reciente', ($r[0]['v'] ?? '') === 'v2');
// El re-empuje del scheduler (DELETE + INSERT) refresca created_at, así que un
// registro viejo que el sistema legado reenvía vuelve a entrar en la ventana.
$pdo->exec("DELETE FROM tmp_rips WHERE numero_documento = '2222'");
$ins->execute(['2222', json_encode([['cod_examen' => 'RE']]), 2000, '08:00:00',
date('Y-m-d H:i:s'), null]);
ok('un re-empuje del scheduler vuelve a hacer visible el registro', $buscar('2222') !== null);
echo "\n" . ($fallos === 0 ? "Todo correcto.\n" : "$fallos pruebas fallaron.\n");
exit($fallos === 0 ? 0 : 1);
+292
View File
@@ -0,0 +1,292 @@
/**
* scripts/test_tv_voz.js — node scripts/test_tv_voz.js
*
* Pruebas de la voz del televisor CONTRA EL CÓDIGO REAL: se extrae el bloque
* de anuncios de display_global.php y se ejecuta con un sintetizador simulado.
* Cubre los tres fallos intermitentes diagnosticados:
* 1. recolector de basura llevándose la locución a mitad de frase
* 2. motor atascado en pausa tras un cancel() (silencio total sin error)
* 3. voz que nunca arranca (bloqueo o voz rota) dejando el cartel pegado
*/
const fs = require('fs');
let fallos = 0;
function ok(desc, cond) {
console.log((cond ? ' ok ' : ' FALLA ') + desc);
if (!cond) fallos++;
}
// ── Extraer el código real ──────────────────────────────────────────────
const src = fs.readFileSync(__dirname + '/../modules/turnero/views/display_global.php', 'utf8')
.replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"');
const js = src.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1];
const ini = js.indexOf('const VOCES_PREFERIDAS');
const fin = js.indexOf('/* ── Fullscreen ── */');
const cuerpo = 'let sonidoActivo=true,_vozES=null;function playBeep(){}'
+ js.slice(ini, fin);
ok('el código extraído contiene el anclaje anti-recolector', cuerpo.includes('_uttAncladas'));
ok('resume() se llama antes de cada speak()',
/resume\(\);\s*\n\s*window\.speechSynthesis\.speak\(utt\)/.test(cuerpo));
ok('el despertador SSE está conectado (los llamados despiertan a la pestaña oculta)',
js.includes("sse_turno.php?area=recepcion") && js.includes("addEventListener('cola_update'"));
// ── Armazón de simulación ───────────────────────────────────────────────
function escenario({ bloqueado = false, msArranque = 20, caida = false, atascoUnaVez = false } = {}) {
let t = 0; const cola = [];
const st = (fn, ms) => cola.push({ t: t + ms, fn });
class Utt {
constructor(x) { this.texto = x; this.l = {}; }
addEventListener(e, f) { (this.l[e] = this.l[e] || []).push(f); }
emit(e, arg) { (this.l[e] || []).forEach(f => f(arg || {})); }
}
let resumeAntesDeSpeak = false, resumePendiente = false;
let activa = null, totalSpeaks = 0;
const synth = {
speaking: false, pending: false,
pause() {},
// Como Chrome de verdad: cancelar una locución activa le dispara
// 'error' con interrupted. Fue exactamente el comportamiento que el
// simulador viejo no imitaba, y por eso no cazó el doble anuncio.
cancel() {
synth.speaking = synth.pending = false;
const u = activa; activa = null;
if (u) u.emit('error', { error: 'interrupted' });
},
resume() { resumePendiente = true; },
getVoices: () => [{ name: 'Sabina', lang: 'es-MX', localService: true }],
speak(u) {
resumeAntesDeSpeak = resumePendiente; resumePendiente = false;
totalSpeaks++;
if (bloqueado || caida) return; // descartado en silencio: ni pending queda
if (atascoUnaVez && totalSpeaks === 1) {
// El modo verificado en vivo (23/08): el motor JURA hablar
// (speaking=true) pero no suena y 'start' no llega jamás.
activa = u; synth.speaking = true;
return;
}
activa = u;
synth.pending = true;
st(() => { if (activa !== u) return; synth.pending = false; synth.speaking = true; u.emit('start'); }, msArranque);
st(() => { if (activa !== u) return; synth.speaking = false; activa = null; u.emit('end'); }, msArranque + 4000);
},
};
const win = { speechSynthesis: synth };
const beacons = [];
const sandbox = new Function(
'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
'localStorage', 'navigator', 'Blob', 'Date', 'document',
cuerpo + '; return { anunciarTurno, _uttAncladas };'
);
const fakeDate = { now: () => t };
fakeDate.prototype = Date.prototype;
const api = sandbox(
win, st, () => {}, Utt,
{ getItem: () => '[]', setItem: () => {} },
{ sendBeacon: (u, b) => beacons.push(u) },
function Blob() {},
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }),
{ addEventListener: () => {}, hidden: false }
);
const correr = (hastaMs) => {
let n = 0;
while (cola.length && n++ < 500) {
cola.sort((a, b) => a.t - b.t);
const e = cola.shift();
if (hastaMs !== undefined && e.t > hastaMs) { cola.unshift(e); break; }
t = e.t; e.fn();
}
};
return { api, correr, beacons, resumeUsado: () => resumeAntesDeSpeak, t: () => t,
speaks: () => totalSpeaks, hablando: () => activa };
}
// ── 1. Anclaje: la locución queda referenciada mientras habla ───────────
{
const e = escenario();
e.api.anunciarTurno('A01', 'Consultorio 2', null, () => {});
e.correr(800); // ya se llamó a speak()
ok('durante el habla la locución está anclada (no la puede recoger el GC)',
e.api._uttAncladas.size === 1);
e.correr(); // hasta el final
ok('al terminar se suelta el anclaje (sin fuga de memoria)',
e.api._uttAncladas.size === 0);
}
// ── 2. resume() antes de speak: motor atascado en pausa ─────────────────
{
const e = escenario();
e.api.anunciarTurno('A02', 'Consultorio 1', null, () => {});
e.correr();
ok('speak() siempre va precedido de resume() (destranca el motor pausado)',
e.resumeUsado());
}
// ── 3. Audio bloqueado: avisa para que el cartel no quede pegado ────────
{
const e = escenario({ bloqueado: true });
let aviso = null;
e.api.anunciarTurno('A03', 'Consultorio 3', null, () => { aviso = e.t(); });
e.correr();
ok('con el audio bloqueado igual avisa y el cartel se puede cerrar',
aviso !== null && aviso < 10000);
ok('el anclaje también se suelta cuando no llegó a hablar',
e.api._uttAncladas.size === 0);
}
// ── 4. Voz lenta (remota): no se corta ──────────────────────────────────
{
const e = escenario({ msArranque: 2800 });
let termino = false;
e.api.anunciarTurno('A04', 'Consultorio 4', 'maría gómez', () => { termino = true; });
e.correr();
ok('una voz que tarda 2,8 s en arrancar habla completa y termina', termino);
}
// ── 5. La bitácora registra start y end ─────────────────────────────────
{
const e = escenario();
e.api.anunciarTurno('A05', 'Consultorio 5', null, () => {});
e.correr();
ok('la bitácora mandó al menos 2 eventos (start y end) al servidor',
e.beacons.length >= 2);
}
// ── 5b. La voz no arranca: reintenta UNA vez, sin doble anuncio ─────────
// El defecto que esto vigila: cancel() dispara 'error' en la locución vieja,
// y sin el token de generación ese handler lanzaba OTRO reintento: dos voces
// superpuestas diciendo lo mismo, de forma intermitente.
{
// Voz que queda pendiente pero jamás arranca (pending eterno no: el sim
// bloqueado ni encola — usamos arranque infinito quitando la cola de start)
// Modo real de Chrome: el speak se descarta EN SILENCIO (ni speaking ni
// pending). Ese es el caso que el vigilante reintenta. El otro modo —voz
// eternamente en pending— se tolera a propósito: es indistinguible de una
// voz remota lenta, y cortarla fue justo el defecto del entrecortado.
const e = escenario({ caida: true });
let avisos = 0;
e.api.anunciarTurno('C01', 'Consultorio 1', null, () => { avisos++; });
e.correr(30000);
ok('voz descartada en silencio: exactamente 2 speak (original + 1 reintento), no más',
e.speaks() === 2);
ok('y el cartel recibe exactamente UN aviso de cierre (vía «mudo»)', avisos === 1);
}
// ── 5c. Un anuncio nuevo que cancela al anterior no lo resucita ─────────
{
const e = escenario({ msArranque: 20 });
let avisosA = 0;
e.api.anunciarTurno('D01', 'Consultorio 1', null, () => { avisosA++; });
e.correr(1000); // D01 está hablando
const speaksAntes = e.speaks();
e.api.anunciarTurno('D02', 'Consultorio 2', null, () => {});
e.correr();
ok('cancelar al anterior con un anuncio nuevo no dispara reintentos fantasma del viejo',
e.speaks() === speaksAntes + 1);
}
// ── 5d. MOTOR ATASCADO: speaking sin 'start' → romperlo y rehablar ──────
// Reproducido en vivo el 23/08: primer llamado tras el ocio, el motor queda
// en speaking=true sin audio. El vigilante debe detectarlo (una locución que
// de verdad suena dispara 'start' de inmediato), romperlo y rehablar — una
// sola vez, sin dobles.
{
const e = escenario({ atascoUnaVez: true });
let avisos = 0;
e.api.anunciarTurno('F01', 'Consultorio 1', null, () => { avisos++; });
e.correr();
ok('el atasco se detecta y el reintento habla: exactamente 2 speak',
e.speaks() === 2);
ok('el llamado termina avisando UNA vez (el reintento habló completo)',
avisos === 1);
ok('sin locuciones ancladas al final (sin fugas tras el atasco)',
e.api._uttAncladas.size === 0);
}
// ── 5e. Keepalive mudo: el motor recibe trabajo inaudible en el ocio ────
// pause/resume no mantiene despierto al motor (verificado en vivo); hablar a
// volumen cero sí. Este caso vigila que el keepalive exista y sea inaudible.
{
const intervalos = [];
let t = 0; const cola = [];
const st = (fn, ms) => cola.push({ t: t + ms, fn });
class Utt { constructor(x) { this.texto = x; this.l = {}; } addEventListener() {} }
let hablado = null;
const synth = { speaking: false, pending: false, pause() {}, cancel() {}, resume() {},
getVoices: () => [], speak(u) { hablado = u; } };
new Function('window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
'localStorage', 'navigator', 'Blob', 'Date', 'document',
cuerpo + ';')(
{ speechSynthesis: synth }, st, (fn, ms) => intervalos.push({ fn, ms }), Utt,
{ getItem: () => '[]', setItem: () => {} }, { sendBeacon: () => {} },
function Blob() {},
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => 0 }),
{ addEventListener: () => {}, hidden: false });
const ka = intervalos.find(i => i.ms === 25000);
ok('existe el keepalive de 25 s', !!ka);
if (ka) ka.fn();
ok('en el ocio habla una letra a VOLUMEN CERO (inaudible, pero trabajo real)',
hablado !== null && hablado.volume === 0);
ok('con el motor ocupado el keepalive no interfiere', (() => {
hablado = null; synth.speaking = true; if (ka) ka.fn(); return hablado === null;
})());
}
// ── 6. El camino feliz: habla bien ──────────────────────────────────────
// Qué dice exactamente, y con qué voz. Un anuncio que "funciona" pero
// pronuncia mal el nombre o elige la voz equivocada también es un fallo.
{
// Instrumentación directa: escenario propio con captura de la locución
let t = 0; const cola = [];
const st = (fn, ms) => cola.push({ t: t + ms, fn });
class Utt {
constructor(x) { this.texto = x; this.l = {}; }
addEventListener(e, f) { (this.l[e] = this.l[e] || []).push(f); }
emit(e, a) { (this.l[e] || []).forEach(f => f(a || {})); }
}
let capturada = null;
const synth = {
speaking: false, pending: false, pause() {}, cancel() {}, resume() {},
getVoices: () => [
{ name: 'Microsoft Sabina', lang: 'es-MX', localService: true },
{ name: 'Google español de Colombia', lang: 'es-CO', localService: false },
],
speak(u) {
capturada = u;
st(() => u.emit('start'), 10);
st(() => u.emit('end'), 4000);
},
};
const api = new Function(
'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
'localStorage', 'navigator', 'Blob', 'Date', 'document',
cuerpo + '; return { anunciarTurno };'
)({ speechSynthesis: synth }, st, () => {}, Utt,
{ getItem: () => '[]', setItem: () => {} },
{ sendBeacon: () => {} }, function Blob() {},
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }),
{ addEventListener: () => {}, hidden: false });
api.anunciarTurno('A01', 'Consultorio 2', 'maría fernanda gómez', () => {});
let n = 0;
while (cola.length && n++ < 200) { cola.sort((a, b) => a.t - b.t); const ev = cola.shift(); t = ev.t; ev.fn(); }
ok('el texto deletrea el código y termina con el destino',
capturada && capturada.texto === 'Turno A 0 1, María Fernanda Gómez, pase a Consultorio 2');
ok('los nombres con tilde salen bien capitalizados (María, no MaríA)',
capturada && capturada.texto.includes('María Fernanda Gómez'));
ok('elige la voz LOCAL (Sabina) aunque la remota es-CO esté disponible',
capturada && capturada.voice && capturada.voice.name === 'Microsoft Sabina');
ok('sin nombre de paciente igual habla: solo código y destino', (() => {
capturada = null;
api.anunciarTurno('B02', 'Recepción 1', null, () => {});
let m = 0;
while (cola.length && m++ < 200) { cola.sort((a, b) => a.t - b.t); const ev = cola.shift(); t = ev.t; ev.fn(); }
return capturada && capturada.texto === 'Turno B 0 2, pase a Recepción 1';
})());
}
console.log();
console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.');
process.exit(fallos === 0 ? 0 : 1);
+29 -6
View File
@@ -696,9 +696,14 @@ function _addExamWizardHtml(string $cid): string {
.campo-linked { display: flex; gap: 16px; padding: 6px 0; .campo-linked { display: flex; gap: 16px; padding: 6px 0;
border-bottom: 1px solid #f0f0f0; } border-bottom: 1px solid #f0f0f0; }
.campo-linked-label { flex: 0 0 38%; font-size: 12px; color: #6c757d; } .campo-linked-label { flex: 0 0 38%; font-size: 12px; color: #6c757d; }
.lnk-corregir { background:none; border:none; color:#94a3b8; cursor:pointer; /* Antes era solo un lápiz gris de 11px: estaba, pero nadie lo encontraba.
padding:0 4px; font-size:11px; flex-shrink:0; } Lleva texto y contorno para que se lea como lo que es, un botón. */
.lnk-corregir:hover { color:#1565c0; } .lnk-corregir { background:none; border:none; border-radius:4px;
color:#1565c0; cursor:pointer; padding:1px 5px; font-size:10px;
font-weight:600; white-space:nowrap; flex-shrink:0;
display:inline-flex; align-items:center; gap:3px;
text-decoration:underline; }
.lnk-corregir:hover { background:#1565c0; border-color:#1565c0; color:#fff; }
.campo-linked-valor { flex: 1; font-size: 13px; font-weight: 600; .campo-linked-valor { flex: 1; font-size: 13px; font-weight: 600;
color: #1565c0; } color: #1565c0; }
@@ -1289,8 +1294,8 @@ function _addExamWizardHtml(string $cid): string {
<div class="campo-linked-valor"><?= esc2($lval) ?></div> <div class="campo-linked-valor"><?= esc2($lval) ?></div>
<?php if ($modoTurnero && $embebido && $modoEditar && !$_lnkBtnPuesto): $_lnkBtnPuesto = true; ?> <?php if ($modoTurnero && $embebido && $modoEditar && !$_lnkBtnPuesto): $_lnkBtnPuesto = true; ?>
<button type="button" class="lnk-corregir no-print" onclick="pedirCorregirPaciente()" <button type="button" class="lnk-corregir no-print" onclick="pedirCorregirPaciente()"
title="Corregir los datos del paciente"> title="Abrir la ficha del paciente para corregir sus datos">
<i class="fas fa-pen"></i> <i class="fas fa-pen" style="font-size:9px"></i>Corregir
</button> </button>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -2302,6 +2307,8 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
return; return;
} }
var datos = json.datos_respuestas || {}; var datos = json.datos_respuestas || {};
var tocados = [], llenos = 0;
document.querySelectorAll('[name]').forEach(function(el) { document.querySelectorAll('[name]').forEach(function(el) {
var raw = el.name, isArr = raw.slice(-2) === '[]', name = isArr ? raw.slice(0, -2) : raw; var raw = el.name, isArr = raw.slice(-2) === '[]', name = isArr ? raw.slice(0, -2) : raw;
if (!(name in datos)) return; if (!(name in datos)) return;
@@ -2314,9 +2321,25 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
} else { } else {
el.value = val; el.value = val;
} }
tocados.push(el);
llenos++;
}); });
// Asignar el valor a mano no dispara ningún evento, y las secciones
// condicionales del formulario —tipo de dolor, tipo de cáncer, grupo
// sanguíneo— se despliegan escuchando 'change' e 'input'. Sin esto los
// datos entraban pero las secciones seguían ocultas, así que parecía
// que el botón no hacía nada.
tocados.forEach(function(el) {
['input', 'change'].forEach(function(tipo) {
el.dispatchEvent(new Event(tipo, { bubbles: true }));
});
});
msg.style.display = ''; msg.style.display = '';
msg.textContent = 'Datos cargados de la visita ' + (json.turno_codigo || '') + '. Revise y firme.'; msg.textContent = llenos
? 'Se cargaron ' + llenos + ' dato(s) de la visita ' + (json.turno_codigo || '') + '. Revise y firme.'
: 'La visita ' + (json.turno_codigo || '') + ' no tiene datos que correspondan a este formulario.';
}) })
.catch(function() { .catch(function() {
btn.disabled = false; btn.disabled = false;