37 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
Lizandro Guarnizo d4674f087d Dashboard del turnero: desglose por recepcionista y por bacteriólogo 2026-08-17 18:50:43 -05:00
Lizandro Guarnizo 29cbebfeb5 Turnero: el historial del paciente ya muestra las visitas anteriores 2026-08-17 18:50:43 -05:00
Lizandro GuarnizoandClaude Opus 5 ccaf6f22cd Configuración: miniatura cuadrada en la lista de medios del televisor
La miniatura era de 90x60 con recorte, así que el material cuadrado se veía
achatado en Configuración y no coincidía con lo que después salía al aire.
Ahora es cuadrada, igual que la pantalla.

La carga en sí no necesitaba cambios: no valida proporciones ni dimensiones,
solo formato y tamaño, así que un video cuadrado siempre entró bien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:28:19 -05:00
Lizandro GuarnizoandClaude Opus 5 c7ff8e4ead Pantalla TV: el video pasa de formato reel a cuadrado de verdad
El cambio anterior hizo cuadrada la caja, pero la dejó dentro de la columna
fija del 29% —que es proporción de reel—, así que el cuadrado salía diminuto
y con medio espacio vertical vacío.

Ahora la columna se dimensiona sola (grid `auto`) y el cuadrado toma todo el
alto del cuerpo, deduciendo el ancho de ahí. Queda tan grande como quepa, con
tope de media pantalla para no ahogar la columna de llamados.

El aspect-ratio va en el elemento interno y no en el contenedor: con
box-sizing border-box, el padding asimétrico del contenedor deformaba la
proporción y el "cuadrado" no quedaba cuadrado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:22:10 -05:00
Lizandro GuarnizoandClaude Opus 5 645ed677ea Permitir abrir la prueba de voces en el televisor
La página sirve para averiguar qué voces tiene ESE equipo, así que hay que
abrirla en el televisor —donde no hay sesión iniciada, porque es un kiosco—.
Tal como quedó, redirigía al login y era inservible justo donde se necesita.

No expone nada: lista las voces que reporta el navegador y lee una frase de
ejemplo inventada. Ningún dato de paciente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:52:11 -05:00
Lizandro GuarnizoandClaude Opus 5 c525cc7261 Pantallas TV: caja cuadrada para el video, que es cuadrado
El material del reel es cuadrado, pero la caja ocupaba todo el alto de una
columna angosta. Con object-fit:cover eso significa recortar: del cuadrado
solo quedaba visible una franja vertical del centro, y se perdían los bordes
—que es justo donde suele ir el logo o el texto de una pieza publicitaria.

Ahora la caja es cuadrada (aspect-ratio 1/1) y se centra en su columna, así
que la pieza entra completa. cover se conserva: sobre una caja cuadrada con
material cuadrado no recorta nada, y si algún día suben algo de otra
proporción, lo encuadra en vez de deformarlo.

Mismo arreglo en display.php, que tiene su propio panel de video por ?video=
con el mismo defecto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:36:58 -05:00
Lizandro GuarnizoandClaude Opus 5 6589503c57 Tablet de firma para el paciente en Recepción 4
Hasta ahora, para que el paciente firmara el consentimiento de bienvenida
había que girarle el monitor a la recepcionista o pasarle el mouse: el botón
Firmar abre el formulario en la pantalla de ella.

Se agrega una pantalla aparte (/erp.php?m=turnero&v=firma) para poner frente
al paciente. Sola, sin que la recepcionista haga nada: cuando el turno entra
a ese puesto y le falta el F-LAB-01, aparece su nombre y un botón grande de
firmar; al terminar dice gracias y vuelve a reposo.

Reutiliza lo que ya existía: el formulario de ver_formulario_enviado.php, el
aviso 'turneroFirmado' que ya emite al firmar, y el sondeo de recepción, que
seguirá poniendo el renglón en verde sin cambios.

Sobre el acceso: va en PUBLIC_ROUTES porque nadie va a iniciar sesión cada
mañana en una tablet que manipula el público. No queda abierta: se identifica
por la cookie del dispositivo, y sin ella no muestra ningún dato. El endpoint
devuelve solo el turno que está en ese puesto en ese instante —no permite
buscar, ni ver otros, ni consultar historial— y descarta turnos de días
anteriores, igual que el televisor. La tablet debe quedar en modo kiosco.

Falta registrar el dispositivo de Recepción 4 desde Configuración; hoy no
tiene ninguno.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:35:20 -05:00
Lizandro GuarnizoandClaude Opus 5 2a87f66bb7 Pantalla TV: dejar de llamar a pacientes que ya se fueron
Reportan que a veces llama a otro paciente. Sí ocurre, y hay 26 turnos sin
cerrar en estado "en servicio" desde el 28 de julio, varios de ellos dos y
tres en el mismo puesto a la vez.

La pantalla pregunta por cada puesto cuál turno está en servicio y se queda
con el más reciente. Mientras hay uno real todo va bien, pero al cerrarse
ese, el puesto queda con el viejo como único en servicio y sube al panel como
si lo estuvieran llamando. Peor: al cargar, la pantalla anunciaba POR VOZ el
turno que encontrara arriba, así que cada recarga —y se recarga sola— podía
gritar el nombre de alguien que se fue hace días.

- Al abrir ya no se anuncia nada: los turnos presentes se dan por vistos y
  solo se anuncia lo que pase de ahí en adelante. Mostrar el estado actual es
  correcto; declamarlo como si fuera un llamado nuevo, no.
- Un turno vale hasta la medianoche de su día, tanto en recepción como en los
  puestos. De los 26 atascados, 25 dejan de aparecer; queda el único de hoy.

El filtro es una red, no la solución: esos 26 turnos hay que cerrarlos, y
mientras existan siguen inflando el "en proceso" del dashboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:19:47 -05:00
Lizandro GuarnizoandClaude Opus 5 0ec0edf245 Toma de muestras: mostrar el F-LAB-08 en los turnos de solo entrega
Cuando recepción marca "solo entrega de muestras", el puesto de toma no
mostraba ningún formulario. No era que no se generara: el backend ya lo
creaba por la vía del lugar —los ocho puestos de toma, Pediatría y
Ginecología tienen vinculado el formulario 16— y era la interfaz la que lo
escondía. En 30 días: 59 turnos de solo entrega, 34 con el F-LAB-08 ya
creado, 0 firmados.

Eran tres bloqueos, no uno:
- la sección de formularios se ocultaba por solo_muestras, y como los puestos
  de toma van en modo embebido, esa sección es la única vía para abrirlo;
- no se cargaba el formulario embebido;
- ni se refrescaba la lista cada 5 s, así que al firmar no se habría visto
  el cambio.

Se conserva la exención del bloqueo para finalizar (línea 1685): el
formulario aparece y se puede llenar, pero un turno de solo entrega no queda
atrapado si no se firma. Cerrar la cola por esto sería peor que el problema
que resuelve.

Estos turnos no traen exámenes, así que la lista solo puede contener los
formularios del puesto: no hay riesgo de que aparezcan consentimientos de
exámenes que no aplican.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 09:20:47 -05:00
Lizandro GuarnizoandClaude Opus 5 b3a6be8f17 Pantalla TV: dejar de cortar la voz que arranca despacio
Dos defectos del mismo cambio de hoy, que explican lo que se oía.

La detección de "el navegador bloqueó el audio" daba 1,9 s de plazo y, si en
ese tiempo no había empezado a hablar, cancelaba y repetía con otra voz. Pero
una voz remota tarda en arrancar porque se baja de internet: el plazo la
declaraba muerta cuando iba a hablar, le cortaba la frase y la repetía con la
voz del navegador. De ahí que se oyera media frase y luego otra distinta, a
veces de hombre y a veces de mujer. Ahora se le pregunta al sintetizador si
está trabajando antes de darla por fallida, y el plazo sube a 3,2 s.

Y la voz se elegía al llegar el turno, no al hablar. La lista de voces la
carga el navegador de forma asíncrona, así que en los primeros llamados tras
abrir la página venía vacía y se hablaba con la voz por defecto. Ahora se
resuelve en el momento de hablar, cuando ya está cargada.

Comprobado: con voz instantánea, lenta (2 s) y muy lenta (4 s) ya no hay
corte y siempre habla Sabina; con el audio bloqueado se sigue avisando para
que el cartel no quede pegado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:23:08 -05:00
Lizandro GuarnizoandClaude Opus 5 8f94a8e012 Pantalla TV: el cartel se quedaba pegado cuando el navegador no dejaba hablar
Regresión del cambio anterior. Al arrancar con sonidoActivo=true,
anunciarTurno() devolvía el objeto de voz aunque el navegador estuviera
bloqueando el audio; el cartel esperaba el evento 'end', que en ese caso no
llega nunca, y quedaba fijo hasta la red de seguridad de 25 segundos. Antes
devolvía null y cerraba a los 5.

Ahora avisa por callback tanto al terminar de hablar como al quedar claro que
no va a hablar, detectado porque 'start' no ocurre. speak() no lanza nada ni
emite 'error' cuando lo bloquean: el silencio es la única señal.

Además, si la voz elegida no arranca —el equipo la lista pero no la puede
usar— se reintenta una vez con la del navegador en vez de quedarse mudo.
Comprobado con tres escenarios: equipo normal (cierra a 4,7 s), audio
bloqueado (3,1 s) y voz que no suena (5,9 s, hablando en el reintento).

Aparte: la expresión de mayúsculas usaba \w, que no cuenta letras acentuadas
y trataba la tilde como separador. "maría fernanda gómez" se pronunciaba
"MaríA Fernanda GóMez".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:12:53 -05:00
Lizandro GuarnizoandClaude Opus 5 7764f88df3 Pantalla TV: preferir Sabina y Paulina, que son locales
La voz es-CO de Chrome es de Google y se baja de internet en cada llamado:
con wifi flojo se corta. Una colombiana local no existe, así que se prefieren
las mexicanas del propio equipo —Sabina en Windows, Paulina en macOS—, que
para leer letras, números y un nombre propio se oyen naturales aquí.

El orden queda: las dos por nombre, luego cualquier local latinoamericana,
luego cualquier local aunque sea de España (peor acento, pero no se corta), y
la remota solo si el equipo no tiene ninguna instalada.

Comprobado contra cinco inventarios de voces: Windows con Sabina, macOS con
Paulina, solo España local, sin ninguna local, y sin voces en español.

voces.php replica el mismo orden para señalar cuál está sonando.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 07:57:33 -05:00
Lizandro GuarnizoandClaude Opus 5 24c05a40c2 Pantalla TV: el sonido arranca encendido, sin esperar que alguien la toque
sonidoActivo empezaba en false y solo un clic lo encendía, así que cualquier
recarga —corte de red, reinicio, refresco— dejaba el televisor mudo hasta que
una persona fuera físicamente a tocarlo. Peor: con esa lógica ni siquiera
poniendo --autoplay-policy=no-user-gesture-required habría sonado, porque la
página se bloqueaba a sí misma antes de que el navegador opinara.

Ahora arranca encendido e intenta abrir el audio al cargar. Si el navegador
lo permite, habla sin que nadie intervenga. Si lo bloquea, se muestra el
letrero para que alguien toque una vez, que es el único camino que dejan las
políticas de reproducción automática.

El pito de confirmación queda solo para el clic humano: al arrancar sola, la
pantalla no tiene por qué pitar cada vez que se recarga. Y el letrero ya no
depende de localStorage sino de si el audio quedó realmente suspendido,
atado a onstatechange porque resume() es asíncrono y antes parpadeaba.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 07:45:52 -05:00
Lizandro GuarnizoandClaude Opus 5 db133d9b5d Pantalla TV: el pito tapaba la primera palabra, y página para elegir voz
El llamado se oía "entrecortado" por dos motivos distintos.

playBeep() y speak() salían en el mismo instante, y el pito dura 0,65 s con
volumen 0,35: "Turno A-01" se pronunciaba por debajo del tono y desde lejos
parecía que el anuncio empezaba ya empezado. Se vuelve a esperar los 700 ms
que el código tenía antes de que se quitaran.

El otro motivo es la voz. getVozES() pide es-CO, que en Chrome es la de
Google: remota. Cada llamado la baja de internet, así que con wifi flojo se
corta o no suena. Las voces disponibles las decide el televisor, no el ERP,
así que en vez de adivinar se agrega /erp.php?m=turnero&v=voces: lista las
voces del equipo, marca cuáles son locales y cuáles se bajan de internet,
señala la que está sonando hoy y deja oír cada una con el texto real de un
llamado. Hay que abrirla en el televisor.

Queda descartado el eco entre pantallas: hay un solo televisor con sonido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 06:38:06 -05:00
Lizandro GuarnizoandClaude Opus 5 cd087f65af Elegir un solo BSUID por teléfono antes de escribir
57 números aparecen con dos identificadores (línea reciclada, o la persona
se registró de nuevo). El recorrido iba por BSUID, así que en esos casos los
dos escribían sobre el mismo usuario y el que quedaba dependía del orden:
cada ejecución dejaba un valor distinto y ninguna era más correcta. De ahí
los "110 reemplazos", que no eran 110 personas sino 57 escribiéndose dos
veces.

Ahora el empate se resuelve en memoria antes de tocar la base —gana el visto
más tarde, que es el vigente— y se escribe una sola vez por teléfono. La
corrida siguiente da 0 cambios y 0 conflictos, que es como debe comportarse
algo idempotente.

Estado final: 5.607 usuarios con su BSUID, sin duplicados, y ningún registro
con el identificador guardado en el campo de teléfono.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:54:21 -05:00
Lizandro GuarnizoandClaude Opus 5 ffb8c3c934 Partir el backfill en dos fases para que no muera a mitad
La ejecución real falló con "MySQL server has gone away" tras escribir 4.522
de 5.663: leer los 228.887 registros tarda más de media hora, y para cuando
empezaba a escribir el servidor ya había cerrado esa conexión. El singleton
de Database no sabe reconectar.

Ahora la lectura guarda el mapa en disco y termina; la segunda ejecución lo
toma de la caché en un segundo y escribe con la conexión sana. Reintentar
sale gratis, que era el otro problema: cada intento costaba media hora.

También informa cuántos teléfonos tienen más de un BSUID. La simulación
decía cero conflictos, pero no podía detectarlos: como no escribe, el bsuid
del usuario siempre estaba vacío y la comparación nunca daba positivo. Al
aplicar de verdad aparecieron. Ahora se cuentan al leer, sin depender de
lo ya escrito.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:08:14 -05:00
Lizandro GuarnizoandClaude Opus 5 6b74db3e4f Versionar la verificación del BSUID
Quedó fuera del commit anterior porque tests/ está en .gitignore, pese a
que el mensaje la mencionaba. Va en scripts/, que sí se versiona, junto al
backfill al que acompaña.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:26:54 -05:00
Lizandro GuarnizoandClaude Opus 5 2b1f08b03f Evitar teléfonos falsos en la historia clínica, y pedir el número a quien lo oculta
El arreglo anterior dejó un defecto: obtenerOCrearDesdeWhatsapp() copiaba
users.phone_number al teléfono de la ficha, y para quien oculta su número ahí
va el BSUID. normalizarTelefono() le quitaba el punto y las letras, así que
"CO.1761088155094242" quedaba guardado como "1761088155094242": un número de
16 dígitos, falso, con apariencia de real, dentro de una historia clínica y
sin que nadie lo notara. Ahora esa ficha se crea sin teléfono, que es la
verdad: no lo tenemos.

- esBsuid() queda definido una sola vez, en config.php, que es donde lo ven
  tanto el servicio de WhatsApp como las clases del laboratorio.
- La lista y el detalle de pacientes dicen "Solo por WhatsApp" en vez de
  mostrar el identificador crudo: recepción necesita entender por qué no
  puede llamar, no ver un código.
- Al crear la ficha desde una conversación se le pide el número con el botón
  de Meta, una sola vez (users.contacto_pedido_at). El texto es editable
  desde configuración. Solo se marca como pedido si Meta aceptó el envío,
  para poder reintentar si falló.
- tests/test_bsuid.php: 30 verificaciones sobre identidad, envío, vinculación
  y creación de fichas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:26:31 -05:00
42 changed files with 2821 additions and 166 deletions
+57 -1
View File
@@ -21,6 +21,53 @@ requireAuthentication();
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0); $adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
$db = Database::getInstance(); $db = Database::getInstance();
/**
* Le pide el teléfono a quien lo tiene oculto en WhatsApp, una sola vez.
*
* Solo aplica a quien se identifica con un BSUID: de esa persona no tenemos
* número, y sin él el laboratorio no puede llamarla. Se le manda el botón que
* Meta dispone para esto y ella decide si lo comparte; si acepta, el webhook
* recibe el teléfono y lo vincula solo.
*
* No se insiste: pedir los datos una vez es razonable, repetirlo en cada
* trámite es acoso. Tampoco se interrumpe la creación de la ficha si el envío
* falla, porque la ficha es lo importante.
*
* @return string qué pasó, para que la interfaz lo pueda mostrar
*/
function pedirContactoSiHaceFalta(Database $db, int $userId): string {
$u = $db->fetch(
'SELECT phone_number, contacto_pedido_at FROM users WHERE id = ?',
[$userId]
);
if (!$u) return 'usuario_no_encontrado';
if (!esBsuid($u['phone_number'])) return 'no_hace_falta'; // ya tenemos su número
if (!empty($u['contacto_pedido_at'])) return 'ya_se_pidio';
try {
require_once __DIR__ . '/../../services/WhatsAppService.php';
$wa = new WhatsAppService();
$texto = getConfigFromDB(
'whatsapp_texto_pedir_contacto',
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?'
);
$r = $wa->pedirContacto($u['phone_number'], $texto);
if (!$r) {
error_log('[crear_desde_whatsapp] WhatsApp rechazó la solicitud de contacto del usuario ' . $userId);
return 'fallo_envio';
}
// Se marca solo si Meta aceptó: si falló, hay que poder reintentarlo
$db->update('users', ['contacto_pedido_at' => date('Y-m-d H:i:s')], 'id = ?', [$userId]);
return 'pedido';
} catch (Exception $e) {
error_log('[crear_desde_whatsapp] Error pidiendo el contacto: ' . $e->getMessage());
return 'fallo_envio';
}
}
// ── GET: solo_paciente ───────────────────────────────────────────────────── // ── GET: solo_paciente ─────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) { if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
$convId = (int)($_GET['conversation_id'] ?? 0); $convId = (int)($_GET['conversation_id'] ?? 0);
@@ -57,7 +104,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
$pacienteRepo = new Paciente(); $pacienteRepo = new Paciente();
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']); $pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
$paciente = $pacienteRepo->obtener($pacienteId); $paciente = $pacienteRepo->obtener($pacienteId);
echo json_encode(['success' => true, 'paciente' => $paciente]);
// Si la persona oculta su teléfono, la ficha queda sin número. Es el momento
// de pedírselo: se le manda el botón de WhatsApp una sola vez.
$contactoPedido = pedirContactoSiHaceFalta($db, (int) $conv['user_id']);
echo json_encode([
'success' => true,
'paciente' => $paciente,
'contacto_pedido' => $contactoPedido,
]);
exit; exit;
} }
+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,
]);
+20 -2
View File
@@ -7,6 +7,15 @@
require_once __DIR__ . '/../../classes/Database.php'; require_once __DIR__ . '/../../classes/Database.php';
require_once __DIR__ . '/ActividadAdmin.php'; require_once __DIR__ . '/ActividadAdmin.php';
// esBsuid() vive en config.php; se garantiza aquí por si esta clase se incluye
// directamente, sin pasar por el arranque del ERP.
if (!function_exists('esBsuid')) {
$configPaciente = __DIR__ . '/../../config/config.php';
if (file_exists($configPaciente)) {
require_once $configPaciente;
}
}
class Paciente { class Paciente {
private Database $db; private Database $db;
@@ -258,10 +267,19 @@ class Paciente {
[$userId] [$userId]
); );
// Quien oculta su teléfono en WhatsApp se identifica con un BSUID, que ocupa
// el lugar del número en `users`. No es un teléfono: guardarlo aquí dejaría
// en la historia clínica un dato falso con apariencia de número real, porque
// normalizarTelefono() le quita el punto y las letras y lo deja en 16 dígitos.
// Mejor la ficha sin teléfono, que es la verdad: no lo tenemos.
$identificador = $user['phone_number'] ?? null;
$esIdentificadorSinTelefono = esBsuid($identificador);
return $this->crear([ return $this->crear([
'user_id' => $userId, 'user_id' => $userId,
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']), 'nombre_completo'=> $user['name']
'telefono' => $user['phone_number'] ?? null, ?? ($esIdentificadorSinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $identificador),
'telefono' => $esIdentificadorSinTelefono ? null : $identificador,
]); ]);
} }
+18
View File
@@ -339,6 +339,24 @@ function deleteConfigFromDB($key) {
} }
} }
if (!function_exists('esBsuid')) {
/**
* ¿Este identificador es un BSUID de Meta y no un número de teléfono?
*
* Desde que WhatsApp permite ocultar el número, quien lo oculta llega
* identificado solo por su BSUID, con la forma "CO.1761088155094242".
* Ese valor ocupa el lugar del teléfono dentro del bot, así que hay que
* distinguirlo antes de tratarlo como si fuera un número real: guardarlo
* en un campo de teléfono deja un dato falso con toda la pinta de verdadero.
*
* @param mixed $valor
* @return bool
*/
function esBsuid($valor) {
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $valor);
}
}
/** /**
* Limpia el cache estático de configuraciones * Limpia el cache estático de configuraciones
*/ */
+8
View File
@@ -23,6 +23,14 @@ class Router
private const PUBLIC_ROUTES = [ private const PUBLIC_ROUTES = [
'turnero/display', 'turnero/display',
'turnero/kiosko', 'turnero/kiosko',
// Tablet de firma del paciente: la manipula el público y nadie va a
// iniciar sesión en ella cada mañana. No queda abierta: se identifica
// por la cookie del dispositivo y sin ella no muestra dato alguno.
'turnero/firma',
// Prueba de voces: hay que abrirla EN el televisor para saber qué voces
// tiene ese equipo, y allí no hay sesión iniciada. No expone nada: solo
// lista las voces del navegador y lee una frase de ejemplo inventada.
'turnero/voces',
]; ];
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */ /** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
+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,
+35 -3
View File
@@ -472,6 +472,28 @@ function fmtFecha(str) {
return str.slice(0, 10).split('-').reverse().join('/'); return str.slice(0, 10).split('-').reverse().join('/');
} }
// Quien oculta su número en WhatsApp llega identificado con un BSUID
// ("CO.1761088155094242"), que se guarda en phone_number pero no es un teléfono.
const esBsuid = v => /^[A-Z]{2}\.\d+$/.test(String(v || ''));
/**
* Celda de contacto. Si de esta persona no tenemos teléfono porque lo tiene
* oculto, se dice así en vez de mostrar el identificador crudo: recepción
* necesita entender por qué no puede llamarla, no ver un código.
*/
function celdaTelefono(p) {
if (esBsuid(p.phone_number)) {
return (p.telefono ? esc(p.telefono) : '<span class="text-muted">Sin teléfono</span>')
+ '<br><i class="fab fa-whatsapp text-success"></i> '
+ '<small class="text-muted" title="Tiene el número oculto en WhatsApp. '
+ 'Se le puede escribir por el chat, pero no llamar.">Solo por WhatsApp</small>';
}
return esc(p.telefono || '—')
+ (p.phone_number
? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>`
: '');
}
async function cargarLista(pag = 1) { async function cargarLista(pag = 1) {
paginaActual = pag; paginaActual = pag;
const busq = document.getElementById('buscador').value.trim(); const busq = document.getElementById('buscador').value.trim();
@@ -495,7 +517,7 @@ async function cargarLista(pag = 1) {
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''} ${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
</td> </td>
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td> <td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
<td class="small">${esc(p.telefono||'—')}${p.phone_number ? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>` : ''}</td> <td class="small">${celdaTelefono(p)}</td>
<td class="small text-muted">${esc(p.email||'—')}</td> <td class="small text-muted">${esc(p.email||'—')}</td>
<td class="small text-muted">${esc(p.ciudad||'—')}</td> <td class="small text-muted">${esc(p.ciudad||'—')}</td>
<td class="small text-muted">${esc(p.eps||'—')}</td> <td class="small text-muted">${esc(p.eps||'—')}</td>
@@ -546,8 +568,18 @@ async function verDetalle(id) {
document.getElementById('detail-body').innerHTML = ` document.getElementById('detail-body').innerHTML = `
<dl class="row small mb-3"> <dl class="row small mb-3">
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd> <dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd> <dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd> p.telefono ? esc(p.telefono)
: (esBsuid(p.phone_number)
? '<span class="text-muted">Sin teléfono — lo tiene oculto en WhatsApp</span>'
: '—')
}</dd>
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${
!p.phone_number ? '—'
: (esBsuid(p.phone_number)
? '<i class="fab fa-whatsapp text-success"></i> Se le puede escribir por el chat, pero no llamar'
: `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}`)
}</dd>
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd> <dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd> <dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''} ${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
@@ -0,0 +1,24 @@
-- 20260811_bsuid_pedir_contacto.sql
--
-- Tercera parte del cambio de identidad de WhatsApp.
--
-- A quien oculta su teléfono se le puede pedir con el botón request_contact_info.
-- Se registra cuándo se le pidió para no volver a insistirle: pedirle los datos
-- una vez es razonable, repetírselo en cada trámite es acoso.
--
-- Queda NULL para todo el mundo; solo se llena cuando efectivamente se pide.
ALTER TABLE users
ADD COLUMN contacto_pedido_at DATETIME NULL DEFAULT NULL
COMMENT 'Cuándo se le pidió el teléfono por el botón de WhatsApp. NULL = nunca.'
AFTER bsuid;
-- Texto editable desde configuración, para que el laboratorio ajuste el mensaje
-- sin tocar código. Si la fila ya existe, se respeta lo que haya.
INSERT INTO system_config (config_key, config_value, description)
VALUES (
'whatsapp_texto_pedir_contacto',
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?',
'Mensaje del botón que pide el teléfono a quien lo tiene oculto en WhatsApp'
)
ON DUPLICATE KEY UPDATE config_key = config_key;
+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
+139 -29
View File
@@ -7,6 +7,7 @@
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio) * - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
* - desglose por prioridad * - desglose por prioridad
* - desglose por lugar * - desglose por lugar
* - desglose por recepcionista y por bacteriólogo (quién atendió a cuántos)
* - lista de turnos del día (para tabla detalle) * - lista de turnos del día (para tabla detalle)
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
@@ -41,10 +42,12 @@ if (!$sesion) {
'tiempo_espera_promedio_min' => null, 'tiempo_espera_promedio_min' => null,
'tiempo_servicio_promedio_min' => null, 'tiempo_servicio_promedio_min' => null,
], ],
'por_prioridad' => [], 'por_prioridad' => [],
'por_lugar' => [], 'por_lugar' => [],
'consent_stats' => [], 'por_recepcionista' => [],
'turnos' => [], 'por_bacteriologo' => [],
'consent_stats' => [],
'turnos' => [],
]); ]);
} }
@@ -54,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);
@@ -112,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"
@@ -125,6 +151,63 @@ $stmtLug = $pdo->prepare(
$stmtLug->execute([$sesionId]); $stmtLug->execute([$sesionId]);
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC); $porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
// ── 4b. Desglose por recepcionista ────────────────────────────
// Mismo molde que el ranking de enfermeras de lab_domicilios
// (api/lab/get_metricas.php). atendido_recepcion_por guarda al PRIMER
// recepcionista que tomó el turno (COALESCE en cambiar_estado.php).
$stmtRec = $pdo->prepare(
"SELECT u.id,
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
COUNT(t.id) AS total,
SUM(t.estado IN ('finalizado','en_servicio','en_espera_lugar')) AS atendidos,
SUM(t.estado = 'ausente') AS ausentes,
SUM(t.estado = 'cancelado') AS cancelados,
ROUND(AVG(
CASE
WHEN t.fin_recepcion_at IS NOT NULL AND t.inicio_recepcion_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_recepcion_at, t.fin_recepcion_at) / 60.0
END
), 1) AS tiempo_promedio_min
FROM turnero_turnos t
JOIN admin_users u ON u.id = t.atendido_recepcion_por
WHERE t.sesion_id = ?
GROUP BY u.id, u.full_name, u.username
ORDER BY atendidos DESC, total DESC"
);
$stmtRec->execute([$sesionId]);
$porRecepcionista = $stmtRec->fetchAll(PDO::FETCH_ASSOC);
// ── 4c. Desglose por bacteriólogo (quien atendió en el lugar) ──
$stmtBac = $pdo->prepare(
"SELECT u.id,
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
COUNT(t.id) AS total,
SUM(t.estado = 'finalizado') AS finalizados,
SUM(t.estado = 'en_servicio') AS en_servicio,
SUM(t.estado = 'ausente') AS ausentes,
ROUND(AVG(
CASE
WHEN t.inicio_lugar_at IS NOT NULL
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
), 1) AS tiempo_promedio_min
FROM turnero_turnos t
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 = ?
GROUP BY u.id, u.full_name, u.username
ORDER BY finalizados DESC, total DESC"
);
$stmtBac->execute([$sesionId]);
$porBacteriologo = $stmtBac->fetchAll(PDO::FETCH_ASSOC);
// ── 5. Lista de turnos del día ──────────────────────────────── // ── 5. Lista de turnos del día ────────────────────────────────
$stmtTurnos = $pdo->prepare( $stmtTurnos = $pdo->prepare(
"SELECT t.id, t.codigo, t.numero, t.estado, "SELECT t.id, t.codigo, t.numero, t.estado,
@@ -157,22 +240,47 @@ $stmtTurnos = $pdo->prepare(
$stmtTurnos->execute([$sesionId]); $stmtTurnos->execute([$sesionId]);
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC); $turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
// Enriquecer con exámenes y comentarios // Enriquecer con exámenes y comentarios — en lote.
foreach ($turnos as &$turno) { // Antes eran 2 consultas por turno dentro del foreach (N+1): con 80 turnos en
// el día eran 160 consultas evitables. Mismo patrón que get_historial.php.
$turnoIds = array_column($turnos, 'id');
$examenesPorTurno = [];
$comentariosPorTurno = [];
if ($turnoIds) {
$ph = implode(',', array_fill(0, count($turnoIds), '?'));
$stmtEx = $pdo->prepare( $stmtEx = $pdo->prepare(
"SELECT DISTINCT et.nombre FROM turnero_examen_items tei "SELECT DISTINCT ts.turno_id, et.nombre
JOIN exam_tipos et ON et.id = tei.exam_tipo_id FROM turnero_solicitudes ts
WHERE tei.solicitud_id = (SELECT id FROM turnero_solicitudes WHERE turno_id = ? LIMIT 1)" JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
WHERE ts.turno_id IN ($ph)
ORDER BY et.nombre ASC"
); );
$stmtEx->execute([$turno['id']]); $stmtEx->execute($turnoIds);
$turno['examenes'] = $stmtEx->fetchAll(PDO::FETCH_ASSOC); foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $row) {
$examenesPorTurno[(int)$row['turno_id']][] = ['nombre' => $row['nombre']];
}
$stmtCom = $pdo->prepare( $stmtCom = $pdo->prepare(
"SELECT usuario_nombre, comentario, tipo, creado_at "SELECT turno_id, usuario_nombre, comentario, tipo, creado_at
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at ASC" FROM turnero_comentarios
WHERE turno_id IN ($ph)
ORDER BY turno_id, creado_at ASC"
); );
$stmtCom->execute([$turno['id']]); $stmtCom->execute($turnoIds);
$turno['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC); foreach ($stmtCom->fetchAll(PDO::FETCH_ASSOC) as $row) {
$tid = (int)$row['turno_id'];
unset($row['turno_id']);
$comentariosPorTurno[$tid][] = $row;
}
}
foreach ($turnos as &$turno) {
$tid = (int)$turno['id'];
$turno['examenes'] = $examenesPorTurno[$tid] ?? [];
$turno['comentarios'] = $comentariosPorTurno[$tid] ?? [];
} }
unset($turno); unset($turno);
@@ -201,6 +309,8 @@ jsonOk([
'resumen' => $resumen, 'resumen' => $resumen,
'por_prioridad' => $porPrioridad, 'por_prioridad' => $porPrioridad,
'por_lugar' => $porLugar, 'por_lugar' => $porLugar,
'por_recepcionista' => $porRecepcionista,
'por_bacteriologo' => $porBacteriologo,
'consent_stats' => $consentStats, 'consent_stats' => $consentStats,
'turnos' => $turnos, 'turnos' => $turnos,
]); ]);
@@ -76,6 +76,8 @@ if (empty($escritoriosRec)) {
JOIN turnero_prioridades p ON p.id = t.prioridad_id JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ? WHERE t.sesion_id = ?
AND t.estado = 'en_recepcion' AND t.estado = 'en_recepcion'
-- Misma regla que en los puestos: el turno vale hasta medianoche
AND DATE(t.llamado_recepcion_at) = CURDATE()
ORDER BY t.llamado_recepcion_at DESC" ORDER BY t.llamado_recepcion_at DESC"
); );
$stmtRec->execute([$sesionId]); $stmtRec->execute([$sesionId]);
@@ -120,6 +122,10 @@ foreach ($lugares as $lugar) {
WHERE t.sesion_id = ? WHERE t.sesion_id = ?
AND t.estado = 'en_servicio' AND t.estado = 'en_servicio'
AND t.lugar_destino_id = ? AND t.lugar_destino_id = ?
-- Un turno vale hasta la medianoche de su día. Quedan bastantes sin
-- cerrar, y sin este filtro el puesto mostraba como \"llamado ahora\"
-- a un paciente de días atrás en cuanto se cerraba el turno real.
AND DATE(t.llamado_lugar_at) = CURDATE()
ORDER BY t.llamado_lugar_at DESC ORDER BY t.llamado_lugar_at DESC
LIMIT 1" LIMIT 1"
); );
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* GET /modules/turnero/api/get_firma_pendiente.php
*
* Le dice a la tablet del paciente qué mostrar: si hay alguien siendo atendido
* en su puesto y le falta firmar el consentimiento de bienvenida (F-LAB-01).
*
* SIN SESIÓN DE OPERADOR, a propósito: esta tablet la manipula el público y
* nadie va a iniciar sesión en ella cada mañana. Se identifica por la cookie
* del dispositivo, que es un token de 64 caracteres registrado en Configuración.
*
* Por eso devuelve lo mínimo: el turno que está en ese puesto en este instante
* y nada más. No permite consultar otros turnos, ni buscar, ni ver historial.
* Sin dispositivo reconocido no responde nada.
*/
require_once __DIR__ . '/../../../config/config.php';
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
/** El único formulario que se firma en esta tablet: el de bienvenida. */
const FORMULARIO_BIENVENIDA = 17;
function responder(array $datos): void {
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
exit;
}
$pdo = Database::getInstance()->getConnection();
// ── 1. ¿Qué puesto es esta tablet? ────────────────────────────────────────
// Solo por token de navegador. La IP no sirve aquí: varias tablets salen por
// la misma y acabaríamos mostrándole a un paciente los datos de otro puesto.
$token = trim($_COOKIE['turnero_token'] ?? '');
if ($token === '') {
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
}
$stmt = $pdo->prepare(
"SELECT td.lugar_id, td.nombre AS dispositivo, tl.nombre AS lugar, tl.tipo
FROM turnero_dispositivos td
JOIN turnero_lugares tl ON tl.id = td.lugar_id
WHERE td.token = ? AND td.activo = 1
LIMIT 1"
);
$stmt->execute([$token]);
$disp = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$disp || $disp['tipo'] !== 'recepcion') {
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
}
// ── 2. ¿Hay alguien siendo atendido ahí ahora? ────────────────────────────
// Mismo criterio que la pantalla del televisor: el turno vale hasta la
// medianoche de su día, para no mostrar a un paciente que ya se fue.
// La sesión está abierta mientras fin_at siga en nulo; no hay columna de estado.
$stmt = $pdo->prepare(
"SELECT t.id, t.codigo, t.paciente_id, t.paciente_nombre
FROM turnero_turnos t
JOIN turnero_sesiones s ON s.id = t.sesion_id
WHERE t.estado = 'en_recepcion'
AND t.recepcion_desk_id = ?
AND DATE(t.llamado_recepcion_at) = CURDATE()
AND s.fin_at IS NULL
ORDER BY t.llamado_recepcion_at DESC
LIMIT 1"
);
$stmt->execute([(int)$disp['lugar_id']]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) {
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
}
// Sin paciente vinculado no hay a quién atribuirle la firma
if (empty($turno['paciente_id'])) {
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
}
// ── 3. ¿Le falta firmar el consentimiento de bienvenida? ──────────────────
$stmt = $pdo->prepare(
"SELECT token, estado FROM turnero_consentimientos
WHERE turno_id = ? AND formulario_id = ?
LIMIT 1"
);
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
if ($consent && in_array($consent['estado'], ['firmado', 'rechazado'], true)) {
responder([
'ok' => true,
'estado' => 'firmado',
'turno_id' => (int)$turno['id'],
'codigo' => $turno['codigo'],
]);
}
// Si el consentimiento todavía no existe se crea aquí. Es lo que permite que
// la tablet aparezca sola, sin que la recepcionista tenga que mandarlo.
if (!$consent) {
$tokenFirma = sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
$ins = $pdo->prepare(
"INSERT IGNORE INTO turnero_consentimientos
(turno_id, formulario_id, token, estado, creado_por)
VALUES (?, ?, ?, 'pendiente', NULL)"
);
$ins->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA, $tokenFirma]);
// INSERT IGNORE puede no haber insertado si otra petición se adelantó:
// se relee para quedarse con el token que realmente quedó guardado.
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$consent) {
responder(['ok' => false, 'motivo' => 'no_se_pudo_crear']);
}
}
responder([
'ok' => true,
'estado' => 'por_firmar',
'turno_id' => (int)$turno['id'],
'codigo' => $turno['codigo'],
'paciente' => $turno['paciente_nombre'],
'url' => BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($consent['token']),
]);
+16 -3
View File
@@ -4,7 +4,7 @@
* GET Historial paginado de turnos con filtros multi-día. * GET Historial paginado de turnos con filtros multi-día.
* *
* Parámetros (todos opcionales): * Parámetros (todos opcionales):
* fecha_desde YYYY-MM-DD (default: hace 7 días) * fecha_desde YYYY-MM-DD (default: hoy; hace 12 meses si se pasa paciente_id)
* fecha_hasta YYYY-MM-DD (default: hoy) * fecha_hasta YYYY-MM-DD (default: hoy)
* estado string ("finalizado","ausente","cancelado","en_servicio",... o vacío = todos) * estado string ("finalizado","ausente","cancelado","en_servicio",... o vacío = todos)
* lugar_id int (0 = todos) * lugar_id int (0 = todos)
@@ -23,16 +23,29 @@ $pdo = db();
// ── Parámetros ──────────────────────────────────────────────── // ── Parámetros ────────────────────────────────────────────────
$hoy = date('Y-m-d'); $hoy = date('Y-m-d');
// paciente_id se lee primero: determina el rango de fechas por defecto.
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
$fechaDesde = trim($_GET['fecha_desde'] ?? ''); $fechaDesde = trim($_GET['fecha_desde'] ?? '');
$fechaHasta = trim($_GET['fecha_hasta'] ?? ''); $fechaHasta = trim($_GET['fecha_hasta'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $hoy;
// Rango por defecto cuando el cliente no manda fechas:
// - Ficha de paciente (paciente_id): ultimos 12 meses.
// Antes caia en "hoy", asi que el historial del paciente solo podia mostrar
// visitas del mismo dia y siempre respondia "sin visitas anteriores".
// - Listado general del historial: hoy (comportamiento original).
// Para ver el historial completo, el cliente pasa fecha_desde explicita.
$desdePorDefecto = $pacienteId > 0
? date('Y-m-d', strtotime('-12 months'))
: $hoy;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $desdePorDefecto;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaHasta)) $fechaHasta = $hoy; if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaHasta)) $fechaHasta = $hoy;
if ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta; if ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta;
$estadoFiltro = trim($_GET['estado'] ?? ''); $estadoFiltro = trim($_GET['estado'] ?? '');
$lugarId = (int)($_GET['lugar_id'] ?? 0); $lugarId = (int)($_GET['lugar_id'] ?? 0);
$prioridadId = (int)($_GET['prioridad_id'] ?? 0); $prioridadId = (int)($_GET['prioridad_id'] ?? 0);
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
$q = trim($_GET['q'] ?? ''); $q = trim($_GET['q'] ?? '');
$perPage = min(200, max(10, (int)($_GET['per_page'] ?? 50))); $perPage = min(200, max(10, (int)($_GET['per_page'] ?? 50)));
+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));
+5 -2
View File
@@ -1160,10 +1160,13 @@ $tab = $_GET['tab'] ?? 'lugares';
<div class="tv-media-item d-flex align-items-center gap-3 p-2" data-id="<?= (int)$m['id'] ?>" <div class="tv-media-item d-flex align-items-center gap-3 p-2" data-id="<?= (int)$m['id'] ?>"
style="border:1px solid #e2e8f0;border-radius:10px"> style="border:1px solid #e2e8f0;border-radius:10px">
<i class="fas fa-grip-vertical text-muted" style="cursor:grab"></i> <i class="fas fa-grip-vertical text-muted" style="cursor:grab"></i>
<?php /* Miniatura cuadrada, igual que la pantalla del televisor: con la
antigua de 90x60 el material cuadrado se veía recortado aquí y
no coincidía con lo que después salía al aire. */ ?>
<?php if ($m['tipo'] === 'video'): ?> <?php if ($m['tipo'] === 'video'): ?>
<video muted style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>"></video> <video muted style="width:64px;height:64px;object-fit:cover;border-radius:6px;background:#000" src="<?= htmlspecialchars($m['url']) ?>"></video>
<?php else: ?> <?php else: ?>
<img style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>" alt=""> <img style="width:64px;height:64px;object-fit:cover;border-radius:6px;background:#000" src="<?= htmlspecialchars($m['url']) ?>" alt="">
<?php endif; ?> <?php endif; ?>
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="small fw-semibold"><i class="fas fa-<?= $m['tipo']==='video'?'film':'image' ?> me-1"></i><?= $m['tipo']==='video'?'Video':'Imagen' ?></div> <div class="small fw-semibold"><i class="fas fa-<?= $m['tipo']==='video'?'film':'image' ?> me-1"></i><?= $m['tipo']==='video'?'Video':'Imagen' ?></div>
+69 -11
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; }
@@ -287,6 +289,18 @@ try {
<div class="lugar-grid" id="lugarGrid"></div> <div class="lugar-grid" id="lugarGrid"></div>
</div> </div>
<!-- Desglose por recepcionista -->
<div class="section-card" id="sectionRecepcionista" style="display:none">
<div class="section-title"><i class="fas fa-user-check me-1"></i>Por recepcionista</div>
<div class="lugar-grid" id="recepcionistaGrid"></div>
</div>
<!-- Desglose por bacteriólogo -->
<div class="section-card" id="sectionBacteriologo" style="display:none">
<div class="section-title"><i class="fas fa-user-nurse me-1"></i>Por bacteriólogo</div>
<div class="lugar-grid" id="bacteriologoGrid"></div>
</div>
<!-- Tabla detalle de turnos --> <!-- Tabla detalle de turnos -->
<div class="section-card"> <div class="section-card">
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2"> <div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
@@ -445,7 +459,8 @@ function mostrarError(msg) {
// ── Render ─────────────────────────────────────────────────── // ── Render ───────────────────────────────────────────────────
function renderDashboard(json) { function renderDashboard(json) {
const { sesion, resumen, por_prioridad, por_lugar, consent_stats = {}, turnos } = json; const { sesion, resumen, por_prioridad, por_lugar, consent_stats = {}, turnos,
por_recepcionista = [], por_bacteriologo = [] } = json;
// ── Sesión info ──────────────────────────────────────── // ── Sesión info ────────────────────────────────────────
const si = document.getElementById('sesionInfo'); const si = document.getElementById('sesionInfo');
@@ -471,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 ────────────────────────────────────
@@ -536,16 +559,51 @@ function renderDashboard(json) {
secLug.style.display = 'none'; secLug.style.display = 'none';
} }
// ── Tarjetas por persona ───────────────────────────────
renderPersonas('sectionRecepcionista', 'recepcionistaGrid', por_recepcionista, [
['Turnos', p => p.total],
['Atendidos', p => p.atendidos],
['Ausentes', p => p.ausentes],
['T. recepción prom.', p => p.tiempo_promedio_min !== null ? p.tiempo_promedio_min + ' min' : '—'],
]);
renderPersonas('sectionBacteriologo', 'bacteriologoGrid', por_bacteriologo, [
['Turnos', p => p.total],
['Finalizados', p => p.finalizados],
['En servicio', p => p.en_servicio],
['T. servicio prom.', p => p.tiempo_promedio_min !== null ? p.tiempo_promedio_min + ' min' : '—'],
]);
// ── Tabla de turnos ──────────────────────────────────── // ── Tabla de turnos ────────────────────────────────────
_turnos = turnos; _turnos = turnos;
renderTabla(turnos); renderTabla(turnos);
} }
function kpiCard(val, lbl, sub) { // Tarjetas "quién atendió a cuántos". Reutiliza el grid de lugares para que
return `<div class="kpi-card"> // las tres secciones se vean iguales.
function renderPersonas(sectionId, gridId, filas, columnas) {
const sec = document.getElementById(sectionId);
if (!filas || !filas.length) { sec.style.display = 'none'; return; }
sec.style.display = '';
document.getElementById(gridId).innerHTML = filas.map(p => `
<div class="lugar-card">
<div class="lc-name"><i class="fas fa-user text-primary me-1"></i>${escHtml(p.nombre || '—')}</div>
${columnas.map(([lbl, val]) =>
`<div class="lc-row"><span>${lbl}</span><span>${escHtml(String(val(p) ?? '—'))}</span></div>`
).join('')}
</div>`).join('');
}
function kpiCard(val, lbl, sub, ayuda) {
// `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>`;
} }
+7 -3
View File
@@ -271,9 +271,13 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')
</div> </div>
<!-- Panel publicitario (se activa con ?video=URL) --> <!-- Panel publicitario (se activa con ?video=URL) -->
<div id="video-panel" style="display:none;overflow:hidden;background:#000;position:relative"> <!-- El material es cuadrado: la caja también, y centrada. Estirada a
toda la altura de una columna angosta, cover recortaba los bordes
del video y solo se veía la franja central. -->
<div id="video-panel" style="display:none;overflow:hidden;background:#000;position:relative;
align-items:center;justify-content:center">
<video id="video-pub" autoplay muted loop playsinline <video id="video-pub" autoplay muted loop playsinline
style="width:100%;height:100%;object-fit:cover"> style="width:100%;height:auto;aspect-ratio:1/1;max-height:100%;object-fit:cover">
<source id="video-src" src=""> <source id="video-src" src="">
</video> </video>
</div> </div>
@@ -338,7 +342,7 @@ document.getElementById('lbl-area').textContent =
const body = document.getElementById('display-body'); const body = document.getElementById('display-body');
src.src = videoUrl; src.src = videoUrl;
vid.load(); vid.load();
panel.style.display = 'block'; panel.style.display = 'flex'; // flex, para poder centrar el cuadro
// 3 columnas: turno | video | cola // 3 columnas: turno | video | cola
body.style.gridTemplateColumns = '1fr 320px 280px'; body.style.gridTemplateColumns = '1fr 320px 280px';
})(); })();
+326 -71
View File
@@ -112,8 +112,12 @@ $_hasVideo = (bool)$_tvPlaylist;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
/* La columna del video se dimensiona sola: `auto` la deja del ancho que
pida su contenido, y el contenido es un cuadrado tan alto como el
cuerpo. Antes era una franja fija del 29% —proporción de reel—, así
que un video cuadrado quedaba diminuto y con medio espacio vacío. */
.pg-body.has-video { .pg-body.has-video {
grid-template-columns: 1fr 29%; grid-template-columns: 1fr auto;
} }
.pg-body.has-video .turno-activo { display: none; } .pg-body.has-video .turno-activo { display: none; }
@@ -240,16 +244,29 @@ $_hasVideo = (bool)$_tvPlaylist;
} }
.ul-item.activo .ul-dest { color: rgba(255,255,255,.85); } .ul-item.activo .ul-dest { color: rgba(255,255,255,.85); }
/* ── Video reel ── */ /* ── Video cuadrado ──
El material ya no es de proporción reel sino cuadrado. La caja toma
todo el alto disponible y el ancho lo deduce de ahí (aspect-ratio),
así que el cuadrado sale tan grande como quepa. Se limita a la mitad
de la pantalla para no ahogar la columna de los llamados. */
.reel-wrap { .reel-wrap {
display: none; display: none;
height: 100%; height: 100%;
max-width: 50vw;
overflow: hidden; overflow: hidden;
padding: 8px 8px 8px 0; padding: 8px 8px 8px 0;
align-items: center;
justify-content: center;
} }
.reel-wrap.has-video { display: block; } .reel-wrap.has-video { display: flex; }
/* El cuadrado se define aquí y no en el contenedor: con box-sizing
border-box, el padding asimétrico del contenedor deformaría la
proporción. La columna se ajusta sola al ancho que resulte. */
.reel-wrap .reel-inner { .reel-wrap .reel-inner {
width: 100%; height: 100%; height: 100%;
width: auto;
aspect-ratio: 1 / 1;
max-width: 100%;
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
box-shadow: -8px 0 30px rgba(0,0,0,.08); box-shadow: -8px 0 30px rgba(0,0,0,.08);
@@ -378,7 +395,7 @@ $_hasVideo = (bool)$_tvPlaylist;
<div class="dot"></div> <div class="dot"></div>
</div> </div>
<div id="sonido-banner" onclick="activarSonido()" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9999;background:rgba(239,68,68,.92);color:#fff;padding:.6rem 1.2rem;font-size:1rem;font-weight:600;cursor:pointer;align-items:center;justify-content:center;gap:.6rem"> <div id="sonido-banner" onclick="activarSonido(true)" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9999;background:rgba(239,68,68,.92);color:#fff;padding:.6rem 1.2rem;font-size:1rem;font-weight:600;cursor:pointer;align-items:center;justify-content:center;gap:.6rem">
<i class="fas fa-volume-mute"></i> Haz clic aquí para activar el sonido <i class="fas fa-volume-mute"></i> Haz clic aquí para activar el sonido
</div> </div>
@@ -404,7 +421,7 @@ $_hasVideo = (bool)$_tvPlaylist;
<div class="hd-right"> <div class="hd-right">
<div class="live-dot"></div> <div class="live-dot"></div>
<div class="reloj" id="reloj">--:--:--</div> <div class="reloj" id="reloj">--:--:--</div>
<button class="btn-fs" id="btn-sonido" onclick="activarSonido()" title="Activar sonido"> <button class="btn-fs" id="btn-sonido" onclick="activarSonido(true)" title="Activar sonido">
<i class="fas fa-volume-mute"></i> <i class="fas fa-volume-mute"></i>
</button> </button>
<button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa"> <button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa">
@@ -499,14 +516,22 @@ function tick() {
tick(); setInterval(tick, 1000); tick(); setInterval(tick, 1000);
/* ── Audio ── */ /* ── Audio ── */
let audioCtx = null, sonidoActivo = false; // Arranca encendido. Esta pantalla vive en un televisor que nadie toca: si el
// sonido esperara un clic, cualquier recarga —corte de red, reinicio, refresco—
// la dejaba muda hasta que alguien fuera físicamente a tocarla, que es lo que
// venía pasando. Si el navegador termina bloqueándolo, se avisa con el letrero.
let audioCtx = null, sonidoActivo = true;
function activarSonido() { function activarSonido(porGesto) {
try { try {
if (!audioCtx) { if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)(); audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} else if (audioCtx.state === 'suspended') { // resume() es asíncrono: sin esto el letrero parpadeaba al abrir,
audioCtx.resume(); // porque el estado todavía decía "suspended" cuando ya iba a sonar.
audioCtx.onstatechange = revisarBloqueo;
}
if (audioCtx.state === 'suspended') {
audioCtx.resume().catch(() => {});
} }
const g = audioCtx.createGain(); g.gain.setValueAtTime(0.001, audioCtx.currentTime); const g = audioCtx.createGain(); g.gain.setValueAtTime(0.001, audioCtx.currentTime);
const o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination); const o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination);
@@ -516,13 +541,39 @@ function activarSonido() {
localStorage.setItem('turneroSonido', '1'); localStorage.setItem('turneroSonido', '1');
const btn = document.getElementById('btn-sonido'); const btn = document.getElementById('btn-sonido');
if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); } if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); }
const banner = document.getElementById('sonido-banner'); // El pito de confirmación solo cuando alguien tocó: al arrancar sola, la
if (banner) banner.style.display = 'none'; // pantalla no tiene por qué pitar cada vez que se recarga.
setTimeout(playBeep, 100); if (porGesto) setTimeout(playBeep, 100);
revisarBloqueo();
} }
// Sin once:true para que cualquier clic reactive si la página recargó
document.addEventListener('click', () => { if (!sonidoActivo) activarSonido(); }); /**
document.addEventListener('touchstart', () => { if (!sonidoActivo) activarSonido(); }); * El navegador puede negarse a sonar sin un gesto humano. No hay forma de
* saberlo preguntando, solo mirando si el contexto quedó suspendido: si es así
* se muestra el letrero para que alguien toque la pantalla una vez.
*
* Se evita del todo abriendo Chrome en el televisor con
* --autoplay-policy=no-user-gesture-required
*/
function revisarBloqueo() {
const banner = document.getElementById('sonido-banner');
if (!banner) return;
const bloqueado = !audioCtx || audioCtx.state === 'suspended';
banner.style.display = bloqueado ? 'flex' : 'none';
}
// Intento de arranque automático, en cuanto la página está lista
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => activarSonido(false));
} else {
activarSonido(false);
}
// Reintento: Chrome a veces deja el contexto suspendido un instante al abrir
setTimeout(() => { if (audioCtx && audioCtx.state === 'suspended') activarSonido(false); }, 2000);
// Cualquier gesto sirve de respaldo si el navegador bloqueó el arranque
document.addEventListener('click', () => activarSonido(true));
document.addEventListener('touchstart', () => activarSonido(true));
function playBeep() { function playBeep() {
if (!sonidoActivo || !audioCtx) return; if (!sonidoActivo || !audioCtx) return;
@@ -544,60 +595,247 @@ function playBeep() {
// Selección de voz española — cargada una vez, reutilizada en cada anuncio // Selección de voz española — cargada una vez, reutilizada en cada anuncio
let _vozES = null; let _vozES = null;
// Voces preferidas, por nombre. Sabina (Windows) y Paulina (macOS) son las
// mexicanas locales: salen del propio equipo, así que nunca se cortan. Una
// colombiana local no existe —la es-CO de Chrome es de Google y se baja de
// internet en cada llamado—, y para leer letras, números y un nombre propio
// el acento mexicano se oye natural aquí; el de España no.
const VOCES_PREFERIDAS = ['sabina', 'paulina'];
function getVozES() { function getVozES() {
if (_vozES) return _vozES; if (_vozES) return _vozES;
const voices = window.speechSynthesis.getVoices(); const voces = window.speechSynthesis.getVoices().filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
// Preferencia: es-CO → es-419 → es-MX → es-US → es-ES → cualquier es-* if (!voces.length) return null;
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
const v = voices.find(v => v.lang === lang); const locales = voces.filter(v => v.localService);
if (v) { _vozES = v; return v; } const elegir = v => { _vozES = v; return v; };
// 1. Las pedidas por nombre, si están instaladas
for (const nombre of VOCES_PREFERIDAS) {
const v = locales.find(v => v.name.toLowerCase().includes(nombre));
if (v) return elegir(v);
} }
const v = voices.find(v => v.lang.startsWith('es')); // 2. Cualquier otra local latinoamericana
if (v) { _vozES = v; } for (const lang of ['es-MX', 'es-419', 'es-US', 'es-CO']) {
return _vozES; const v = locales.find(v => v.lang === lang);
if (v) return elegir(v);
}
// 3. Cualquier local, aunque sea de España: peor acento, pero no se corta
if (locales.length) return elegir(locales[0]);
// 4. Sin ninguna local, queda la remota. Suena mejor, pero depende del wifi:
// es la que venía entrecortándose.
for (const lang of ['es-CO', 'es-419', 'es-MX', 'es-US', 'es-ES']) {
const v = voces.find(v => v.lang === lang);
if (v) return elegir(v);
}
return elegir(voces[0]);
} }
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);
} }
// Auto-restore sonido si estaba activo antes de un reload // El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
if (localStorage.getItem('turneroSonido') === '1') { // lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
const banner = document.getElementById('sonido-banner');
if (banner) banner.style.display = 'flex'; // Espera antes de hablar: el pito dura 0,65 s y hablando encima tapaba
// "Turno X", que desde lejos sonaba como si el anuncio empezara ya empezado.
// A nivel de archivo porque showAnnouncement() también lo necesita.
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 (_) {}
} }
function anunciarTurno(codigo, destino, paciente) { /**
* Dice el llamado en voz alta.
*
* Avisa por `onFin` cuando terminó de hablar, o cuando quedó claro que no va a
* hablar. Lo segundo importa tanto como lo primero: el cartel se cierra con esa
* señal, y si nunca llegaba se quedaba pegado en pantalla.
*
* Si la voz elegida no arranca —pasa cuando el equipo la lista pero no la puede
* usar— se reintenta una vez con la del navegador, en vez de quedarse mudo.
*
* @return bool si se intentó hablar. Falso significa pantalla sin sonido.
*/
function anunciarTurno(codigo, destino, paciente, onFin) {
playBeep(); playBeep();
if (!sonidoActivo || !('speechSynthesis' in window)) return null; if (!sonidoActivo || !('speechSynthesis' in window)) return false;
const letras = codigo.split('').join(' '); const letras = codigo.split('').join(' ');
const nomVoz = paciente ? paciente.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) : null; // \w no cuenta las letras acentuadas, así que trataba la tilde como
// separador: "maría" salía "MaríA" y la voz lo pronunciaba raro.
const nomVoz = paciente
? paciente.toLowerCase().replace(/(^|\s)(\p{L})/gu, (_, sep, c) => sep + c.toUpperCase())
: null;
let texto = `Turno ${letras}`; let texto = `Turno ${letras}`;
if (nomVoz) texto += `, ${nomVoz}`; if (nomVoz) texto += `, ${nomVoz}`;
texto += `, pase a ${destino}`; texto += `, pase a ${destino}`;
const utt = new SpeechSynthesisUtterance(texto);
utt.lang = 'es-CO';
const voz = getVozES();
if (voz) utt.voice = voz;
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
// Con el sintetizador libre se habla de inmediato. Solo cuando hay algo en let avisado = false;
// curso hace falta cancelar y esperar un instante: Chrome ignora un speak() const avisar = () => { if (!avisado) { avisado = true; onFin(); } };
// encadenado a un cancel() en el mismo ciclo.
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) { const decir = (esReintento) => {
window.speechSynthesis.cancel(); const utt = new SpeechSynthesisUtterance(texto);
setTimeout(() => window.speechSynthesis.speak(utt), 80); utt.lang = 'es-CO';
} else { utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
window.speechSynthesis.speak(utt);
} _uttAncladas.add(utt);
return utt; const soltar = () => _uttAncladas.delete(utt);
const pedido = Date.now();
const gen = ++_vozGen;
const vigente = () => gen === _vozGen;
let arranco = false;
utt.addEventListener('start', () => {
arranco = 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
// navegador bloquea el audio, o la voz no sirve, no pasa absolutamente
// 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;
setTimeout(() => {
if (!vigente()) { soltar(); return; }
if (arranco) return;
// pending sin speaking = voz remota descargando: una prórroga de 6 s
// y solo entonces se da por perdida. Cortarla de una fue el defecto
// del entrecortado original.
if (window.speechSynthesis.pending && !window.speechSynthesis.speaking) {
setTimeout(() => { if (!vigente() || arranco) return; rendirse(); }, 6000);
return;
}
// speaking sin 'start' = ATASCO (finge hablar sin audio). Una locución
// que de verdad suena dispara 'start' de inmediato: romperlo.
rendirse();
}, margen);
// Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un
// speak() encadenado a un cancel() en el mismo ciclo.
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
window.speechSynthesis.cancel();
}
setTimeout(() => {
// La voz se elige aquí y no al crear el anuncio: el navegador carga
// la lista de forma asíncrona, y en los primeros llamados tras abrir
// la página todavía venía vacía. Entonces no se asignaba voz y
// hablaba la del navegador —de ahí que a veces sonara un hombre y a
// veces una mujer—. A esta altura ya está cargada.
if (!esReintento) {
const voz = getVozES();
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);
}, esReintento ? 250 : BEEP_MS);
};
decir(false);
return true;
} }
/* ── Fullscreen ── */ /* ── Fullscreen ── */
@@ -642,8 +880,6 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
ov.classList.add('visible'); ov.classList.add('visible');
const utt = anunciarTurno(codigo, destino, paciente);
bar.style.transition = 'none'; bar.style.transition = 'none';
bar.style.width = '100%'; bar.style.width = '100%';
requestAnimationFrame(() => requestAnimationFrame(() => { requestAnimationFrame(() => requestAnimationFrame(() => {
@@ -668,11 +904,15 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn))); setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn)));
}; };
if (utt) { // anunciarTurno avisa tanto si terminó de hablar como si quedó claro que no
utt.addEventListener('end', () => setTimeout(cerrarCuandoToque, 400)); // va a hablar. Esperar solo el fin de la voz dejaba el cartel pegado en las
utt.addEventListener('error', cerrarCuandoToque); // pantallas donde el navegador bloquea el audio.
// Red de seguridad: Chrome a veces no emite 'end' y el cartel quedaría fijo const intentoHablar = anunciarTurno(codigo, destino, paciente,
setTimeout(dismiss, 25000); () => setTimeout(cerrarCuandoToque, 400));
if (intentoHablar) {
// Red de seguridad: Chrome a veces no emite 'end' a mitad de una frase
setTimeout(dismiss, 20000);
} else { } else {
setTimeout(dismiss, MIN_VISIBLE); setTimeout(dismiss, MIN_VISIBLE);
} }
@@ -727,18 +967,16 @@ function renderSnapshot(snap) {
}); });
lastSeenKeys = currentKeys; lastSeenKeys = currentKeys;
if (_firstLoad) { // Al abrir la pantalla NO se anuncia nada por voz. Antes se llamaba al turno
_firstLoad = false; // que estuviera arriba, pero eso no es un llamado nuevo: es el estado en que
const sorted = allTurnos.slice().sort((a, b) => new Date(b.llamado_at || 0) - new Date(a.llamado_at || 0)); // se encontró el puesto. Si allí había un turno sin cerrar de horas atrás, el
if (sorted.length > 0) { // televisor gritaba el nombre de un paciente que ya se había ido. Y como esta
const t = sorted[0]; // pantalla se recarga sola, cada recarga era una oportunidad de equivocarse.
const c = t.prioridad_color || '<?= $_labColor ?>'; //
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || ''); // El recorrido de arriba ya dio por vistos los turnos presentes —registra la
const nomVoz = t.paciente_id ? t.paciente_nombre : null; // clave sin anunciar mientras _firstLoad siga en pie—, así que aquí solo hay
announcedCalls.add(key); // que levantar la bandera: de la siguiente vuelta en adelante sí se anuncia.
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c }); if (_firstLoad) _firstLoad = false;
}
}
if (anuncioNuevo) renderLlamados(); if (anuncioNuevo) renderLlamados();
@@ -828,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);
+200
View File
@@ -0,0 +1,200 @@
<?php
/**
* modules/turnero/views/firma.php
* Tablet de firma del paciente — /erp.php?m=turnero&v=firma
*
* Pantalla de cara al público: se pone frente al paciente en el mostrador y
* muestra, sola, el consentimiento de bienvenida cuando le toca firmarlo.
* Antes había que girarle el monitor a la recepcionista o pasarle el mouse.
*
* La tablet se identifica por la cookie del dispositivo, registrada desde
* Configuración. Sin ella no muestra nada: es lo que impide que cualquiera
* abra esta dirección y vea el nombre del paciente de turno.
*
* Conviene dejarla en modo kiosco, sin barra de direcciones, para que desde
* ella no se pueda navegar al resto del ERP.
*/
$_fCfg = [];
try {
$__pdo = Database::getInstance()->getConnection();
$_fCfg = $__pdo->query(
"SELECT clave, valor FROM lab_config WHERE clave IN ('empresa_nombre','doc_logo_base64','doc_color')"
)->fetchAll(PDO::FETCH_KEY_PAIR) ?: [];
} catch (\Throwable $_) {}
$_fNombre = htmlspecialchars($_fCfg['empresa_nombre'] ?? 'Laboratorio');
$_fLogo = $_fCfg['doc_logo_base64'] ?? '';
$_fColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_fCfg['doc_color'] ?? '') ? $_fCfg['doc_color'] : '#1565c0';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Firma · <?= $_fNombre ?></title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --brand: <?= $_fColor ?>; }
html, body {
height: 100%; width: 100%; overflow: hidden;
font-family: 'Segoe UI', system-ui, sans-serif;
background: #f8fafc; color: #1e293b;
-webkit-user-select: none; user-select: none;
}
.pantalla {
height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 4vh 5vw; gap: 2.5vh;
}
.pantalla.oculta { display: none; }
.logo { max-height: 14vh; max-width: 50vw; object-fit: contain; }
.marca { font-size: clamp(1.2rem, 3vw, 2rem); font-weight: 700; color: #64748b; }
.saludo { font-size: clamp(1.4rem, 4vw, 2.6rem); font-weight: 600; color: #94a3b8; }
.etiqueta { font-size: clamp(.9rem, 2vw, 1.2rem); font-weight: 700;
letter-spacing: 3px; text-transform: uppercase; color: #94a3b8; }
.paciente { font-size: clamp(1.8rem, 5.5vw, 3.4rem); font-weight: 800; line-height: 1.15; }
.codigo { font-size: clamp(1.1rem, 2.6vw, 1.6rem); font-weight: 700; color: var(--brand); }
/* Botón deliberadamente enorme: lo va a tocar gente mayor, de pie y de afán */
.btn-firmar {
margin-top: 2vh;
background: var(--brand); color: #fff; border: 0;
border-radius: 18px; cursor: pointer;
font-family: inherit; font-weight: 800;
font-size: clamp(1.5rem, 4.5vw, 2.6rem);
padding: clamp(1rem, 3.5vh, 2.2rem) clamp(2.5rem, 12vw, 6rem);
box-shadow: 0 10px 30px rgba(0,0,0,.18);
transition: transform .12s ease, filter .12s ease;
}
.btn-firmar:active { transform: scale(.96); filter: brightness(.92); }
.ok-icono { font-size: clamp(3.5rem, 12vw, 7rem); color: #16a34a; line-height: 1; }
.ok-txt { font-size: clamp(1.4rem, 4vw, 2.4rem); font-weight: 700; color: #166534; }
.aviso { font-size: clamp(1rem, 2.4vw, 1.4rem); color: #94a3b8; max-width: 34ch; line-height: 1.5; }
/* El formulario ocupa toda la pantalla al abrirse */
#marco { position: fixed; inset: 0; z-index: 50; background: #fff; display: none; }
#marco.abierto { display: block; }
#marco iframe { width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<!-- En reposo: nadie a quien pedirle firma -->
<div class="pantalla" id="p-reposo">
<?php if ($_fLogo): ?><img class="logo" src="<?= htmlspecialchars($_fLogo) ?>" alt=""><?php endif; ?>
<div class="marca"><?= $_fNombre ?></div>
<div class="saludo">Bienvenido</div>
</div>
<!-- Hay alguien y le falta firmar -->
<div class="pantalla oculta" id="p-firmar">
<div class="etiqueta">Turno <span id="f-codigo"></span></div>
<div class="paciente" id="f-paciente"></div>
<div class="aviso">Por favor lea y firme el consentimiento para continuar con su atención.</div>
<button class="btn-firmar" id="btn-firmar">Firmar</button>
</div>
<!-- Ya firmó -->
<div class="pantalla oculta" id="p-gracias">
<div class="ok-icono">✓</div>
<div class="ok-txt">¡Gracias!</div>
<div class="aviso">Su consentimiento quedó registrado. Puede continuar en el mostrador.</div>
</div>
<!-- La tablet no está registrada -->
<div class="pantalla oculta" id="p-sin-registro">
<div class="marca"><?= $_fNombre ?></div>
<div class="aviso">Esta tablet todavía no está asignada a un puesto.<br>
Regístrela desde Configuración del turnero.</div>
</div>
<div id="marco"><iframe id="marco-iframe" src="about:blank"></iframe></div>
<script>
const API = '<?= defined('BASE_URL') ? BASE_URL : '/' ?>modules/turnero/api/';
let turnoEnPantalla = null; // turno que se está mostrando
let firmando = false; // con el formulario abierto no se cambia de pantalla
function mostrar(id) {
['p-reposo','p-firmar','p-gracias','p-sin-registro']
.forEach(p => document.getElementById(p).classList.toggle('oculta', p !== id));
}
function abrirFormulario(url) {
firmando = true;
document.getElementById('marco-iframe').src = url;
document.getElementById('marco').classList.add('abierto');
}
function cerrarFormulario() {
firmando = false;
document.getElementById('marco').classList.remove('abierto');
document.getElementById('marco-iframe').src = 'about:blank';
}
document.getElementById('btn-firmar').addEventListener('click', () => {
const url = document.getElementById('btn-firmar').dataset.url;
if (url) abrirFormulario(url);
});
async function revisar() {
// Con el formulario abierto no se toca la pantalla: el paciente está firmando
if (firmando) return;
let d;
try {
const r = await fetch(API + 'get_firma_pendiente.php', { cache: 'no-store' });
d = await r.json();
} catch (_) {
return; // Sin red se deja lo que haya puesto; ya volverá
}
if (!d.ok) {
if (d.motivo === 'sin_dispositivo') mostrar('p-sin-registro');
return;
}
if (d.estado === 'por_firmar') {
document.getElementById('f-codigo').textContent = d.codigo || '';
document.getElementById('f-paciente').textContent = d.paciente || '';
document.getElementById('btn-firmar').dataset.url = d.url || '';
turnoEnPantalla = d.turno_id;
mostrar('p-firmar');
return;
}
if (d.estado === 'firmado') {
// El "gracias" solo tiene sentido para quien acaba de firmar aquí.
// Si la tablet se abre con un turno ya firmado de antes, va a reposo.
mostrar(turnoEnPantalla === d.turno_id ? 'p-gracias' : 'p-reposo');
return;
}
// reposo: se olvida el turno anterior para no dejar datos de un paciente
// en pantalla mientras llega el siguiente
turnoEnPantalla = null;
mostrar('p-reposo');
}
// ver_formulario_enviado.php avisa al terminar; si no llega el aviso, el sondeo
// se encarga igual cuando el consentimiento aparezca como firmado.
window.addEventListener('message', (e) => {
const t = e.data && e.data.type;
if (t === 'turneroFirmado') {
cerrarFormulario();
mostrar('p-gracias');
setTimeout(revisar, 2500);
}
});
revisar();
setInterval(revisar, 2000);
</script>
</body>
</html>
+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}`);
+25 -9
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">
@@ -1355,9 +1363,11 @@ async function seleccionarSinLlamar(turnoId) {
mostrarCabeceraTurno(t); mostrarCabeceraTurno(t);
await cargarFichaSolicitud(t.id); await cargarFichaSolicitud(t.id);
clearInterval(pollingConsentId); clearInterval(pollingConsentId);
if (!_esSoloEntrega(t)) // También para solo entrega de muestras: a esos turnos les corresponde el
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000); // formulario del puesto, así que la lista tiene que refrescarse igual o al
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(t)) // firmar no se vería el cambio.
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
cargarFormEmbebido(t.id); cargarFormEmbebido(t.id);
mostrarFichaMobile(); mostrarFichaMobile();
} }
@@ -1400,9 +1410,8 @@ async function abrirFicha(turno) {
mostrarCabeceraTurno(turno); mostrarCabeceraTurno(turno);
await cargarFichaSolicitud(turno.id); await cargarFichaSolicitud(turno.id);
clearInterval(pollingConsentId); clearInterval(pollingConsentId);
if (!_esSoloEntrega(turno)) pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000); if (LUGAR_FORM_MODO === 'embebido' && lugarId)
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(turno))
cargarFormEmbebido(turno.id); cargarFormEmbebido(turno.id);
mostrarFichaMobile(); mostrarFichaMobile();
} }
@@ -1468,10 +1477,15 @@ async function cargarFichaSolicitud(turnoId) {
const esSoloMuestras = !!(sol && sol.solo_muestras == 1); const esSoloMuestras = !!(sol && sol.solo_muestras == 1);
document.getElementById('badge-solo-muestras')?.classList.toggle('d-none', !esSoloMuestras); document.getElementById('badge-solo-muestras')?.classList.toggle('d-none', !esSoloMuestras);
// Ocultar sección consentimientos y formulario embebido cuando es solo entrega // La sección de formularios ya NO se esconde por ser solo entrega de
// muestras. Esos turnos no traen exámenes, pero sí les corresponde el
// formulario del puesto (F-LAB-08, Datos Toma de Muestras): el backend
// se lo venía creando y la interfaz lo ocultaba, así que se acumulaban
// creados y sin firmar. Quién se muestra lo decide renderConsentimientos
// según lo que realmente haya que llenar.
const secConsent = document.getElementById('sec-consent'); const secConsent = document.getElementById('sec-consent');
const secForm = document.getElementById('sec-form-embebido'); const secForm = document.getElementById('sec-form-embebido');
if (secConsent) secConsent.classList.toggle('d-none', esSoloMuestras); if (secConsent) secConsent.classList.remove('d-none');
if (secForm) secForm.classList.add('d-none'); if (secForm) secForm.classList.add('d-none');
// Embarazada // Embarazada
@@ -2230,7 +2244,7 @@ function resetFicha() {
_muestrasActivas = []; _muestrasActivas = [];
const secMuestras = document.getElementById('sec-muestras'); const secMuestras = document.getElementById('sec-muestras');
if (secMuestras) secMuestras.classList.add('d-none'); if (secMuestras) secMuestras.classList.add('d-none');
// Restaurar sección consent (puede haber sido ocultada por turno solo-muestras) // Dejar la sección visible para la siguiente ficha
document.getElementById('sec-consent')?.classList.remove('d-none'); document.getElementById('sec-consent')?.classList.remove('d-none');
document.getElementById('badge-solo-muestras')?.classList.add('d-none'); document.getElementById('badge-solo-muestras')?.classList.add('d-none');
document.getElementById('ficha-orden').classList.add('d-none'); document.getElementById('ficha-orden').classList.add('d-none');
@@ -2515,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 || '';
@@ -2551,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');
} }
+154
View File
@@ -0,0 +1,154 @@
<?php
/**
* modules/turnero/views/voces.php
* Prueba de voces del televisor — /erp.php?m=turnero&v=voces
*
* Las voces disponibles no las decide el ERP sino el equipo: dependen del
* sistema operativo y del navegador del televisor. Por eso esta página hay que
* abrirla EN EL TELEVISOR, no en el computador de la oficina: cada equipo
* ofrece una lista distinta.
*
* Sirve para oír cada voz con el texto real de un llamado y elegir la que
* quede. Distingue las locales de las que se bajan de internet, que son las
* que se entrecortan cuando el wifi flaquea.
*/
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prueba de voces · Turnero</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #0f172a; color: #e2e8f0;
}
h1 { font-size: 1.5rem; margin: 0 0 4px; }
.sub { color: #94a3b8; font-size: .95rem; margin-bottom: 20px; }
.aviso {
background: #1e293b; border-left: 4px solid #38bdf8;
padding: 12px 16px; border-radius: 6px; margin-bottom: 22px;
font-size: .92rem; line-height: 1.5;
}
.fila {
display: flex; align-items: center; gap: 14px;
background: #1e293b; border-radius: 8px;
padding: 14px 16px; margin-bottom: 10px;
border: 1px solid #334155;
}
.fila.elegida { border-color: #38bdf8; background: #1e3a52; }
.nom { font-weight: 600; flex: 1; min-width: 0; }
.nom small { display: block; font-weight: 400; color: #94a3b8; font-size: .8rem; }
.tag {
font-size: .72rem; padding: 3px 9px; border-radius: 99px;
white-space: nowrap; font-weight: 600;
}
.local { background: #14532d; color: #86efac; }
.remota { background: #7c2d12; color: #fdba74; }
.actual { background: #0c4a6e; color: #7dd3fc; }
button {
background: #0284c7; color: #fff; border: 0;
padding: 10px 20px; border-radius: 6px; cursor: pointer;
font-size: .95rem; font-weight: 600; white-space: nowrap;
}
button:hover { background: #0369a1; }
#vacio { color: #94a3b8; padding: 20px 0; }
</style>
</head>
<body>
<h1>Prueba de voces</h1>
<div class="sub">Ábrala <strong>en el televisor</strong>: cada equipo ofrece voces distintas.</div>
<div class="aviso">
Las <span class="tag local">local</span> salen del propio equipo: nunca se cortan, aunque suenen
algo más robóticas.<br>
Las <span class="tag remota">de internet</span> suenan más naturales, pero se bajan de los
servidores de Google en cada llamado. Con wifi débil se entrecortan o no suenan.
<br><br>
Oiga las que aparezcan y dígame cuál prefiere.
</div>
<div id="lista"><div id="vacio">Cargando voces…</div></div>
<script>
// El mismo texto que dice la pantalla de turnos, para juzgarlas en condiciones
const TEXTO = 'Turno A 0 1, María Fernanda Gómez, pase a Consultorio 2';
// Réplica de la preferencia de display_global.php, para señalar cuál está
// sonando. Si allá se cambia el orden, hay que cambiarlo aquí también.
const VOCES_PREFERIDAS = ['sabina', 'paulina'];
function vozActual(voces) {
if (!voces.length) return null;
const locales = voces.filter(v => v.localService);
for (const nombre of VOCES_PREFERIDAS) {
const v = locales.find(v => v.name.toLowerCase().includes(nombre));
if (v) return v;
}
for (const lang of ['es-MX','es-419','es-US','es-CO']) {
const v = locales.find(v => v.lang === lang);
if (v) return v;
}
if (locales.length) return locales[0];
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
const v = voces.find(v => v.lang === lang);
if (v) return v;
}
return voces[0];
}
function probar(voz) {
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(TEXTO);
u.voice = voz;
u.lang = voz.lang;
u.rate = 0.95;
u.pitch = 1.05;
window.speechSynthesis.speak(u);
}
function pintar() {
const todas = window.speechSynthesis.getVoices();
const es = todas.filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
const lista = document.getElementById('lista');
const actual = vozActual(es);
if (!es.length) {
lista.innerHTML = '<div id="vacio">Este equipo no tiene ninguna voz en español instalada. '
+ 'Habría que instalarle una desde la configuración del sistema, '
+ 'o dejar el llamado solo con el pito y el cartel.</div>';
return;
}
// Las locales primero: son las que interesan
es.sort((a, b) => (b.localService - a.localService) || a.name.localeCompare(b.name));
lista.innerHTML = '';
es.forEach((v, i) => {
const fila = document.createElement('div');
fila.className = 'fila' + (v === actual ? ' elegida' : '');
fila.innerHTML =
'<div class="nom">' + v.name + '<small>' + v.lang + '</small></div>'
+ (v.localService ? '<span class="tag local">local</span>'
: '<span class="tag remota">de internet</span>')
+ (v === actual ? '<span class="tag actual">la de ahora</span>' : '');
const b = document.createElement('button');
b.textContent = 'Oír';
b.onclick = () => probar(v);
fila.appendChild(b);
lista.appendChild(fila);
});
}
// getVoices() suele venir vacío en la primera llamada: el navegador las carga aparte
window.speechSynthesis.onvoiceschanged = pintar;
pintar();
setTimeout(pintar, 600);
</script>
</body>
</html>
+61 -7
View File
@@ -11,23 +11,42 @@
* *
* Es idempotente: se puede correr las veces que haga falta. * Es idempotente: se puede correr las veces que haga falta.
* *
* La lectura del histórico tarda más de media hora (son ~230.000 registros de
* un servidor remoto), y mantener la conexión abierta todo ese rato la mataba
* antes de empezar a escribir: "MySQL server has gone away". Por eso el mapa se
* guarda en disco y la escritura usa una conexión nueva. De paso, reintentar
* sale gratis: si el mapa ya está en caché, no se vuelve a leer la tabla.
*
* Uso: * Uso:
* php scripts/backfill_bsuid.php --simular (no escribe, solo informa) * php scripts/backfill_bsuid.php --simular (no escribe, solo informa)
* php scripts/backfill_bsuid.php * php scripts/backfill_bsuid.php
* php scripts/backfill_bsuid.php --releer (ignora la caché y relee el histórico)
*/ */
require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/../config/config.php';
$simular = in_array('--simular', $argv, true); $simular = in_array('--simular', $argv, true);
$releer = in_array('--releer', $argv, true);
echo $simular ? "Modo simulación: no se escribe nada.\n\n" : "Aplicando cambios.\n\n"; echo $simular ? "Modo simulación: no se escribe nada.\n\n" : "Aplicando cambios.\n\n";
$db = Database::getInstance(); $cache = sys_get_temp_dir() . '/backfill_bsuid_mapa.json';
// --- 1. Recorrer el histórico y armar el mapa BSUID → teléfono --- // --- 1. Recorrer el histórico y armar el mapa BSUID → teléfono ---
echo "Leyendo webhook_logs...\n";
$mapa = []; $mapa = [];
$sinTelefono = []; $sinTelefono = [];
if (!$releer && is_readable($cache)) {
$guardado = json_decode(file_get_contents($cache), true) ?: [];
$mapa = $guardado['mapa'] ?? [];
$sinTelefono = $guardado['sinTelefono'] ?? [];
printf("Mapa tomado de la caché (%s, del %s).\n", basename($cache), date('Y-m-d H:i', filemtime($cache)));
printf("Para releer el histórico: --releer\n\n");
}
if (!$mapa) {
echo "Leyendo webhook_logs (tarda; es una sola vez)...\n";
$db = Database::getInstance();
// Una sola pasada sin buffer: son más de 400.000 registros, así que ni caben // Una sola pasada sin buffer: son más de 400.000 registros, así que ni caben
// en memoria de golpe ni conviene reconsultar la tabla por bloques (cada bloque // en memoria de golpe ni conviene reconsultar la tabla por bloques (cada bloque
// volvía a recorrerla entera y tardaba una eternidad). // volvía a recorrerla entera y tardaba una eternidad).
@@ -82,24 +101,59 @@ try {
} }
$stmt->closeCursor(); $stmt->closeCursor();
} finally { } finally {
// Dejar la conexión como estaba: las escrituras siguientes la necesitan con buffer // Dejar la conexión como estaba
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $bufferPrevio); $conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $bufferPrevio);
} }
printf("\n registros revisados: %d\n", $leidos); printf("\n registros revisados: %d\n", $leidos);
printf(" equivalencias encontradas: %d\n", count($mapa)); printf(" equivalencias encontradas: %d\n", count($mapa));
printf(" personas que llegaron sin teléfono: %d\n\n", count($sinTelefono)); file_put_contents($cache, json_encode(['mapa' => $mapa, 'sinTelefono' => $sinTelefono]));
printf(" mapa guardado en %s\n", $cache);
// Se termina aquí a propósito. Tras media hora de lectura el servidor ya cerró
// esta conexión, y el singleton de Database no sabe reconectar: escribir ahora
// falla con "MySQL server has gone away" a mitad del recorrido, que es justo lo
// que pasó la primera vez. La segunda ejecución toma el mapa de la caché en un
// segundo y escribe con la conexión sana.
echo "\nMapa listo. Ejecute otra vez el comando para aplicarlo.\n";
exit(0);
}
printf(" equivalencias encontradas: %d\n", count($mapa));
printf(" personas que llegaron sin teléfono: %d\n", count($sinTelefono));
// --- 1b. Un solo BSUID por teléfono ---
// Hay números que aparecen con dos identificadores: la línea cambió de dueño
// (reciclada) o la persona se registró de nuevo. Si se recorre el mapa tal cual,
// ambos escriben sobre el mismo usuario y el que queda depende del orden: cada
// ejecución dejaba un valor distinto. Se resuelve antes de tocar la base,
// quedándose con el visto más tarde, y se escribe una sola vez por teléfono.
$porTelefono = [];
$elegido = [];
foreach ($mapa as $b => $t) {
$porTelefono[$t][] = $b;
$elegido[$t] = $b; // el orden del mapa es el de lectura: el último gana
}
$ambiguos = array_filter($porTelefono, fn($bs) => count($bs) > 1);
printf(" teléfonos distintos: %d\n", count($porTelefono));
printf(" teléfonos con más de un BSUID: %d (se toma el más reciente)\n\n", count($ambiguos));
foreach (array_slice($ambiguos, 0, 10, true) as $t => $bs) {
printf(" %s → %s\n", substr($t, 0, -4) . '****', implode(', ', $bs));
}
if (count($ambiguos) > 10) printf(" (y %d más)\n", count($ambiguos) - 10);
if ($ambiguos) echo "\n";
// --- 2. Escribir el BSUID en el usuario que corresponde --- // --- 2. Escribir el BSUID en el usuario que corresponde ---
$db = Database::getInstance();
$marcados = 0; $noExisten = 0; $yaEstaban = 0; $conflictos = 0; $marcados = 0; $noExisten = 0; $yaEstaban = 0; $conflictos = 0;
foreach ($mapa as $bsuid => $telefono) { foreach ($elegido as $telefono => $bsuid) {
$u = $db->fetch("SELECT id, bsuid FROM users WHERE phone_number = :t", ['t' => $telefono]); $u = $db->fetch("SELECT id, bsuid FROM users WHERE phone_number = :t", ['t' => $telefono]);
if (!$u) { $noExisten++; continue; } if (!$u) { $noExisten++; continue; }
if ($u['bsuid'] === $bsuid) { $yaEstaban++; continue; } if ($u['bsuid'] === $bsuid) { $yaEstaban++; continue; }
if (!empty($u['bsuid'])) { if (!empty($u['bsuid'])) {
// El BSUID se regenera si la persona cambia de número: gana el más reciente printf(" aviso: %s tenía %s y queda con %s\n", $telefono, $u['bsuid'], $bsuid);
printf(" aviso: %s tenía %s y ahora %s\n", $telefono, $u['bsuid'], $bsuid);
$conflictos++; $conflictos++;
} }
if (!$simular) { if (!$simular) {
+162
View File
@@ -0,0 +1,162 @@
<?php
/**
* Verificación de la identidad por BSUID (nombres de usuario de WhatsApp).
* Ejecutar: php test_bsuid.php
*/
require_once __DIR__ . '/../services/WhatsAppService.php';
$fallos = 0;
function chequear($descripcion, $condicion) {
global $fallos;
if ($condicion) { echo " ok $descripcion\n"; }
else { echo " FALLA $descripcion\n"; $fallos++; }
}
// --- 1. Reconocer un BSUID y no confundirlo con un teléfono ---
chequear('reconoce el BSUID real de hoy', WhatsAppService::esBsuid('CO.1761088155094242'));
chequear('reconoce el BSUID largo de Meta', WhatsAppService::esBsuid('US.13491208655302741918'));
chequear('un celular NO es BSUID', !WhatsAppService::esBsuid('573214533764'));
chequear('vacío NO es BSUID', !WhatsAppService::esBsuid(''));
chequear('texto suelto NO es BSUID', !WhatsAppService::esBsuid('CO.abc'));
// --- 2. formatPhoneNumber no debe destrozar el BSUID ---
$svc = (new ReflectionClass('WhatsAppService'))->newInstanceWithoutConstructor();
$fmt = new ReflectionMethod('WhatsAppService', 'formatPhoneNumber');
$fmt->setAccessible(true);
chequear('el BSUID sale intacto',
$fmt->invoke($svc, 'CO.1761088155094242') === 'CO.1761088155094242');
chequear('el celular sin indicativo sigue recibiendo el 57',
$fmt->invoke($svc, '3214533764') === '573214533764');
chequear('el celular con indicativo se conserva',
$fmt->invoke($svc, '573214533764') === '573214533764');
// --- 3. El payload debe usar `recipient` para BSUID y `to` para teléfonos ---
// Se replica la transformación de sendMessage(), que es privada y hace red.
$transformar = function (array $payload) {
if (isset($payload['to']) && WhatsAppService::esBsuid($payload['to'])) {
$payload['recipient'] = $payload['to'];
unset($payload['to']);
}
return $payload;
};
$conBsuid = $transformar(['to' => 'CO.1761088155094242', 'type' => 'text']);
chequear('con BSUID se envía en `recipient`', ($conBsuid['recipient'] ?? null) === 'CO.1761088155094242');
chequear('con BSUID desaparece `to`', !isset($conBsuid['to']));
$conTel = $transformar(['to' => '573214533764', 'type' => 'text']);
chequear('con teléfono se mantiene `to`', ($conTel['to'] ?? null) === '573214533764');
chequear('con teléfono no aparece `recipient`', !isset($conTel['recipient']));
// --- 4. El webhook debe extraer el remitente del payload real que llegó hoy ---
$payloadReal = json_decode('{"messaging_product":"whatsapp",
"contacts":[{"profile":{"name":"con la fe puesta en Dios","username":"Luzmarytosfer"},
"user_id":"CO.1761088155094242"}],
"messages":[{"from_user_id":"CO.1761088155094242","id":"wamid.XXX",
"timestamp":"1786481656","text":{"body":"Buenos dias"},"type":"text"}]}', true);
$m = $payloadReal['messages'][0];
$remitente = $m['from'] ?? ($m['wa_id'] ?? ($m['from_user_id'] ?? null));
chequear('el mensaje que se perdía ahora sí tiene remitente', $remitente === 'CO.1761088155094242');
$nombres = [];
foreach ($payloadReal['contacts'] as $c) {
$n = $c['profile']['name'] ?? null;
if (!$n) continue;
foreach ([$c['wa_id'] ?? null, $c['user_id'] ?? null] as $k) {
if ($k) $nombres[$k] = $n;
}
}
chequear('se rescata el nombre aunque no haya wa_id',
($nombres['CO.1761088155094242'] ?? null) === 'con la fe puesta en Dios');
// --- 5. El identificador debe caber en la columna ---
chequear('el BSUID más largo cabe en varchar(32)', strlen('US.13491208655302741918') <= 32);
// --- 6. Extraer el teléfono del contacto compartido (webhook `contacts` de Meta) ---
$extraerTelefono = function (array $mensaje) {
foreach ($mensaje['contacts'] ?? [] as $c) {
foreach ($c['phones'] ?? [] as $t) {
$cand = $t['wa_id'] ?? ($t['phone'] ?? null);
if ($cand) return preg_replace('/[^0-9]/', '', $cand);
}
}
return null;
};
$porBoton = ['type' => 'contacts', 'from_user_id' => 'CO.1761088155094242',
'contacts' => [['origin' => 'contact_request',
'phones' => [['phone' => '+57 321 4533764', 'wa_id' => '573214533764', 'type' => 'CELL']]]]];
chequear('del botón se extrae el teléfono', $extraerTelefono($porBoton) === '573214533764');
// Compartido a mano: puede venir con vcard y sin wa_id
$aMano = ['type' => 'contacts', 'from_user_id' => 'CO.1761088155094242',
'contacts' => [['origin' => 'other', 'vcard' => 'BEGIN:VCARD...',
'phones' => [['phone' => '+57 321 453 3764', 'type' => 'CELL']]]]];
chequear('compartido a mano también se extrae', $extraerTelefono($aMano) === '573214533764');
chequear('sin teléfono devuelve nulo', $extraerTelefono(['type' => 'contacts', 'contacts' => [[]]]) === null);
// --- 7. El botón para pedir el contacto debe armarse como exige Meta ---
$pedir = new ReflectionMethod('WhatsAppService', 'pedirContacto');
chequear('pedirContacto existe y es pública', $pedir->isPublic());
$payloadBoton = [
'messaging_product' => 'whatsapp',
'recipient_type' => 'individual',
'to' => $fmt->invoke($svc, 'CO.1761088155094242'),
'type' => 'interactive',
'interactive' => ['type' => 'request_contact_info',
'body' => ['text' => 'texto'],
'action' => ['name' => 'request_contact_info']],
];
$enviado = $transformar($payloadBoton);
chequear('el botón viaja con `recipient` cuando es BSUID',
($enviado['recipient'] ?? null) === 'CO.1761088155094242' && !isset($enviado['to']));
chequear('el tipo interactivo es el que Meta espera',
$enviado['interactive']['type'] === 'request_contact_info'
&& $enviado['interactive']['action']['name'] === 'request_contact_info');
// --- 8. La ficha del paciente no debe recibir el BSUID como teléfono ---
// Réplica de la decisión de Paciente::obtenerOCrearDesdeWhatsapp()
$fichaDesde = function ($phoneNumber, $nombre = null) {
$sinTelefono = esBsuid($phoneNumber);
return [
'nombre_completo' => $nombre ?? ($sinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $phoneNumber),
'telefono' => $sinTelefono ? null : $phoneNumber,
];
};
$fichaOculta = $fichaDesde('CO.1761088155094242');
chequear('la ficha queda SIN teléfono si es BSUID', $fichaOculta['telefono'] === null);
chequear('y sin el identificador metido en el nombre',
strpos($fichaOculta['nombre_completo'], 'CO.') === false);
$fichaNormal = $fichaDesde('573214533764');
chequear('con teléfono real la ficha lo conserva', $fichaNormal['telefono'] === '573214533764');
chequear('y el nombre por defecto no cambia', $fichaNormal['nombre_completo'] === 'Paciente 573214533764');
// El daño concreto que se evita: normalizarTelefono() dejaba 16 dígitos con
// toda la pinta de un número real dentro de la historia clínica.
$comoQuedaba = preg_replace('/[^0-9+]/', '', 'CO.1761088155094242');
chequear('antes se guardaba un número falso creíble', $comoQuedaba === '1761088155094242');
chequear('y ahora eso ya no llega al campo teléfono', $fichaOculta['telefono'] !== $comoQuedaba);
// --- 9. Solo se pide el contacto a quien hace falta, y una sola vez ---
$decidir = function ($phoneNumber, $pedidoAt) {
if (!esBsuid($phoneNumber)) return 'no_hace_falta';
if (!empty($pedidoAt)) return 'ya_se_pidio';
return 'pedido';
};
chequear('a quien tiene teléfono no se le pide', $decidir('573214533764', null) === 'no_hace_falta');
chequear('a quien lo oculta sí se le pide', $decidir('CO.1761088155094242', null) === 'pedido');
chequear('y no se le insiste una segunda vez',
$decidir('CO.1761088155094242', '2026-08-11 16:00:00') === 'ya_se_pidio');
// --- 10. La comprobación vive en un solo sitio ---
chequear('esBsuid() global y el del servicio coinciden',
esBsuid('CO.1761088155094242') === WhatsAppService::esBsuid('CO.1761088155094242')
&& esBsuid('573214533764') === WhatsAppService::esBsuid('573214533764'));
echo $fallos === 0 ? "\nTodo correcto.\n" : "\n$fallos verificaciones fallaron.\n";
exit($fallos === 0 ? 0 : 1);
+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);
+3 -2
View File
@@ -917,11 +917,12 @@ class WhatsAppService
/** /**
* ¿El destinatario es un BSUID (identificador de usuario por empresa) y no un teléfono? * ¿El destinatario es un BSUID (identificador de usuario por empresa) y no un teléfono?
* Formato de Meta: código de país, punto y dígitos. Ej.: "CO.1761088155094242". * La definición vive en config.php, que es donde la ven también las clases del
* laboratorio; aquí solo se expone para quien ya tiene el servicio a la mano.
*/ */
public static function esBsuid($destinatario) public static function esBsuid($destinatario)
{ {
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $destinatario); return esBsuid($destinatario);
} }
/** /**
+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;