57 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
Lizandro GuarnizoandClaude Opus 5 26025a6399 Reconocer por BSUID a quien oculta su teléfono, y poder pedírselo
Meta no permite averiguar el teléfono a partir del BSUID: no hay endpoint de
consulta inversa, cada empresa debe llevar su propia equivalencia. Pero el
BSUID llega en TODOS los webhooks de mensaje, también en los que aún traen
teléfono, así que la equivalencia se puede ir guardando sola mientras la
persona todavía muestra su número.

- users.bsuid guarda esa equivalencia, y el webhook la anota en cada mensaje.
  Cuando alguien oculte su número, se le seguirá reconociendo y respondiendo
  a su teléfono de siempre.
- Va en columna aparte y no en phone_number porque el teléfono además cruza
  con el paciente y el turnero; mezclarlos rompería esos cruces.
- Para quien nunca escribió mostrando el número queda pedirle el contacto:
  pedirContacto() manda el botón request_contact_info, y el webhook atiende
  el mensaje `contacts` que llega si acepta.
- Al vincular puede aparecer un segundo registro de la misma persona. No se
  fusionan: una fusión mal hecha mezcla dos historias clínicas. Gana el que
  tiene el teléfono, el otro queda anotado en el log para revisarlo a mano.
- scripts/backfill_bsuid.php carga las equivalencias del histórico (6.423),
  con --simular para verlas sin escribir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:07:52 -05:00
Lizandro GuarnizoandClaude Opus 5 92179af56a Atender a quien oculta su teléfono en WhatsApp (BSUID)
Meta desplegó los nombres de usuario: quien oculta su teléfono llega al
webhook sin `from` ni `wa_id`, identificado solo por su BSUID con la forma
"CO.1761088155094242". El webhook exigía teléfono y descartaba el mensaje
en silencio, sin guardarlo ni dejar rastro: 221 mensajes de 65 personas
ignorados por completo, ya el 3-6% de lo que entra cada día y subiendo.

- webhook.php lee `from_user_id` cuando no llega el teléfono, y rescata el
  nombre del contacto indexando también por `user_id`.
- WhatsAppService responde por BSUID: Meta exige el campo `recipient` en
  lugar de `to`. Se traduce en sendMessage(), el punto por donde pasan todos
  los envíos, en vez de en los diez métodos que arman payloads.
- formatPhoneNumber() devuelve el BSUID intacto; antes le quitaba el punto
  y las letras y lo dejaba en un destinatario inexistente.
- Las cuatro columnas del flujo pasan a varchar(32): los BSUID de Meta
  llegan a 23 caracteres y se truncaban en silencio en varchar(20).

De estas personas no tendremos el teléfono; si un flujo lo necesita, hay
que identificarlas por documento.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:41:10 -05:00
Lizandro GuarnizoandClaude Opus 5 381c2ca04e Rol Calidad incluye también formularios
La coordinación del SIG administra los documentos del sistema además de revisar
los tiempos del proceso, así que el rol suma lab_formularios en solo lectura.
Permite consolidar en una sola cuenta a quien antes necesitaba dos accesos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:42:57 -05:00
Lizandro GuarnizoandClaude Opus 5 3a7b267f65 Rol de Calidad, y restricción por rol dentro del turnero
Rol nuevo para consultar turnos y tiempos de atención, de solo lectura y con
acceso únicamente a dashboard e historial. Se le permite exportar el historial,
que es la forma de analizar tiempos fuera del sistema.

Al crearlo salió que las vistas del turnero no verificaban nada por su cuenta:
el control del ERP es por módulo, así que cualquiera con acceso al turnero podía
abrir Configuración —lugares, dispositivos, plantillas— escribiendo la URL,
aunque el menú no se la mostrara. Aplicaba a todos los roles, no solo al nuevo.

Se agrega _acceso.php con una verificación de rol que usan las tres pantallas
que operan sobre la atención o cambian configuración. Verificado que cada rol
conserva lo que ya usaba: recepcionista entra a recepción, bacteriólogo a las
estaciones, supervisor y administradores a todo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:50:24 -05:00
Lizandro GuarnizoandClaude Opus 5 dc17c6a469 Permitir atender Pediatría y Ginecología desde equipos con estación fija
Un equipo registrado por IP o token queda atado a su estación: si pedía otra,
lugar.php lo devolvía a la suya sin mostrar nada, y parecía que el enlace estaba
roto. La restricción tiene sentido para las Tomas de Muestras, pero Pediatría y
Ginecología se atienden desde cualquier puesto.

Se agrega la marca acceso_libre en turnero_lugares en vez de codificar los
nombres, para que mañana se habilite otra estación sin tocar código. Las
marcadas así no redirigen y aparecen en el menú del equipo junto a la suya.

El bloqueo se mantiene en todo lo demás: el equipo sigue atado a su estación, el
botón de cambiar sigue oculto y no puede saltar a una Toma de Muestras ajena.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:47:11 -05:00
Lizandro GuarnizoandClaude Opus 5 813b712df0 Motivo al marcar ausente, y notas visibles con su origen en dashboard e historial
Marcar ausente solo pedía confirmación: el turno quedaba registrado sin ninguna
explicación, y hoy son 6 en un día. Ahora se pide el motivo y se guarda como
comentario de recepción, no como columna nueva: así aparece en todas las
pantallas que ya muestran comentarios, sin duplicar el dato ni tocar el esquema.

Dashboard e historial mostraban las notas sin indicar de dónde venían, así que no
se distinguía una observación de toma de muestras de una de recepción. Se agrega
la insignia de origen con los mismos colores que ya usa la bandeja, y el título
pasa a "Notas y observaciones" con su cantidad.

El historial además traía solo las 3 últimas por turno y en orden inverso. Con
recepción empezando a dejar notas eso habría escondido justamente el motivo de
una ausencia sin que nada indicara que faltaban; ahora trae hasta 20, en orden
cronológico.

De las 114 notas existentes, todas son de toma de muestras: recepción nunca dejó
ninguna porque no tenía dónde hacerlo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:37:33 -05:00
Lizandro GuarnizoandClaude Opus 5 4bc22bd00c Pantalla TV: el llamado por voz salía entrecortado y con retraso
El troceo lo causaba el keepalive del sintetizador: corría cada 10 segundos sin
verificar si estaba hablando, y pause() a mitad de frase la corta. Como un
anuncio con nombre y destino supera los 10 segundos, lo alcanzaba siempre. Ahora
solo actúa con el sintetizador en reposo, que es para lo que existe: evitar que
Chrome lo suspenda tras un rato sin uso.

El cartel se cerraba a los 5 segundos aunque la voz siguiera sonando, y al
cerrarse pasaba al siguiente de la cola, que cancelaba el anuncio en curso. Si
dos pacientes se llamaban seguido, el primero quedaba a media frase. Ahora se
cierra cuando terminó de hablar y se cumplió el mínimo visual, con una red de
seguridad por si el navegador no emite el evento de fin.

Sobre la demora: se quita la espera fija de 300 ms antes de hablar —solo se
mantiene, y reducida, cuando de verdad hay que cancelar algo en curso— y el
sondeo baja de 2 s a 1 s. El retraso desde el clic pasa de 0,3–2,3 s a 0–1 s.

La velocidad sube de 0.85 a 0.95: acorta el anuncio sin perder claridad.

Validado con una simulación del reloj y del sintetizador: reproduce el corte
con el código anterior y no ocurre con el nuevo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:05:56 -05:00
Lizandro GuarnizoandClaude Opus 5 c00faa2b06 Corregir la ficha del paciente desde el propio formulario
El acceso quedaba solo en la tarjeta del paciente de la estación: al ver un dato
mal mientras se llenaba el formulario había que cerrarlo, buscar el botón y
volver a abrirlo. Ahora los datos del paciente dentro del formulario llevan un
lápiz que abre el mismo modal, ya en modo edición, sin cerrar lo que se está
llenando.

El formulario va embebido, así que no puede abrir el modal por su cuenta: le
avisa al contenedor por el mismo mecanismo que ya usa para informar una firma.
El modal queda por encima del formulario (z-index 9500 contra 9000).

De paso se corrige un error del commit anterior: el refresco tras guardar
buscaba el iframe con un selector equivocado —el modal es modal-consentimiento,
no modal-consent—, así que el formulario nunca tomaba el dato corregido sin
recargar a mano. Ahora se recargan los dos iframes posibles por su id real.

El lápiz aparece una sola vez, en el primer campo vinculado, y no se imprime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:55:54 -05:00
Lizandro GuarnizoandClaude Opus 5 a6bf35cb58 Corregir secciones condicionales que dejaron de aparecer, y habilitar la obstétrica
Regresión introducida hoy en 8948ea6: al ocultar con display:none los campos de
secciones cuya condición no se cumple, dejaron de aparecer al marcar la opción
que las activa. El evaluador de condiciones anima con max-height y opacity pero
nunca tocaba display, así que el campo quedaba invisible pese a "mostrarse".
Afectaba a Tipo de dolor, Tipo de cáncer, Grupo sanguíneo y Otro antecedente
familiar de F-LAB-08.

Al revisarlo apareció un problema anterior en DATOS OBSTÉTRICOS, que depende del
sexo del paciente: `genero` nunca se entregó como clave vinculable, así que la
condición no podía evaluarse ni en PHP ni en el navegador. Antes esos campos
salían siempre visibles —también para hombres— porque los campos de secciones
ocultas se renderizaban igual; al corregir eso quedaban inaccesibles.

Se expone `genero`, y tanto el evaluador de PHP como el del navegador resuelven
condiciones que dependen de un campo vinculado: en PHP leyendo la ficha del
paciente, en el navegador leyendo el valor mostrado.

Validado con node reproduciendo la falla y las siete combinaciones de las cinco
secciones condicionales, incluidas paciente mujer y hombre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:10:20 -05:00
Lizandro GuarnizoandClaude Opus 5 57818b180a Estación de muestras: corregir la ficha del paciente desde el turno
Los campos del formulario F-LAB-08 son vinculados: leen de la ficha del
paciente. Si el dato está mal, no había forma de arreglarlo desde la estación
—el modal era de solo lectura— y corregirlo únicamente en el documento habría
dejado la ficha equivocada, reapareciendo en el próximo turno, orden o
consentimiento. Por eso se corrige la ficha y no el documento: el dato queda
bien en todo el sistema y el formulario, al ser automático, lo toma solo.

La edición tiene dos niveles de fricción. Teléfono, dirección y EPS se editan
directo: cambian seguido y el error es de bajo riesgo. Nombre, tipo y número
de documento y fecha de nacimiento quedan tras un botón aparte con
confirmación, porque identifican al paciente en toda su historia clínica.

Si el turno ya tiene documentos firmados se advierte que corregir la ficha no
los modifica y que hay que emitirlos de nuevo, para que nadie espere que un
PDF ya firmado se actualice solo.

Paciente::actualizar registraba en la bitácora solo el valor nuevo, lo que
impedía reconstruir el anterior si la corrección resultaba equivocada —justo
cuando hace falta consultarlo. Ahora guarda antes y después, y únicamente de
los campos que realmente cambiaron.

La validación del servidor ya cubría nombre, celular, documento y correo, y la
cédula tiene índice único, así que no hizo falta agregar validación.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:05:48 -05:00
Lizandro GuarnizoandClaude Opus 5 fd46a86007 Campos vinculados: entregar la dirección del paciente y completar desde datos vivos
El consentimiento F-LAB-01 tiene un campo vinculado a `direccion`, pero esa
clave no estaba entre las que el sistema entrega, así que quedaba vacía. Como
en modo vista los campos vacíos no se imprimen, el campo desaparecía del
documento en vez de salir en blanco, y por eso pasó inadvertido.

Se agrega `direccion` a las tres consultas de paciente y a las claves
vinculables. La tiene cargada el 61% de los pacientes.

Además, en los envíos de formulario los campos vinculados leían solo de
datos_prefilled, que es una copia tomada al momento de enviar: una clave
agregada después nunca aparecía en documentos ya emitidos. Ahora se completa
con los datos vivos del paciente, sin pisar lo que la copia ya traía.

Aparte, al esquema del formulario 17 le faltaba el campo del número de
documento —solo pedía el tipo—, lo que se corrigió en la definición del
formulario. Los 706 consentimientos ya firmados no se modifican.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 08:07:42 -05:00
Lizandro GuarnizoandClaude Opus 5 5d4315fae7 Recepción: el contador de exámenes no se actualizaba en las cargas silenciosas
La insignia con la cantidad de exámenes solo se refrescaba desde los callbacks
onItemAdd/onItemRemove de TomSelect, y hay cuatro rutas que manipulan la
selección por código sin dispararlos:

- Importar desde RIPS usa addItem(id, true) —silencioso a propósito, para no
  recalcular precios en cada examen—, así que la insignia quedaba en cero
  aunque se hubieran cargado varios.
- El botón "Limpiar selección", toggleSoloMuestras y resetCheckboxes usan
  clear(), que internamente quita los ítems en modo silencioso: la insignia
  conservaba el número anterior.

Al revisarlo apareció un segundo efecto en toggleSoloMuestras: limpiaba los
exámenes sin recalcular, dejando el panel de precios mostrando exámenes que
ya no estaban seleccionados.

Se agrega limpiarExamenes(), que limpia y sincroniza contador y precios, y se
usa en las tres rutas de limpieza; la carga desde RIPS actualiza el contador
una vez al terminar, conservando el modo silencioso por examen.

Validado con node sobre un doble de TomSelect que replica el comportamiento
silencioso: los cuatro escenarios pasan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 07:59:31 -05:00
Lizandro GuarnizoandClaude Opus 5 9b5e144386 Exportación de la documentación a PDF mediante vista de impresión
Nueva vista que reúne los 32 documentos en un solo entregable maquetado para
imprimir: portada con los datos del laboratorio, índice numerado, y cada
capítulo y documento empezando en página nueva.

Se resolvió como vista y no como archivo generado para que el entregable
refleje siempre el estado actual, incluidos los inventarios técnicos que se
generan del código y de la base en cada consulta. No hay paso de regeneración
que alguien pueda olvidar.

El CSS de impresión evita cortes a mitad de tabla, bloque de código o cita,
repite el encabezado en tablas que abarcan varias páginas y oculta la barra de
acciones. Permite exportar todo o una sección puntual, y respeta la
visibilidad por rol: cada usuario exporta lo que puede leer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:31:22 -05:00
Lizandro GuarnizoandClaude Opus 5 bc318db129 Documentación: menús y respuestas del bot, y manual de domicilios
El análisis de completitud cruzando roles contra módulos y tablas contra
documentos encontró dos huecos reales.

Manual de domicilios: recepcionista, lab_recepcion y lab_readonly tienen acceso
a la pantalla administrativa de domicilios, y la única página sobre el tema era
la del portal del enfermero, restringida a enfermeros. Son pantallas distintas
—una ve todo, la otra solo lo propio— y ahora cada una tiene su guía. Los
enfermeros pasan también a ver el manual de formularios, que su rol habilita.

Menús y respuestas automáticas del bot: había cuatro tablas en uso que ninguna
página explicaba. El bot responde solo las preguntas frecuentes mediante
autoresponses (7 configuradas) y ofrece un menú interactivo de 21 opciones en
dos niveles, todo configurable desde la base sin desplegar código. También
quedan documentados el estado de conversación, los envíos masivos y las
encuestas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:04:27 -05:00
Lizandro GuarnizoandClaude Opus 5 74e219e2ae LIA consulta solo el manual de usuario, y descarta coincidencias irrelevantes
El asistente está para ayudar a usar el sistema, no a mantenerlo: la
documentación técnica, de arquitectura y de operación queda fuera de su
contexto incluso para administradores, que pueden leerla directamente en el
módulo. El filtrado por rol sigue aplicándose dentro del manual.

Al restringirlo apareció que la relevancia era débil: una pregunta sobre
respaldos devolvía Recepción y Kiosko porque palabras comunes como "paciente"
suman igual en todos los documentos. Ahora cada palabra pesa según en cuántos
documentos aparece, y se descartan los resultados que no superen un umbral
absoluto ni queden cerca del mejor. Una pregunta fuera del manual devuelve
cero fuentes y LIA lo dice, en vez de responder con lo que tenga a mano.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:00:04 -05:00
Lizandro GuarnizoandClaude Opus 5 9c2ef19510 Documentación: órdenes médicas, formularios y reportes; cobertura por rol completa
Al cruzar los roles con sus módulos aparecieron manuales que faltaban: había
roles con acceso a pantallas que ninguna página explicaba. lab_readonly tenía
órdenes médicas, formularios_readonly tenía el diseñador de formularios y
supervisor tenía reportes, y ninguno de los tres estaba documentado.

Manual: órdenes médicas (flujo de revisión y autorización, con el ayuno
señalado como el dato de mayor consecuencia si se transcribe mal), formularios
(tipos de campo, secciones condicionales, quién firma qué) y reportes (la
diferencia entre el dashboard del turnero y el módulo de reportes, que miran
cosas distintas y no son comparables).

Técnica: órdenes médicas con sus estados y trazabilidad, reportes,
configuración del laboratorio y dashboard — los cuatro módulos de la
generación anterior que quedaban sin página.

Ahora cada rol ve solo sus páginas: readonly ve 2, los roles operativos entre
3 y 5, supervisor 10 y los administradores las 30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:35:38 -05:00
Lizandro GuarnizoandClaude Opus 5 fe96e5b60d Documentación en usted, y panel de LIA con el mismo diseño del dashboard
Toda la documentación pasa de voseo a tratamiento de usted, incluidos los
diagramas y los textos de la interfaz. Los prompts de ambos asistentes lo
piden explícitamente, para que las respuestas generadas también lo respeten.

El panel de LIA en la página de documentación adopta el diseño del dashboard
del turnero: mismo botón circular, mismo panel deslizante con encabezado en
degradado y logo LIA, mismos chips de atajo, burbujas y campo de entrada.
Los atajos se arman con los documentos que ese usuario puede ver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:57:28 -05:00
Lizandro GuarnizoandClaude Opus 5 71c9dfd424 Documentación: diagramas, kiosko y chat, catálogos, respaldos y endpoints documentados
Diagramas en texto dentro de bloques de código, en vez de capturas: se editan
como texto, no pesan en el repositorio y no quedan desactualizados solos. Se
agregan el recorrido de un turno, quién firma cada consentimiento, el paso de
una muestra pendiente entre visitas y la línea de tiempo de las tomas seriadas.

Páginas nuevas del manual: kiosko y pantallas de TV (las dos que funcionan sin
nadie operándolas), y chat de WhatsApp, que explica por qué el bot deja de
responder cuando un operador toma la conversación y de dónde sale el límite de
24 horas.

Páginas técnicas nuevas: registro de exámenes, y los catálogos del laboratorio
agrupados en una sola página por compartir la misma forma. Operación suma
respaldos y recuperación, incluyendo qué datos históricos no son recuperables.

Los 10 endpoints que no tenían comentario de cabecera ahora lo tienen, así que
la tabla generada por {{endpoints}} queda completa: 83 de 83.

DOCUMENTACION_LAB.md y README_LAB.md quedan como puntero al módulo;
WEBHOOK_ENDPOINTS.md se elimina por estar ya migrado a la sección técnica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:49:02 -05:00
Lizandro GuarnizoandClaude Opus 5 81c007c516 LIA responde sobre la documentación, filtrada por el rol de quien pregunta
DocIndex::contextoIA() selecciona los documentos relevantes a la pregunta y
devuelve su texto. Reutiliza el filtrado por rol ya existente: al contexto que
se envía al modelo solo entra lo que ese usuario podría leer por su cuenta en
el módulo Soporte, así el asistente no puede revelar contenido restringido.
Verificado: un recepcionista preguntando por permisos recibe solo el manual
básico, sin el SQL ni los detalles internos que sí recibe un administrador.

- services/GeminiService.php concentra la llamada a la API y la contabilidad
  del presupuesto, que antes vivía dentro de ai_chat.php y ahora comparten los
  dos asistentes.
- La LIA del turnero suma la documentación a su contexto operativo, así responde
  tanto "cuánto facturamos hoy" como "cómo marco un paciente ausente".
- modules/soporte/api/ai_docs.php es el asistente de la documentación: no accede
  a datos de pacientes ni de la operación, solo a los documentos visibles. A
  diferencia del anterior no exige acceso al turnero, así que lo puede usar
  cualquier usuario autenticado desde la página de documentación.
- La respuesta cita de qué documentos salió.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:41:40 -05:00
Lizandro GuarnizoandClaude Opus 5 dc27184051 Documentación: visibilidad por rol en cada documento y una sola entrada de menú
Un documento puede declarar qué roles lo ven mediante una cabecera al inicio
del archivo; sin ella hereda el permiso de su sección. Los administradores ven
todo. Se aplica tanto al índice como al acceso directo por URL, y una sección
que queda sin documentos visibles deja de mostrarse.

Con esto un recepcionista ve solo el manual de recepción, un bacteriólogo el de
toma de muestras y un enfermero el suyo, en vez del manual completo.

El módulo declaraba cinco enlaces que apuntaban todos a la misma vista, así que
al abrir una sección quedaban dos entradas marcadas como activas a la vez. Queda
una sola, "Documentación": la navegación por secciones ya vive dentro de la
página.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:37:33 -05:00
94 changed files with 5478 additions and 1075 deletions
+5 -518
View File
@@ -1,522 +1,9 @@
# Documentación del Sistema de Laboratorio
# Obsoleto
> **Sistema**: Laboratorio Clínico — Módulos de Agendamiento y Formularios
> **Última actualización**: 27/03/2026
Este documento quedó desactualizado y se conserva solo por historial de git.
---
La documentación vigente está **dentro del sistema**, en el módulo Soporte:
## Tabla de contenidos
/erp.php?m=soporte&v=documentacion
1. [Módulo de Agendamiento (Domicilios)](#1-módulo-de-agendamiento-domicilios)
- [¿Qué es?](#qué-es)
- [Archivos involucrados](#archivos-involucrados)
- [Base de datos](#base-de-datos)
- [Roles y permisos](#roles-y-permisos)
- [Flujo de estados](#flujo-de-estados)
- [Funcionalidades](#funcionalidades)
- [API Endpoints](#api-endpoints)
2. [Módulo de Formularios](#2-módulo-de-formularios)
- [¿Qué es?](#qué-es-1)
- [Archivos involucrados](#archivos-involucrados-1)
- [Base de datos](#base-de-datos-1)
- [Roles y permisos](#roles-y-permisos-1)
- [Flujo completo](#flujo-completo)
- [Tipos de campos del Builder](#tipos-de-campos-del-builder)
- [Firma digital](#firma-digital)
- [Enlace público y vigencia](#enlace-público-y-vigencia)
- [PDF y visualización del documento](#pdf-y-visualización-del-documento)
- [Sello de integridad SHA-256](#sello-de-integridad-sha-256)
- [Firma del profesional](#firma-del-profesional)
- [API Endpoints](#api-endpoints-1)
---
## 1. Módulo de Agendamiento (Domicilios)
### ¿Qué es?
El módulo de agendamiento gestiona los **servicios de toma de muestras a domicilio**. Permite crear, asignar, seguir y completar visitas médicas domiciliarias. Los administradores gestionan la agenda desde `lab_domicilios.php`; los enfermeros gestionan su propia agenda desde `enfermero_portal.php`.
---
### Archivos involucrados
| Archivo | Descripción |
|---|---|
| `lab_domicilios.php` | Vista principal del admin — tabla con filtros, detalle del domicilio, formularios recibidos, exportar CSV |
| `enfermero_portal.php` | Portal exclusivo del enfermero — su agenda personal del día, ordenada por hora, con tarjetas colapsables |
| `lab_enfermeras.php` | CRUD del personal de enfermería |
| `classes/lab/Domicilio.php` | Clase ORM — crear, editar, cambiar estado, estadísticas |
| `classes/lab/Enfermera.php` | Clase ORM — CRUD, agenda por enfermero, carga de trabajo |
| `classes/lab/Asignacion.php` | Clase ORM — asignar / reasignar / liberar enfermero a domicilio |
| `api/lab/get_domicilios.php` | GET — lista de domicilios con filtros y estadísticas del día |
| `api/lab/save_domicilio.php` | POST — crear, actualizar o cambiar estado |
| `api/lab/update_domicilio_enfermero.php` | POST — el enfermero avanza el estado desde su portal |
| `api/lab/my_agenda.php` | GET — agenda del enfermero actualmente autenticado |
| `api/lab/save_asignacion.php` | POST — asignar o reasignar enfermero |
| `api/lab/get_asignaciones.php` | GET — asignaciones por fecha |
| `api/lab/registrar_pago.php` | POST — registrar pago de un domicilio |
| `api/lab/save_servicio_extra.php` | POST — agregar servicio realizado durante la visita |
| `api/lab/get_notas_domicilio.php` | GET — notas clínicas y libres del domicilio |
| `api/lab/upload_nota_imagen.php` | POST — subir imagen adjunta a una nota |
| `api/lab/crear_desde_whatsapp.php` | GET/POST — crear paciente u orden desde una conversación de WhatsApp activa |
---
### Base de datos
#### Tabla `lab_domicilios`
Tabla principal. Cada fila es un servicio a domicilio.
| Campo | Tipo | Descripción |
|---|---|---|
| `id` | INT PK | Identificador único |
| `paciente_id` | INT FK | Paciente al que se le realiza el servicio |
| `orden_id` | INT FK NULL | Orden médica adjunta (opcional) |
| `direccion` | TEXT | Dirección completa de la visita |
| `ciudad` | VARCHAR(100) | Ciudad |
| `barrio` | VARCHAR(100) | Barrio |
| `indicaciones_dir` | TEXT | Referencias o indicaciones adicionales ("apto 302, tocar campanilla") |
| `fecha_programada` | DATE | Fecha de la visita |
| `hora_programada` | TIME | Hora de la visita |
| `tipo_servicio` | VARCHAR(100) | Tipo de servicio (toma de muestra, etc.) |
| `tipo_cliente` | ENUM | `particular` / `seguro` / `eps` |
| `examenes_solicitados` | TEXT | Lista de exámenes (cuando no hay orden médica) |
| `seguro_nombre` | VARCHAR | Nombre del seguro o EPS |
| `autorizacion` | VARCHAR | Número de autorización |
| `valor_domicilio` | DECIMAL | Valor del servicio |
| `valor_copago` | DECIMAL | Copago a cargo del cliente |
| `copago_laboratorio` | DECIMAL | Copago al laboratorio |
| `pago_estado` | ENUM | `pending` / `pagado` / `exento` |
| `pago_modo` | ENUM | `efectivo` / `transferencia` / `otro` |
| `pago_monto` | DECIMAL | Monto pagado |
| `pago_fecha` | DATETIME | Fecha del pago |
| `pago_notas` | TEXT | Notas sobre el pago |
| `estado` | ENUM | Ver [Flujo de estados](#flujo-de-estados) |
| `motivo_cancelacion` | TEXT | Motivo si fue cancelado (obligatorio) |
| `fecha_reprogramada` | DATE | Nueva fecha si fue reprogramado |
| `hora_llegada` | TIME | Registrada automáticamente al iniciar la visita |
| `hora_salida` | TIME | Registrada automáticamente al completar |
| `observaciones` | TEXT | Observaciones del resultado de la visita |
| `muestras_tomadas` | TEXT | Lista de muestras obtenidas |
| `notas_admin` | TEXT | Notas internas del equipo administrativo |
| `creado_por` | INT FK | Usuario que creó el registro |
#### Tabla `lab_enfermeras`
Personal de enfermería disponible para asignación.
Campos: `id`, `numero_documento`, `tipo_documento` (CC/CE/TI/PA), `nombre_completo`, `telefono`, `telefono_alt`, `email`, `zona`, `notas`, `is_active`.
#### Tabla `lab_asignaciones`
Asignación de enfermero a domicilio. Máximo un enfermero activo por domicilio (`UNIQUE KEY` en `domicilio_id`).
Campos: `id`, `domicilio_id`, `enfermera_id`, `asignada_por`, `estado` (`asignada` / `confirmada` / `liberada` / `completada`), `notas`.
#### Tabla `lab_servicios_extra`
Servicios realizados por el enfermero durante la visita, adicionales a la orden original.
Tipos disponibles: `inyeccion`, `cura`, `nebulizacion`, `toma_muestra`, `tension_arterial`, `glucometria`, `otro`.
Campos: `id`, `domicilio_id`, `descripcion`, `tipo`, `notas`, `requiere_pago`, `valor`, `realizado_por`.
#### Tabla `lab_domicilio_notas`
Notas registradas por el enfermero durante la visita.
Dos tipos:
- **`clinica`**: datos de la ficha clínica — antecedentes, medicamentos, acudiente (si el paciente es menor de edad).
- **`libre`**: nota libre con título, cuerpo de texto enriquecido e imagen adjunta.
Campos: `id`, `domicilio_id`, `enfermera_id`, `tipo`, `antecedentes`, `medicamentos`, `acudiente_nombre`, `acudiente_documento`, `titulo`, `cuerpo`, `imagen_path`.
---
### Roles y permisos
| Rol | Acceso |
|---|---|
| **Admin** | Crear, editar y ver todos los domicilios. Asignar/reasignar enfermeros. Registrar pagos. Exportar Excel. Ver informe completo con notas. Ver agenda de cualquier enfermero usando `?eid=X`. |
| **Enfermero** | Solo ve su propia agenda (`enfermero_portal.php`). Avanza el estado de sus domicilios asignados. Agrega servicios extra. Registra notas clínicas y libres. Visualiza órdenes médicas adjuntas. |
> **Redirección automática**: si el usuario autenticado tiene rol `enfermero`, `lab_domicilios.php` lo redirige inmediatamente a `enfermero_portal.php`.
Los roles se definen en `admin_users.role` (ENUM `admin` / `enfermero`) y `admin_users.enfermera_id` (FK a `lab_enfermeras`).
---
### Flujo de estados
```
[programado]
│ El enfermero confirma que realizará la visita
[confirmado]
│ El enfermero sale hacia el domicilio
[en_camino]
│ El enfermero llega → hora_llegada se registra automáticamente
[en_domicilio]
│ El enfermero finaliza → hora_salida se registra automáticamente
[completado]
Desde cualquier estado:
→ [cancelado] (requiere motivo_cancelacion como campo obligatorio)
→ [reprogramado] (requiere fecha_reprogramada)
```
**Transiciones permitidas al enfermero** (validadas en `update_domicilio_enfermero.php`):
| Estado actual | Estados posibles |
|---|---|
| `programado` | `confirmado` |
| `confirmado` | `en_camino`, `cancelado` |
| `en_camino` | `en_domicilio`, `cancelado` |
| `en_domicilio` | `completado`, `cancelado` |
El **admin** puede cambiar a cualquier estado directamente, incluyendo cancelar desde cualquier punto.
---
### Funcionalidades
- **Filtros**: por fecha, estado, enfermero asignado. Botón "Hoy" para filtro rápido.
- **Resumen del día**: conteo de domicilios por estado en la parte superior.
- **Panel de detalle**: al hacer clic en un domicilio se abre el panel lateral con toda la información, notas clínicas, notas libres e informe imprimible.
- **Asignar / Reasignar enfermero**: modal con lista del personal disponible.
- **Sin asignar**: badge con el conteo de domicilios que aún no tienen enfermero.
- **Registrar pago**: modal para marcar el cobro con modalidad y monto.
- **Servicios extra**: el enfermero los agrega desde su portal durante la visita.
- **Notas del enfermero**: ficha clínica con antecedentes, medicamentos, acudiente (si menor) y notas libres con imagen adjunta.
- **Informe de domicilio**: vista imprimible del domicilio con datos del paciente, ficha clínica y notas del enfermero.
- **Exportar CSV (Excel)**: exporta todos los domicilios filtrados, incluyendo las columnas de notas del enfermero (antecedentes, medicamentos, acudiente, notas libres).
- **Formularios recibidos**: pestaña dentro de `lab_domicilios.php` que muestra formularios enviados con filtro por plantilla y estado.
- **Portal del enfermero**: tarjetas colapsables ordenadas por hora, separadas en "Activos" y "Finalizados". Permite avanzar estados, agregar notas y ver órdenes.
---
### API Endpoints
| Endpoint | Método | Descripción |
|---|---|---|
| `api/lab/get_domicilios.php` | GET | Lista con filtros. `?id=X` para uno solo con detalle completo. |
| `api/lab/save_domicilio.php` | POST | Crear, editar o cambiar estado. `?solo_estado=true` para solo cambiar estado. |
| `api/lab/update_domicilio_enfermero.php` | POST | El enfermero avanza el estado de su domicilio. |
| `api/lab/my_agenda.php` | GET | Agenda del enfermero autenticado con servicios extra. |
| `api/lab/save_asignacion.php` | POST | Asignar o reasignar enfermero a domicilio. |
| `api/lab/get_asignaciones.php` | GET | Asignaciones por fecha. |
| `api/lab/registrar_pago.php` | POST | Registrar pago con monto y modalidad. |
| `api/lab/save_servicio_extra.php` | POST | Agregar servicio realizado durante la visita. |
| `api/lab/get_notas_domicilio.php` | GET | Notas del domicilio (clínicas y libres). |
| `api/lab/upload_nota_imagen.php` | POST | Subir imagen adjunta a una nota libre. |
| `api/lab/crear_desde_whatsapp.php` | GET/POST | Crear paciente u orden desde una conversación de WhatsApp activa. |
---
## 2. Módulo de Formularios
### ¿Qué es?
El módulo de formularios permite crear **plantillas de documentos** (consentimientos, historias clínicas, autorizaciones, encuestas) mediante un builder visual, enviarlas a los pacientes por WhatsApp y recopilar sus respuestas con firma digital. El documento firmado genera un **sello de integridad SHA-256** que puede verificarse públicamente.
---
### Archivos involucrados
| Archivo | Descripción |
|---|---|
| `lab_formularios.php` | Vista principal — lista de plantillas y registro de envíos |
| `lab_formulario_builder.php` | Editor visual drag & drop (ventana separada, solo admin) |
| `form_cliente.php` | Página pública — el paciente llena y firma sin iniciar sesión |
| `ver_formulario_enviado.php` | Vista del documento firmado — acceso por ID (admin/enfermero) o token (cliente) |
| `verificar_formulario.php` | Verificación pública de autenticidad por hash SHA-256 |
| `classes/lab/Formulario.php` | Clase ORM — CRUD de plantillas, crear envíos, guardar respuestas, generar hash |
| `api/lab/get_formularios.php` | GET — lista plantillas o envíos |
| `api/lab/save_formulario.php` | POST — crear, editar y eliminar plantillas (solo admin) |
| `api/lab/send_formulario.php` | POST — crear instancia de envío, devolver URL pública y mensaje WhatsApp |
| `api/lab/submit_formulario.php` | GET/POST — cargar el formulario por token / guardar la respuesta del cliente |
| `api/lab/firmar_profesional.php` | POST — guardar firma del profesional (requiere sesión activa) |
---
### Base de datos
#### Tabla `lab_formularios`
Plantillas de documentos creadas desde el builder.
| Campo | Tipo | Descripción |
|---|---|---|
| `id` | INT PK | Identificador único |
| `nombre` | VARCHAR(150) | Nombre de la plantilla |
| `descripcion` | TEXT | Descripción visible al cliente |
| `categoria` | ENUM | `consentimiento` / `historia_clinica` / `autorizacion` / `encuesta` / `otro` |
| `esquema` | LONGTEXT | JSON con el array de campos del formulario |
| `permite_firma` | TINYINT(1) | El formulario tiene sección de firma global |
| `requiere_firma` | TINYINT(1) | La firma global es obligatoria |
| `firma_modos` | VARCHAR(50) | `canvas`, `foto` o `canvas,foto` (separados por coma) |
| `version` | SMALLINT | Se incrementa automáticamente al editar el esquema |
| `is_active` | TINYINT(1) | Soft-delete |
| `creado_por` | INT FK | Usuario que creó la plantilla |
| `doc_encabezado` | VARCHAR | Override del nombre de empresa en el documento |
| `doc_subtitulo` | VARCHAR | Override del subtítulo en el documento |
| `doc_logo_base64` | LONGTEXT | Override del logo en el documento |
| `doc_color` | VARCHAR(20) | Override del color del encabezado del documento |
| `doc_pie_pagina` | TEXT | Override del pie de página |
#### Tabla `lab_form_envios`
Cada fila es una instancia enviada a un paciente.
| Campo | Tipo | Descripción |
|---|---|---|
| `id` | INT PK | Identificador único |
| `formulario_id` | INT FK | Plantilla enviada |
| `paciente_id` | INT FK NULL | Paciente asociado |
| `domicilio_id` | INT FK NULL | Domicilio asociado (opcional) |
| `token` | CHAR(64) UNIQUE | Token público de 64 caracteres hex (acceso sin sesión) |
| `datos_prefilled` | LONGTEXT | JSON con datos pre-llenados al enviar (incluye `__paciente.*`) |
| `datos_cliente` | LONGTEXT | JSON con las respuestas completadas por el cliente |
| `firma_svg` | LONGTEXT | Firma del paciente (PNG base64 — canvas o foto) |
| `ip_cliente` | VARCHAR(45) | IP del cliente al enviar el formulario |
| `user_agent` | VARCHAR(512) | Navegador del cliente |
| `estado` | ENUM | `pendiente` / `completado` / `firmado` / `expirado` |
| `enviado_por` | INT FK | Usuario que generó el enlace |
| `enviado_via` | ENUM | `whatsapp` / `email` / `link` |
| `expira_en` | DATETIME NULL | Siempre `NULL` — el enlace no expira |
| `completado_en` | DATETIME | Fecha y hora en que el cliente completó el formulario |
| `hash_verificacion` | CHAR(64) | Sello de integridad SHA-256 del documento |
---
### Roles y permisos
| Rol | Acceso |
|---|---|
| **Admin** | Crear, editar y eliminar plantillas desde el builder. Enviar formularios a cualquier paciente. Ver todos los envíos. Ver y descargar el PDF de cualquier formulario. |
| **Enfermero** | Enviar formularios existentes a sus pacientes. Ver solo sus propios envíos (`enviado_por = su user_id`). No puede crear ni editar plantillas. Puede firmar como profesional en los formularios que él mismo envió. |
| **Cliente (público)** | Accede a `form_cliente.php?t=TOKEN` sin ninguna autenticación. Llena y firma el formulario. Puede volver al mismo enlace en cualquier momento para ver el documento firmado y descargarlo como PDF. |
---
### Flujo completo
```
1. ADMIN crea la plantilla
├─ Abre lab_formulario_builder.php (se abre en ventana nueva)
├─ Arrastra campos al canvas y los configura
├─ Configura el diseño del documento (logo, color, encabezado, pie de página)
└─ Guarda → POST api/lab/save_formulario.php → lab_formularios
2. ADMIN o ENFERMERO envía el formulario
├─ lab_formularios.php → botón "Enviar" → modal
├─ Busca y selecciona el paciente
├─ Previsualiza los campos que llegarán pre-llenados
├─ Selecciona el canal: WhatsApp o "solo link"
└─ POST api/lab/send_formulario.php
├─ Genera token de 64 hex chars: bin2hex(random_bytes(32))
├─ Crea fila en lab_form_envios (estado=pendiente, expira_en=NULL)
└─ Devuelve URL pública y mensaje preformateado para WhatsApp
3. CLIENTE recibe el enlace (por WhatsApp u otro medio)
├─ Abre form_cliente.php?t=TOKEN
├─ GET api/lab/submit_formulario.php?t=TOKEN → carga datos del formulario
└─ Si ya fue firmado antes → muestra pantalla de solo lectura con link al PDF
4. CLIENTE llena el formulario
├─ Campos "linked" llegan pre-llenados con datos del paciente (readonly si tienen valor)
├─ Campos vacíos linked son editables para que el cliente los complete
├─ Campos firma_profesional muestran aviso "uso exclusivo del profesional"
└─ Dibuja su firma (canvas) o adjunta una foto de firma
5. CLIENTE envía
├─ POST api/lab/submit_formulario.php
├─ Se genera hash SHA-256 (contenido + firma + ID + token + timestamp)
├─ Estado → "firmado" (si hay firma) o "completado" (sin firma)
└─ Pantalla de éxito con hash visible y botón para descargar el PDF
6. PROFESIONAL firma (si el formulario lo requiere)
├─ Admin/Enfermero abre ver_formulario_enviado.php?id=X con sesión activa
├─ Aparece canvas de firma en la posición del campo firma_profesional
├─ Dibuja su firma y hace clic en "Guardar firma"
└─ POST api/lab/firmar_profesional.php → guarda campo_id_svg en datos_cliente
7. ADMIN/ENFERMERO revisa el resultado
├─ lab_formularios.php → pestaña "Envíos" → icono "Ver respuesta"
└─ ver_formulario_enviado.php?id=X → documento HTML imprimible
8. VERIFICACIÓN pública de integridad
└─ verificar_formulario.php?h=HASH_SHA256
├─ Busca en lab_form_envios.hash_verificacion
└─ Muestra: nombre del formulario, paciente, fecha, estado y si el sello es válido
```
---
### Tipos de campos del Builder
#### Campos de entrada
| Tipo | Descripción |
|---|---|
| `texto` | Campo de texto corto de una sola línea |
| `textarea` | Área de texto largo (varias líneas) |
| `numero` | Campo numérico |
| `fecha` | Selector de fecha |
| `hora` | Selector de hora |
| `select` | Lista desplegable con opciones configurables |
| `radio` | Selección única con opciones configurables |
| `checkbox` | Selección múltiple con opciones configurables |
| `lista_marcable` | Lista de ítems numerados con checkboxes |
#### Campos de firma
| Tipo | Descripción |
|---|---|
| `firma` | Firma del **paciente** — visible y editable en `form_cliente.php` |
| `firma_profesional` | Firma del **profesional** — bloqueada para el cliente; solo editable desde el panel admin/enfermero |
#### Campos de contenido
| Tipo | Descripción |
|---|---|
| `separador` | Separador visual o título de sección |
| `parrafo` | Bloque de texto estático (pre-formatado o flujo libre) |
| `parrafo_inline` | Párrafo con marcadores `{nombre_completo}`, `{telefono}`, etc. que se convierten en espacios editables si el valor está vacío |
#### Campos vinculados al paciente (`tipo: linked`)
Se auto-rellenan con los datos del paciente al momento de enviar. Si el valor existe → campo de solo lectura. Si está vacío → el cliente puede completarlo.
| `linked_key` | Dato que extrae |
|---|---|
| `nombre_completo` | Nombre completo del paciente |
| `numero_documento` | Número de documento |
| `tipo_documento` | Tipo de documento |
| `fecha_nacimiento` | Fecha de nacimiento |
| `telefono` | Teléfono |
| `email` | Correo electrónico |
| `eps` | EPS o aseguradora |
| `direccion` | Dirección |
---
### Firma digital
**Modos disponibles** (configurados en la plantilla mediante `firma_modos`):
| Modo | Funcionamiento |
|---|---|
| `canvas` | El cliente dibuja su firma con el dedo o el mouse. Se captura con `canvas.toDataURL('image/png')`. |
| `foto` | El cliente sube una imagen desde su cámara o galería (`<input accept="image/*" capture="environment">`). Se convierte a base64 con `FileReader`. |
Ambos modos pueden estar activos simultáneamente en la misma plantilla.
**Firma global vs. firma por campo:**
- Si el esquema **no incluye** campos tipo `firma`, se muestra una sección de firma global al pie del formulario.
- Si el esquema **incluye** campos `firma`, cada uno tiene su propio widget canvas independiente en la posición configurada dentro del formulario.
---
### Enlace público y vigencia
- **URL pública**: `form_cliente.php?t=TOKEN`
- **TOKEN**: 64 caracteres hexadecimales generados con `bin2hex(random_bytes(32))`.
- **Sin sesión**: el cliente no necesita crear cuenta ni iniciar sesión.
- **Sin vencimiento**: la columna `expira_en` existe en la tabla pero siempre es `NULL`. El enlace es permanente.
- **Bloqueo por estado**: si el formulario ya fue completado o firmado, el enlace muestra la pantalla de solo lectura. No permite modificar la respuesta.
- **Idempotencia**: si el cliente reintenta enviar (por error de red, por ejemplo), el sistema devuelve éxito con los datos ya guardados en lugar de crear un duplicado.
---
### PDF y visualización del documento
No se usa ninguna librería de generación de PDF en el backend. El documento es la página `ver_formulario_enviado.php` con estilos `@media print`. El usuario puede imprimirla o guardarla como PDF directamente desde el navegador.
**Contenido del documento impreso:**
- Encabezado con logo, nombre, subtítulo, datos de contacto y color corporativo
- Datos del paciente (nombre, documento, fecha de nacimiento, teléfono, EPS)
- Respuestas del formulario campo por campo, en el orden del esquema
- Imagen de la firma del paciente
- Firma del profesional (si fue completada)
- Sello SHA-256 con link para verificar autenticidad
- Pie de página con fecha de generación e ID del documento
**Formas de acceder al documento:**
| URL | Quién puede acceder |
|---|---|
| `ver_formulario_enviado.php?id=X` | Admin (cualquier formulario) o Enfermero (solo los que él envió). Requiere sesión. |
| `ver_formulario_enviado.php?t=TOKEN` | Cliente u cualquier persona con el enlace. Sin sesión. Solo si el estado es `firmado` o `completado`. |
---
### Sello de integridad SHA-256
Al guardar la respuesta del cliente, el sistema genera un hash SHA-256 que vincula de forma única el contenido del formulario con la firma y el momento en que se completó.
**Construcción del hash** (en `Formulario::guardarRespuesta()`):
```
SHA-256 de:
JSON de los datos del cliente
+ firma SVG/PNG del paciente
+ ID interno del envío
+ token del enlace
+ timestamp del momento de registro
```
**¿Para qué sirve?** Cualquier persona con el hash puede ir a `verificar_formulario.php?h=HASH` para confirmar que:
- El documento existe en la base de datos.
- El nombre del formulario y del paciente.
- La fecha en que fue completado.
- El estado actual (firmado / completado).
Si el documento fue alterado, el hash no coincidirá y la verificación fallará.
---
### Firma del profesional
Algunos formularios requieren que un profesional de salud también firme el documento, además del paciente.
**Flujo:**
1. Al diseñar la plantilla en el builder se agrega un campo `tipo: firma_profesional` en la posición deseada.
2. Cuando el cliente llena el formulario en `form_cliente.php`, ese campo muestra solo un aviso: *"Uso exclusivo del profesional de salud"*. El cliente no puede interactuar con él.
3. Una vez que el cliente ha completado y enviado el formulario, el admin o enfermero abre `ver_formulario_enviado.php?id=X` con sesión activa y verá el canvas de firma en esa posición.
4. El profesional dibuja su firma y hace clic en "Guardar firma".
5. La firma se guarda mediante POST a `api/lab/firmar_profesional.php`.
**Validaciones en el servidor:**
- Requiere sesión activa (`isUserLoggedIn()`).
- Si el usuario es enfermero, solo puede firmar en formularios que él mismo envió.
- Valida que el `campo_id` corresponde a un campo `tipo: firma_profesional` en el esquema del formulario.
- Valida que la imagen enviada sea un data URI de imagen válido.
> Si el formulario se accede via `?t=TOKEN` (cliente público), el canvas **no aparece**. En su lugar se muestra un aviso *"Pendiente de firma del profesional"* (solo visible en pantalla, no en el PDF impreso).
---
### API Endpoints
| Endpoint | Método | Autenticación | Descripción |
|---|---|---|---|
| `api/lab/get_formularios.php` | GET | Sesión | Lista plantillas. `?id=X` para una sola. `?envios=1` para lista de envíos. |
| `api/lab/save_formulario.php` | POST | Admin | Crear, editar o eliminar una plantilla. |
| `api/lab/send_formulario.php` | POST | Admin / Enfermero | Crear instancia de envío. Devuelve URL pública y mensaje para WhatsApp. |
| `api/lab/submit_formulario.php` | GET | Público | Cargar el formulario por token (sin sesión). |
| `api/lab/submit_formulario.php` | POST | Público | Guardar la respuesta y firma del cliente. |
| `api/lab/firmar_profesional.php` | POST | Sesión | Guardar la firma del profesional en un campo `firma_profesional`. |
---
*Documentación generada para uso interno del equipo.*
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
+15
View File
@@ -23,6 +23,21 @@ que solo sirve para ordenar:
El título sale del primer encabezado `#` del archivo. No hay que registrar nada
en ningún índice: se descubre solo.
## Limitar un documento a ciertos roles
Por defecto un documento hereda el permiso de su sección. Para restringirlo más,
se declara al inicio del archivo:
---
roles: enfermero, supervisor
---
# Enfermeros — domicilios
Solo esos roles lo ven; para el resto no aparece en el índice ni es accesible
por URL. Los administradores ven todo, siempre. Una sección que queda sin
documentos visibles no se muestra.
## Contenido que se genera solo
Estos marcadores, en una línea propia, se reemplazan al cargar la página con
+5 -125
View File
@@ -1,129 +1,9 @@
# Módulo Administrativo — Laboratorio Clínico
# Obsoleto
Módulo add-on para el sistema de chatbot WhatsApp que permite gestionar órdenes médicas recibidas como imágenes, domicilios, enfermeras y pacientes.
Este documento quedó desactualizado y se conserva solo por historial de git.
---
La documentación vigente está **dentro del sistema**, en el módulo Soporte:
## Instalación
/erp.php?m=soporte&v=documentacion
### 1. Ejecutar migraciones de base de datos
```bash
php migrations/20260302_lab_run_migrations.php
```
Crea 7 tablas nuevas sin modificar las existentes:
- `lab_pacientes`
- `lab_enfermeras`
- `lab_ordenes_medicas`
- `lab_domicilios`
- `lab_asignaciones`
- `lab_autorizaciones`
- `lab_actividad_admin`
Para deshacer:
```bash
php migrations/20260302_lab_run_migrations.php --rollback
```
### 2. Verificar instalación
Accede desde el navegador (con sesión admin activa):
```
https://tu-servidor/lab_status.php
```
O desde CLI:
```bash
php lab_status.php
```
---
## Archivos del módulo
### Vistas PHP
| Archivo | Descripción |
|---|---|
| `lab_dashboard.php` | Panel principal con estadísticas en tiempo real |
| `lab_ordenes.php` | Gestión de órdenes médicas (estados, imágenes, historial) |
| `lab_pacientes.php` | CRUD de pacientes, vinculación con usuarios WhatsApp |
| `lab_domicilios.php` | Agenda de domicilios y asignación de enfermeras |
| `lab_enfermeras.php` | CRUD de enfermeras y visualización de agenda diaria |
| `lab_reportes.php` | Trazabilidad, log de actividad, exportación CSV |
| `lab_status.php` | Verificador de estado del módulo |
### Clases (models)
Ubicadas en `classes/lab/`:
- `ActividadAdmin.php` — Base de trazabilidad
- `Paciente.php` — Modelo de pacientes
- `Enfermera.php` — Modelo de enfermeras
- `OrdenMedica.php` — Modelo de órdenes médicas con flujo de estados
- `Domicilio.php` — Modelo de domicilios con flujo de estados
- `Asignacion.php` — Modelo de asignaciones enfermera ↔ domicilio
### API REST
Ubicados en `api/lab/`:
| Endpoint | Método | Descripción |
|---|---|---|
| `get_pacientes.php` | GET | Lista paginada de pacientes |
| `save_paciente.php` | POST | Crear/actualizar paciente |
| `get_ordenes.php` | GET | Lista/detalle de órdenes |
| `save_orden.php` | POST | Crear/actualizar orden |
| `autorizar_orden.php` | POST | Cambiar estado de una orden |
| `get_domicilios.php` | GET | Lista/detalle de domicilios |
| `save_domicilio.php` | POST | Crear/actualizar domicilio |
| `get_enfermeras.php` | GET | Lista de enfermeras + agenda |
| `save_enfermera.php` | POST | Crear/actualizar enfermera |
| `get_asignaciones.php` | GET | Asignaciones por fecha |
| `save_asignacion.php` | POST | Asignar/liberar/completar enfermera |
| `get_actividad.php` | GET | Log de actividad con filtros |
| `get_stats.php` | GET | Estadísticas para dashboard |
| `crear_desde_whatsapp.php` | GET/POST | Crear orden desde conversación activa |
---
## Flujos de estado
### Órdenes médicas
```
pendiente → en_revision → autorizada → en_domicilio → completada
↘ rechazada
```
### Domicilios
```
programado → confirmado → en_camino → en_domicilio → completado
↘ cancelado
↘ reprogramado
```
---
## Integración con el chatbot
En `conversations.php`, los mensajes de imagen entrantes tienen un botón **<i class="fas fa-flask"></i>** (verde) en las acciones del mensaje. Al hacer click:
1. Se abre un modal con la imagen adjunta
2. El operador busca o selecciona un paciente (o usa el contacto de la conversación)
3. Completa datos opcionales (médico, exámenes, ayuno)
4. Se crea la orden en estado `pendiente`
---
## Exportaciones CSV
Disponibles desde `lab_reportes.php`:
- **Órdenes médicas** del período — incluye estado, médico, exámenes
- **Domicilios** del período — incluye enfermera asignada, dirección, estado
- **Pacientes** — catálogo completo con total de órdenes
---
## Requisitos
- PHP 8.2+
- MariaDB 10.11+ (o MySQL 8+)
- Bootstrap 5.3 (ya incluido en el sistema)
- Font Awesome 6.4 (ya incluido en el sistema)
- `uploads/media/` con permisos de escritura (755/775)
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
-163
View File
@@ -1,163 +0,0 @@
# Webhook WhatsApp — Endpoints y Características
## Endpoint principal
```
URL: /api/webhook.php
```
---
## GET — Verificación de webhook
```
GET /api/webhook.php?hub.mode=subscribe&hub.verify_token=TOKEN&hub.challenge=CHALLENGE
```
### Parámetros que envía Meta
| Parámetro | Valor esperado |
|---|---|
| `hub.mode` | `subscribe` |
| `hub.verify_token` | El token configurado en `system_config.webhook_verify_token` |
| `hub.challenge` | Número aleatorio que debe devolverse tal cual |
### ⚠️ Bug conocido
El código lee `$_GET['hub_verify_token']` (con guión bajo), pero PHP convierte los puntos a guiones bajos automáticamente al parsear `$_GET`, por lo que **funciona correctamente**.
### Respuesta exitosa
```
HTTP 200
Body: {challenge}
```
### Respuesta fallida
```
HTTP 403
Body: {"error":"Token de verificación inválido"}
```
---
## POST — Recepción de eventos
```
POST /api/webhook.php
Content-Type: application/json
```
### Estructura del payload esperado (Meta Cloud API)
```json
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WABA_ID",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"phone_number_id": "PHONE_NUMBER_ID"
},
"contacts": [{
"wa_id": "573001234567",
"profile": { "name": "Nombre Contacto" }
}],
"messages": [{
"from": "573001234567",
"id": "wamid.XXX",
"timestamp": "1234567890",
"type": "text",
"text": { "body": "Hola" }
}]
}
}]
}]
}
```
### Tipos de mensaje soportados
| `type` | Descripción |
|---|---|
| `text` | Texto plano |
| `image` | Imagen (con caption opcional) |
| `audio` | Audio / nota de voz |
| `video` | Video |
| `document` | Documento / PDF |
| `sticker` | Sticker |
| `reaction` | Reacción emoji a otro mensaje |
| `interactive` | Respuesta de lista o botón |
### El campo `field` del change puede ser
- `messages` → mensajes entrantes y estados
- `conversations` → alias aceptado también
### Eventos de estado (statuses)
```json
"statuses": [{
"id": "wamid.XXX",
"status": "sent|delivered|read|failed",
"recipient_id": "573001234567"
}]
```
### Respuesta exitosa
```
HTTP 200
Body: {"status":"success"}
```
---
## Configuración necesaria en `system_config` (BD)
| config_key | Descripción |
|---|---|
| `whatsapp_token` | Access Token de Meta |
| `whatsapp_phone_number_id` | Phone Number ID de la línea |
| `webhook_verify_token` | Token de verificación del webhook |
| `whatsapp_api_url` | `https://graph.facebook.com/v22.0/` |
---
## Variables de entorno equivalentes (`.env`)
```env
WHATSAPP_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WEBHOOK_VERIFY_TOKEN=
WHATSAPP_API_URL=https://graph.facebook.com/v22.0/
DB_HOST=
DB_PORT=3306
DB_NAME=
DB_USER=
DB_PASS=
```
---
## Tablas BD que usa el webhook
| Tabla | Uso |
|---|---|
| `users` | Crea o busca usuario por `phone_number` |
| `conversations` | Guarda cada mensaje (deduplicado por `message_id`) |
| `webhook_logs` | Registra el payload crudo de cada POST |
| `notifications` | Crea aviso de nuevo mensaje entrante |
| `media_queue` | Encola media que no pudo descargarse en el momento |
| `system_config` | Lee tokens y configuración |
---
## Seguridad — pendiente de implementar
- No valida la firma `X-Hub-Signature-256` en el POST.
- Se recomienda agregar antes de procesar:
```php
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $input, APP_SECRET);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
```
+57 -1
View File
@@ -21,6 +21,53 @@ requireAuthentication();
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
$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 ─────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
$convId = (int)($_GET['conversation_id'] ?? 0);
@@ -57,7 +104,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
$pacienteRepo = new Paciente();
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
$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;
}
+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,
]);
+112 -6
View File
@@ -113,24 +113,41 @@ class WhatsAppWebhook {
return;
}
// Extraer nombres de contactos del payload (contacts[].profile.name)
// Extraer nombres de contactos del payload (contacts[].profile.name).
// Se indexa por wa_id y también por user_id (BSUID): desde que Meta desplegó
// los nombres de usuario, quien oculta su teléfono llega sin wa_id.
$contactNames = [];
if (isset($value['contacts']) && is_array($value['contacts'])) {
foreach ($value['contacts'] as $contact) {
$waId = $contact['wa_id'] ?? null;
$name = $contact['profile']['name'] ?? null;
if ($waId && $name) {
$contactNames[$waId] = $name;
if (!$name) continue;
foreach ([$contact['wa_id'] ?? null, $contact['user_id'] ?? null] as $clave) {
if ($clave) $contactNames[$clave] = $name;
}
}
}
foreach ($items as $message) {
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos.
$phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
$messageId = $message['id'] ?? ($message['message_id'] ?? null);
$timestamp = $message['timestamp'] ?? null;
// Meta manda el BSUID en todos los mensajes, traigan teléfono o no.
$bsuid = $message['from_user_id'] ?? null;
// Si la persona oculta su teléfono, `from` y `wa_id` no llegan. En ese caso
// se busca por BSUID: si ya escribió antes mostrando su número, se le
// reconoce y se le sigue respondiendo a ese teléfono.
if (empty($phoneNumber) && $bsuid) {
$conocido = $this->db->fetch(
"SELECT phone_number FROM users WHERE bsuid = :b", ['b' => $bsuid]
);
// Si no se le conoce, el BSUID hace de identificador: sirve para responderle,
// aunque no permita cruzarlo con el paciente ni con el turnero.
$phoneNumber = $conocido['phone_number'] ?? $bsuid;
}
// Si falta lo crítico, saltar
if (empty($phoneNumber) || empty($messageId)) {
continue;
@@ -148,7 +165,7 @@ class WhatsAppWebhook {
// Obtener o crear usuario
$user = $this->getUserByPhone($phoneNumber);
$contactName = $contactNames[$phoneNumber] ?? null;
$contactName = $contactNames[$phoneNumber] ?? ($bsuid ? ($contactNames[$bsuid] ?? null) : null);
if (!$user) {
$userId = $this->createUser($phoneNumber);
$user = $this->getUserById($userId);
@@ -173,6 +190,20 @@ class WhatsAppWebhook {
}
}
// Guardar la equivalencia BSUID↔usuario mientras la persona todavía muestra
// su teléfono. El día que lo oculte, ese registro es lo único que permitirá
// reconocerla, así que se anota en cada mensaje y no solo la primera vez.
if ($bsuid && !empty($user['id']) && ($user['bsuid'] ?? null) !== $bsuid) {
try {
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$user['id']]);
$user['bsuid'] = $bsuid;
} catch (Exception $e) {
// Choca si ese BSUID ya está en otro usuario (la persona cambió de
// número). No es motivo para perder el mensaje: se sigue adelante.
error_log('[webhook] No se pudo guardar el BSUID ' . $bsuid . ': ' . $e->getMessage());
}
}
// Procesar diferentes tipos de mensaje
$messageText = '';
$messageType = 'text';
@@ -191,6 +222,28 @@ class WhatsAppWebhook {
'reaction_emoji' => $emoji
];
} elseif (($message['type'] ?? '') === 'contacts') {
// La persona compartió su contacto, sea por el botón que se le pidió
// o a mano. Es la única forma de obtener el teléfono de quien lo oculta.
$messageType = 'contacts';
$messageText = json_encode($message['contacts'] ?? []);
$telefonoCompartido = null;
foreach ($message['contacts'] ?? [] as $c) {
foreach ($c['phones'] ?? [] as $t) {
// wa_id ya viene normalizado; `phone` puede traer espacios y signos
$candidato = $t['wa_id'] ?? ($t['phone'] ?? null);
if ($candidato) {
$telefonoCompartido = preg_replace('/[^0-9]/', '', $candidato);
break 2;
}
}
}
if ($telefonoCompartido && !empty($user['id'])) {
$this->vincularTelefonoCompartido($user, $telefonoCompartido, $bsuid);
}
} elseif (isset($message['interactive'])) {
// Interactive replies (list or button) - normalize to text so bot can process
$messageType = 'text';
@@ -450,6 +503,59 @@ class WhatsAppWebhook {
}
}
/**
* Vincula el teléfono que la persona acaba de compartir con el usuario que
* hasta ahora solo se conocía por su BSUID.
*
* Puede haber dos registros de la misma persona: el viejo, de cuando escribía
* mostrando el número, y el nuevo creado con el BSUID de identificador. No se
* fusionan aquí (implicaría mover conversaciones, estados y aceptación de
* términos, y una fusión mal hecha mezcla historias clínicas de dos personas):
* se deja el registro con el teléfono como el bueno y se marca el otro, para
* que alguien lo revise.
*
* @return bool si el teléfono quedó vinculado
*/
private function vincularTelefonoCompartido(&$user, $telefono, $bsuid) {
// Ya lo teníamos: nada que hacer
if (($user['phone_number'] ?? null) === $telefono) {
return true;
}
$existente = $this->db->fetch(
"SELECT id FROM users WHERE phone_number = :t AND id <> :id",
['t' => $telefono, 'id' => $user['id']]
);
try {
if ($existente) {
// El registro bueno es el que tiene el teléfono. Se le pasa el BSUID
// para que a partir de ahora se le reconozca por ahí.
if ($bsuid) {
$this->db->update('users', ['bsuid' => null], 'id = ?', [$user['id']]);
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$existente['id']]);
}
error_log(sprintf(
'[webhook] BSUID %s compartió el teléfono %s, que ya era del usuario %d. ' .
'El usuario %d queda duplicado y hay que revisarlo a mano.',
$bsuid, $telefono, $existente['id'], $user['id']
));
$user = $this->getUserById($existente['id']) ?: $user;
return true;
}
// No había otro registro: el placeholder pasa a tener el teléfono real
$this->db->update('users', ['phone_number' => $telefono], 'id = ?', [$user['id']]);
$user['phone_number'] = $telefono;
error_log(sprintf('[webhook] BSUID %s quedó vinculado al teléfono %s', $bsuid, $telefono));
return true;
} catch (Exception $e) {
error_log('[webhook] Error vinculando el teléfono compartido: ' . $e->getMessage());
return false;
}
}
private function getUserByPhone($phoneNumber) {
return $this->db->fetch(
"SELECT * FROM users WHERE phone_number = :phone",
+35 -3
View File
@@ -7,6 +7,15 @@
require_once __DIR__ . '/../../classes/Database.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 {
private Database $db;
@@ -173,8 +182,22 @@ class Paciente {
}
}
// Registrar antes y después de lo que realmente cambia. Guardar solo el
// valor nuevo impide reconstruir el dato anterior si la corrección
// resultó equivocada, que es justo cuando hace falta consultarlo.
$antes = $this->db->fetch('SELECT * FROM lab_pacientes WHERE id = ?', [$id]) ?: [];
$cambios = [];
foreach ($campos as $campo => $nuevo) {
$previo = $antes[$campo] ?? null;
if ((string)$previo !== (string)$nuevo) {
$cambios[$campo] = ['antes' => $previo, 'despues' => $nuevo];
}
}
$ok = $this->db->update('lab_pacientes', $campos, 'id = ?', [$id]);
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $campos);
if ($cambios) {
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $cambios);
}
return $ok > 0;
}
@@ -244,10 +267,19 @@ class Paciente {
[$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([
'user_id' => $userId,
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
'telefono' => $user['phone_number'] ?? null,
'nombre_completo'=> $user['name']
?? ($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
*/
+8
View File
@@ -23,6 +23,14 @@ class Router
private const PUBLIC_ROUTES = [
'turnero/display',
'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 */
+66
View File
@@ -70,6 +70,8 @@ require_once __DIR__ . '/shared/components/sidebar.php';
<option value="enfermeras">Enfermeras</option>
<option value="formularios">Formularios</option>
<option value="asignaciones">Asignaciones</option>
<option value="turnero">Turnero</option>
<option value="turnero_config">Turnero · configuración</option>
</select>
</div>
<div class="col-auto">
@@ -85,6 +87,11 @@ require_once __DIR__ . '/shared/components/sidebar.php';
<option value="enviar">enviar</option>
<option value="reprogramar">reprogramar</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>
</div>
<div class="col">
@@ -104,6 +111,18 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</div>
<!-- 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-body p-0">
<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();
}
// ── 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() {
const params = new URLSearchParams({
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('/');
}
// 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) {
paginaActual = pag;
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>` : ''}
</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.ciudad||'—')}</td>
<td class="small text-muted">${esc(p.eps||'—')}</td>
@@ -546,8 +568,18 @@ async function verDetalle(id) {
document.getElementById('detail-body').innerHTML = `
<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">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
<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>
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${
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">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>` : ''}
@@ -0,0 +1,19 @@
-- 20260811_bsuid_identidad_whatsapp.sql
--
-- Meta desplegó los nombres de usuario de WhatsApp: quien oculta su teléfono
-- llega al webhook sin `from` ni `wa_id`, identificado solo por su BSUID
-- (identificador de usuario por empresa), con la forma "CO.1761088155094242".
--
-- Ese identificador ocupa el lugar del teléfono en el flujo del bot, pero no
-- cabe en varchar(20): los BSUID de Meta llegan a 23 caracteres, así que se
-- truncarían en silencio y la persona quedaría imposible de responder.
--
-- Se amplían a varchar(32) las cuatro tablas por donde circula.
-- Las vistas (scheduled_messages_view, v_paciente_resumen) heredan el tipo.
-- lab_pacientes.telefono se deja igual: ahí va el teléfono real del paciente,
-- y de estas personas justamente no lo tenemos.
ALTER TABLE users MODIFY phone_number VARCHAR(32) NOT NULL;
ALTER TABLE user_states MODIFY phone_number VARCHAR(32) NOT NULL;
ALTER TABLE terms_acceptance MODIFY phone_number VARCHAR(32) NOT NULL;
ALTER TABLE file_requests MODIFY phone_number VARCHAR(32) NOT NULL;
@@ -0,0 +1,21 @@
-- 20260811_bsuid_mapa_identidad.sql
--
-- Segunda parte del cambio de identidad de WhatsApp (ver 20260811_bsuid_identidad_whatsapp.sql).
--
-- Meta manda el BSUID en TODOS los webhooks de mensaje, también en los que aún
-- traen teléfono. Eso permite guardar la equivalencia BSUID↔teléfono mientras
-- la persona todavía muestra su número, de modo que el día que lo oculte
-- siga siendo reconocible: ya sabemos quién es.
--
-- El histórico de webhook_logs tiene 6.423 equivalencias que se cargan con
-- scripts/backfill_bsuid.php.
--
-- Se guarda en una columna aparte y no en phone_number porque son dos cosas
-- distintas: el BSUID identifica, el teléfono además sirve para cruzar con el
-- paciente y el turnero. Mezclarlos rompería esos cruces.
ALTER TABLE users ADD COLUMN bsuid VARCHAR(32) NULL DEFAULT NULL COMMENT 'Identificador de usuario por empresa (Meta). Presente aunque la persona oculte su teléfono.' AFTER phone_number;
-- Único: un BSUID identifica a una sola persona dentro del portafolio.
-- Admite varios NULL, que es el caso de todos los usuarios ya existentes.
ALTER TABLE users ADD UNIQUE KEY uk_users_bsuid (bsuid);
@@ -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;
@@ -0,0 +1,11 @@
-- Estaciones accesibles desde equipos asignados a otra estación.
-- Pediatría y Ginecología se atienden desde cualquier puesto, pero el bloqueo
-- por IP o token devolvía al equipo a su estación sin avisar.
-- Idempotente: puede correrse más de una vez.
ALTER TABLE turnero_lugares
ADD COLUMN IF NOT EXISTS acceso_libre TINYINT(1) NOT NULL DEFAULT 0
COMMENT 'Accesible desde equipos asignados a otra estación';
UPDATE turnero_lugares SET acceso_libre = 1
WHERE nombre IN ('Pediatria', 'Ginecologia') AND acceso_libre = 0;
+21
View File
@@ -0,0 +1,21 @@
-- Rol de Calidad: consulta de turnos y tiempos de atención.
-- Solo lectura, y dentro del turnero solo dashboard e historial.
-- Incluye formularios: la coordinación del SIG administra los documentos
-- además de revisar los tiempos del proceso.
-- Idempotente: puede correrse más de una vez.
INSERT INTO roles (name, slug, description, color, home_page, is_system)
SELECT 'Calidad', 'calidad',
'Consulta de turnos y tiempos de atención. Solo lectura: dashboard e historial.',
'#7c3aed', '/erp.php?m=turnero&v=dashboard', 0
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE slug = 'calidad');
INSERT INTO role_modules (role_id, module_slug, permission, can_view, can_create, can_edit, can_delete, can_export)
SELECT r.id, m.slug, 'read', 1, 0, 0, 0, IF(m.slug = 'turnero', 1, 0)
FROM roles r
JOIN (SELECT 'turnero' AS slug UNION ALL SELECT 'soporte'
UNION ALL SELECT 'lab_formularios') m
WHERE r.slug = 'calidad'
AND NOT EXISTS (
SELECT 1 FROM role_modules rm WHERE rm.role_id = r.id AND rm.module_slug = m.slug
);
+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;
+3
View File
@@ -1,4 +1,7 @@
<?php
/**
* GET ?id=N — Detalle de un examen del catálogo con sus ítems.
*/
require_once __DIR__ . '/_helpers.php';
requireLogin();
+3
View File
@@ -1,4 +1,7 @@
<?php
/**
* GET ?exam_id=N — Tarifas de un examen por empresa o convenio.
*/
require_once __DIR__ . '/_helpers.php';
requireLogin();
+3
View File
@@ -1,4 +1,7 @@
<?php
/**
* GET ?q=&categoria=&page=&limit= — Catálogo de exámenes, paginado y filtrable.
*/
require_once __DIR__ . '/_helpers.php';
requireLogin();
+3
View File
@@ -1,4 +1,7 @@
<?php
/**
* POST — Crea o actualiza un examen del catálogo. Requiere administrador.
*/
require_once __DIR__ . '/_helpers.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
+3
View File
@@ -1,4 +1,7 @@
<?php
/**
* POST — Crea o actualiza un ítem (analito) de un examen. Requiere administrador.
*/
require_once __DIR__ . '/_helpers.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
+3
View File
@@ -1,4 +1,7 @@
<?php
/**
* POST — Crea o actualiza la tarifa de un examen. Requiere administrador.
*/
require_once __DIR__ . '/_helpers.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
+3
View File
@@ -1,4 +1,7 @@
<?php
/**
* POST { id } — Elimina un médico del catálogo.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
+3
View File
@@ -1,4 +1,7 @@
<?php
/**
* GET — Lista de médicos solicitantes.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
+3
View File
@@ -1,4 +1,7 @@
<?php
/**
* POST — Crea o actualiza un médico del catálogo.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
+158 -4
View File
@@ -7,6 +7,14 @@
* Convenciones:
* docs/<seccion>/<NN>-<slug>.md → el prefijo NN solo ordena, no se muestra
* El título sale del primer encabezado `# ` del archivo.
*
* Un documento puede restringir su visibilidad con una cabecera al inicio:
*
* ---
* roles: enfermero, recepcionista
* ---
*
* Sin cabecera, hereda el permiso de su sección. Los administradores ven todo.
*/
final class DocIndex
@@ -14,7 +22,7 @@ final class DocIndex
/** Secciones, en orden de aparición. `admin` = solo administradores. */
public const SECCIONES = [
'manual' => ['titulo' => 'Manual de usuario', 'icono' => 'fas fa-book-reader', 'admin' => false,
'resumen' => 'Cómo usar el sistema, paso a paso, según tu rol.'],
'resumen' => 'Cómo usar el sistema, paso a paso, según su rol.'],
'tecnica' => ['titulo' => 'Documentación técnica','icono' => 'fas fa-code', 'admin' => true,
'resumen' => 'Cada módulo por dentro: tablas, endpoints y dependencias.'],
'arquitectura'=> ['titulo' => 'Arquitectura', 'icono' => 'fas fa-sitemap', 'admin' => true,
@@ -23,18 +31,63 @@ final class DocIndex
'resumen' => 'Qué hacer cuando algo falla. Configuraciones críticas.'],
];
/** Roles que ven todo, sin importar lo que declare cada documento. */
public const ROLES_TOTALES = ['admin', 'superadmin'];
public static function dir(): string
{
return __DIR__ . '/docs';
}
private static function rolActual(): string
{
return $_SESSION['admin_user']['role'] ?? '';
}
private static function esAdmin(): bool
{
return in_array(self::rolActual(), self::ROLES_TOTALES, true);
}
/**
* Separa la cabecera del cuerpo de un documento.
* @return array{0: array<string,string>, 1: string} [metadatos, cuerpo]
*/
public static function leer(string $archivo): array
{
$texto = (string)@file_get_contents($archivo);
if (!preg_match('/^---\R(.*?)\R---\R?(.*)$/s', $texto, $m)) {
return [[], $texto];
}
$meta = [];
foreach (preg_split('/\R/', $m[1]) as $linea) {
if (preg_match('/^\s*([\w-]+)\s*:\s*(.*)$/', $linea, $kv)) {
$meta[strtolower($kv[1])] = trim($kv[2]);
}
}
return [$meta, ltrim($m[2])];
}
/** ¿El usuario actual puede ver este documento concreto? */
public static function puedeVerDoc(string $seccion, string $archivo): bool
{
if (self::esAdmin()) return true;
if (!self::puedeVer($seccion)) return false;
[$meta] = self::leer($archivo);
if (empty($meta['roles'])) return true; // sin cabecera: hereda la sección
$permitidos = array_filter(array_map('trim', explode(',', strtolower($meta['roles']))));
return in_array(strtolower(self::rolActual()), $permitidos, true);
}
/** ¿El usuario actual puede ver esta sección? */
public static function puedeVer(string $seccion): bool
{
$cfg = self::SECCIONES[$seccion] ?? null;
if (!$cfg) return false;
if (!$cfg['admin']) return true;
return in_array($_SESSION['admin_user']['role'] ?? '', ['admin', 'superadmin'], true);
return self::esAdmin();
}
/** Secciones visibles para el usuario actual. */
@@ -60,6 +113,7 @@ final class DocIndex
$docs = [];
foreach (glob($ruta . '/*.md') ?: [] as $archivo) {
if (!self::puedeVerDoc($sec, $archivo)) continue;
$base = basename($archivo, '.md');
$slug = preg_replace('/^\d+-/', '', $base);
$docs[] = [
@@ -71,6 +125,9 @@ final class DocIndex
}
usort($docs, fn($a, $b) => strcmp($a['orden'], $b['orden']));
// Una sección sin documentos visibles no se muestra
if (!$docs) continue;
$arbol[$sec] = $cfg + ['docs' => $docs];
}
return $arbol;
@@ -84,7 +141,8 @@ final class DocIndex
if (!preg_match('/^[a-z0-9-]+$/', $seccion) || !preg_match('/^[a-z0-9-]+$/', $slug)) return null;
foreach (glob(self::dir() . '/' . $seccion . '/*.md') ?: [] as $archivo) {
if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) === $slug) return $archivo;
if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) !== $slug) continue;
return self::puedeVerDoc($seccion, $archivo) ? $archivo : null;
}
return null;
}
@@ -103,6 +161,102 @@ final class DocIndex
return ucfirst(str_replace('-', ' ', $slugFallback));
}
/** Palabras que no aportan al puntaje de relevancia. */
private const VACIAS = [
'como','cual','cuales','donde','cuando','porque','para','pero','esta','este','esto',
'con','sin','por','que','del','las','los','una','uno','del','sus','sobre','desde',
'hacer','tengo','puedo','quiero','necesito','ayuda','favor','the','and','not','del',
];
/** Secciones que puede consultar el asistente. Ver contextoIA(). */
public const SECCIONES_IA = ['manual'];
/**
* Selecciona los documentos más relevantes para una pregunta y devuelve su
* texto, listo para dárselo a un modelo de lenguaje.
*
* Dos restricciones se aplican a la vez:
*
* 1. Por sección: el asistente solo consulta el manual de usuario. Está
* para ayudar a usar el sistema, no a mantenerlo — la documentación
* técnica, de arquitectura y de operación queda fuera incluso para
* administradores, que pueden leerla directamente en el módulo.
* 2. Por rol: dentro del manual solo entra lo que ese usuario podría leer
* por su cuenta, así el asistente no puede revelar contenido ajeno.
*
* @return array{0: string, 1: array<int,string>} [contexto, títulos usados]
*/
public static function contextoIA(string $pregunta, int $maxDocs = 4, int $maxChars = 14000): array
{
$palabras = array_values(array_filter(
preg_split('/[^a-záéíóúñü0-9]+/u', mb_strtolower($pregunta)),
fn($p) => mb_strlen($p) > 2 && !in_array($p, self::VACIAS, true)
));
if (!$palabras) return ['', []];
// Textos de los documentos consultables
$docs = [];
foreach (self::arbol() as $sec => $cfg) {
if (!in_array($sec, self::SECCIONES_IA, true)) continue;
foreach ($cfg['docs'] as $doc) {
[, $cuerpo] = self::leer($doc['archivo']);
$docs[] = [
'titulo' => $doc['titulo'],
'seccion' => $cfg['titulo'],
'cuerpo' => $cuerpo,
'heno' => mb_strtolower($doc['titulo'] . ' ' . $cuerpo),
];
}
}
if (!$docs) return ['', []];
// Peso de cada palabra según en cuántos documentos aparece: una que está
// en todos ("paciente") no distingue nada; una que está en pocos sí.
$total = count($docs);
$peso = [];
foreach ($palabras as $p) {
$enCuantos = 0;
foreach ($docs as $d) if (str_contains($d['heno'], $p)) $enCuantos++;
$peso[$p] = $enCuantos ? log(1 + $total / $enCuantos) : 0;
}
$candidatos = [];
foreach ($docs as $d) {
$puntaje = 0.0;
foreach ($palabras as $p) {
if (!$peso[$p]) continue;
// El título pesa mucho más que una mención en el cuerpo
$puntaje += substr_count(mb_strtolower($d['titulo']), $p) * 12 * $peso[$p];
$puntaje += min(substr_count($d['heno'], $p), 6) * $peso[$p];
}
if ($puntaje > 0) $candidatos[] = ['puntaje' => $puntaje] + $d;
}
if (!$candidatos) return ['', []];
usort($candidatos, fn($a, $b) => $b['puntaje'] <=> $a['puntaje']);
// Umbral absoluto: por debajo son coincidencias sueltas de palabras
// sin relación real con la pregunta, y darle eso al modelo lo lleva a
// responder con lo que tenga a mano en vez de admitir que no sabe.
if ($candidatos[0]['puntaje'] < 6) return ['', []];
// Y relativo: descartar lo que quede muy por debajo del mejor resultado
$corte = $candidatos[0]['puntaje'] * 0.45;
$candidatos = array_values(array_filter($candidatos, fn($c) => $c['puntaje'] >= $corte));
$candidatos = array_slice($candidatos, 0, $maxDocs);
$porDoc = (int)floor($maxChars / count($candidatos));
$ctx = '';
$titulos = [];
foreach ($candidatos as $c) {
$texto = preg_replace('/\{\{\w+\}\}/', '', $c['cuerpo']); // los marcadores no aportan
$texto = mb_substr(trim($texto), 0, $porDoc);
$ctx .= "\n\n===== [{$c['seccion']}] {$c['titulo']} =====\n" . $texto;
$titulos[] = $c['titulo'];
}
return [trim($ctx), $titulos];
}
/**
* Índice para el buscador: un registro por documento con su texto plano.
* Solo incluye secciones visibles para el usuario actual.
@@ -112,7 +266,7 @@ final class DocIndex
$out = [];
foreach (self::arbol() as $sec => $cfg) {
foreach ($cfg['docs'] as $doc) {
$texto = @file_get_contents($doc['archivo']) ?: '';
[, $texto] = self::leer($doc['archivo']);
// Aplanar: sin marcas, sin saltos, compacto
$texto = preg_replace('/```.*?```/s', ' ', $texto);
$texto = preg_replace('/[#>*_`|-]+/', ' ', $texto);
+101
View File
@@ -0,0 +1,101 @@
<?php
/**
* modules/soporte/api/ai_docs.php
* POST { pregunta: string, historial?: [{rol,texto},...] }
*
* LIA respondiendo únicamente sobre la documentación del sistema. El contexto
* lo arma DocIndex, que filtra por rol: solo entra lo que este usuario podría
* leer por su cuenta en el módulo Soporte. No accede a datos de pacientes ni
* de la operación del día.
*/
require_once __DIR__ . '/../../../config/config.php';
require_once __DIR__ . '/../../../services/GeminiService.php';
require_once __DIR__ . '/../DocIndex.php';
header('Content-Type: application/json; charset=utf-8');
function salir(array $datos, int $codigo = 200): void
{
http_response_code($codigo);
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
exit;
}
if (!isUserLoggedIn()) salir(['ok' => false, 'error' => 'No autenticado'], 401);
if ($_SERVER['REQUEST_METHOD'] !== 'POST') salir(['ok' => false, 'error' => 'Método no permitido'], 405);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$pregunta = trim($body['pregunta'] ?? '');
if ($pregunta === '') salir(['ok' => false, 'error' => 'Pregunta vacía'], 400);
if (mb_strlen($pregunta) > 800) salir(['ok' => false, 'error' => 'Pregunta demasiado larga'], 400);
$gemini = new GeminiService(Database::getInstance()->getConnection());
if (!$gemini->hayClave()) {
salir(['ok' => false, 'error' => 'El asistente no está configurado. Avisa a un administrador.'], 503);
}
if ($gemini->presupuestoAgotado()) {
salir(['ok' => false, 'error' => '⚠️ Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.'], 402);
}
// ── Documentación visible para este usuario, acotada a la pregunta ──
[$contexto, $titulos] = DocIndex::contextoIA($pregunta);
if ($contexto === '') {
salir([
'ok' => true,
'respuesta' => 'Eso no está en el manual de usuario. Pruebe con otras palabras, '
. 'o revise el índice de la izquierda: puede estar en otra sección '
. 'de la documentación.',
'fuentes' => [],
]);
}
// ── Historial de la conversación ───────────────────────────────
$historial = [];
foreach (array_slice(is_array($body['historial'] ?? null) ? $body['historial'] : [], -6) as $h) {
$texto = trim((string)($h['texto'] ?? ''));
if ($texto === '') continue;
$historial[] = [
'role' => (($h['rol'] ?? '') === 'model') ? 'model' : 'user',
'parts' => [['text' => mb_substr($texto, 0, 1500)]],
];
}
$prompt = <<<PROMPT
Eres LIA, la asistente de documentación del ERP del Laboratorio Clínico Ximena Caicedo.
Responde preguntas sobre CÓMO USAR el sistema, apoyándose ÚNICAMENTE en el manual de usuario
incluido abajo. Reglas:
- Si la respuesta no está en la documentación, dilo con claridad. No inventes ni supongas.
- Solo conoce el manual de usuario, no la documentación técnica ni la de operación. Si le
preguntan algo técnico (base de datos, código, configuración interna), diga que eso está en
la documentación del módulo Soporte y que lo consulte allí.
- El manual incluido es el que este usuario tiene permitido consultar. No mencione ni deduzca
la existencia de contenido que no esté aquí.
- Responde en español, tratando SIEMPRE de usted (nunca tú ni vos). Directo y práctico:
si es un procedimiento, enumera los pasos.
- Usa tablas o viñetas cuando ayuden a leer.
- No inventes rutas, nombres de botones ni consultas SQL que no aparezcan en la documentación.
DOCUMENTACIÓN DISPONIBLE:
{$contexto}
PROMPT;
try {
$r = $gemini->preguntar($prompt, $pregunta, $historial);
salir([
'ok' => true,
'respuesta' => $r['respuesta'],
'truncada' => $r['truncada'],
'fuentes' => $titulos,
'tokens_usados' => $r['tokens_usados'],
'tokens_restantes' => $r['tokens_restantes'],
'tokens_max' => $r['tokens_max'],
]);
} catch (\Throwable $e) {
salir(['ok' => false, 'error' => $e->getMessage()], 502);
}
@@ -13,7 +13,7 @@ En `admin_users` conviven dos campos que parecen redundantes y **no lo son**:
> **Hay que mantener las dos sincronizadas.** Cambiar solo `role` deja al usuario con los permisos viejos, porque el acceso real sale de `role_id`. Este error ya ocurrió: un usuario cambió de rol, la interfaz mostraba el rol nuevo y los módulos seguían siendo los anteriores.
Al cambiar el rol de alguien, actuali las dos a la vez:
Al cambiar el rol de alguien, actualice las dos a la vez:
```sql
UPDATE admin_users
@@ -78,7 +78,7 @@ users / conversations el contacto y su hilo
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql`. La convención del repositorio es que sean **idempotentes**`IF NOT EXISTS` y guardas en los `UPDATE`/`INSERT` — para poder correrlas más de una vez sin daño.
> Un cambio aplicado directo en producción sin dejar la migración correspondiente hace que un entorno nuevo no lo tenga. Si tocás el esquema, dejá el archivo.
> Un cambio aplicado directo en producción sin dejar la migración correspondiente hace que un entorno nuevo no lo tenga. Si toca el esquema, deje el archivo.
## Inventario completo
@@ -98,7 +98,7 @@ No hay traza de auditoría que permita reconstruirlos.
## Al hacer cambios
- **Cambio de esquema** → dejá la migración en `migrations/`, idempotente.
- **Endpoint nuevo** → verifi permisos ahí adentro, no confíes en la vista.
- **Tocar el turnero** → es lo que más gente usa a diario; probá con un turno real.
- **Cambio de esquema** → deje la migración en `migrations/`, idempotente.
- **Endpoint nuevo** → verifique permisos ahí adentro, no confíes en la vista.
- **Tocar el turnero** → es lo que más gente usa a diario; pruebe con un turno real.
- **Tocar formularios firmados** → tienen valor legal. Un render roto es un documento inválido.
@@ -4,23 +4,23 @@ Lo mínimo para moverse por el sistema, sin importar el rol.
## Entrar
Se ingresa con usuario y contraseña. Si no reconocés tu usuario, buscá tu **número de cédula**: la mayoría de las cuentas del personal se crearon así.
Se ingresa con usuario y contraseña. Si no reconoce su usuario, busque su **número de cédula**: la mayoría de las cuentas del personal se crearon así.
Al entrar vas directo a la pantalla principal de tu rol. No todos ven lo mismo: el menú de la izquierda muestra únicamente los módulos habilitados para vos.
Al entrar va directo a la pantalla principal de su rol. No todos ven lo mismo: el menú de la izquierda muestra únicamente los módulos habilitados para usted.
## Si no ves algo que deberías ver
## Si no ve algo que deberías ver
Casi siempre es una de estas dos:
1. **Te cambiaron los permisos hace poco.** Los permisos se cargan **al iniciar sesión**. Cerrá sesión y volvé a entrar.
2. **Tu rol no lo incluye.** Pedile a un administrador que lo revise.
1. **Le cambiaron los permisos hace poco.** Los permisos se cargan **al iniciar sesión**. Cierre sesión y vuelva a entrar.
2. **Su rol no lo incluye.** Pídale a un administrador que lo revise.
## Cómo está organizado
| Zona | Qué contiene |
|---|---|
| Menú izquierdo | Los módulos a los que tenés acceso |
| Barra superior | Tu usuario y el cierre de sesión |
| Menú izquierdo | Los módulos a los que tiene acceso |
| Barra superior | Su usuario y el cierre de sesión |
| Centro | La pantalla activa |
## Los módulos principales
@@ -34,19 +34,46 @@ Casi siempre es una de estas dos:
| **Formularios** | Consentimientos y fichas; diseño y envíos |
| **Soporte** | Esta documentación |
## El recorrido de un paciente
Desde que saca su turno hasta que se va:
```
KIOSKO RECEPCIÓN TOMA DE MUESTRAS
│ │ │
▼ ▼ ▼
┌──────┐ llama ┌──────┐ deriva ┌──────────────┐ ┌────────────┐
│espera├──────────►│recep.├───────────►│ espera lugar ├──►│ en servicio│
└──┬───┘ └──┬───┘ └──────────────┘ └──────┬─────┘
│ │ │
│ no responde │ ▼
└──────────────────┴──────────► ausente ┌────────────────┐
│ finalizado │
cancelado └────────────────┘
cuenta para facturar
```
Un turno **nunca se borra**. Si el paciente no aparece se marca *ausente*; si no se hace, *cancelado*. Los dos quedan registrados, y ninguno cuenta como facturación.
## Cosas que conviene saber desde el principio
**Los turnos no se borran.** Se cancelan o se marcan como ausente, pero quedan registrados. Es a propósito: el historial tiene valor clínico y administrativo.
**Las firmas quedan con nombre y cédula.** Cuando firmás un formulario, el sistema registra quién sos. No es opcional ni configurable.
**Las firmas quedan con nombre y cédula.** Cuando firma un formulario, el sistema registra quién es. No es opcional ni configurable.
**Una muestra pendiente no se pierde.** Si un paciente queda debiendo una muestra y vuelve otro día, aparece sola en la estación, marcada como *visita anterior*, con los exámenes de aquella orden.
**Nadie factura lo que no terminó.** En los reportes del día, lo cobrado en turnos finalizados y lo que sigue en curso se muestran por separado. Los ausentes y cancelados no se cuentan.
## Manual según tu rol
## Manual según su rol
- [Kiosko y pantallas](?m=soporte&v=documentacion&s=manual&d=kiosko-y-pantallas)
- [Recepción](?m=soporte&v=documentacion&s=manual&d=recepcion)
- [Chat de WhatsApp](?m=soporte&v=documentacion&s=manual&d=chat-whatsapp)
- [Órdenes médicas](?m=soporte&v=documentacion&s=manual&d=ordenes-medicas)
- [Domicilios](?m=soporte&v=documentacion&s=manual&d=domicilios)
- [Formularios](?m=soporte&v=documentacion&s=manual&d=formularios)
- [Reportes y métricas](?m=soporte&v=documentacion&s=manual&d=reportes)
- [Toma de muestras](?m=soporte&v=documentacion&s=manual&d=toma-de-muestras)
- [Enfermeros — domicilios](?m=soporte&v=documentacion&s=manual&d=enfermeros)
- [Administración](?m=soporte&v=documentacion&s=manual&d=administracion)
@@ -0,0 +1,70 @@
# Kiosko y pantallas de TV
Las dos pantallas que funcionan solas, sin nadie operándolas. Conviene entenderlas porque cuando fallan, el que se entera primero es quien está en recepción.
## El kiosko
El tótem de la entrada, donde el paciente saca su turno sin ayuda.
```
PACIENTE LLEGA
┌──────────────┐
│ Elige tipo │ general, preferencial, embarazada,
│ de turno │ solo entrega de muestras…
└──────┬───────┘
┌──────────────┐
│Datos básicos │ nombre y celular (opcional)
└──────┬───────┘
┌──────────────┐
│ Imprime │──► el turno aparece en la pantalla de TV
│ su turno │ y en la cola de recepción
└──────────────┘
```
Es una de las **dos únicas pantallas públicas** del sistema: no pide usuario ni contraseña, porque nadie va a iniciar sesión en el tótem de la entrada.
### Prioridades
El tipo de turno que elige el paciente define su lugar en la cola. Las prioridades las configura un administrador; quien atiende no necesita hacer nada: **Llamar siguiente** ya respeta el orden.
### Si el paciente deja su celular
Puede recibir por WhatsApp el aviso de su turno y los consentimientos para firmar desde el teléfono mientras espera. Vale la pena insistirle en que lo deje.
### Cuando el kiosko falla
Un turno siempre se puede crear a mano desde recepción. El kiosko es una comodidad, no un requisito — si está caído, la atención sigue.
## Pantalla de TV
La de la sala de espera. Muestra el turno que se está llamando y la cola, y reproduce contenido del laboratorio de fondo.
También es pública: se abre en el navegador del televisor y se deja andando.
### El contenido de fondo
Es una lista de videos e imágenes que se reproducen en bucle, uno detrás de otro:
```
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ video 1 │──►│ imagen │──►│ video 2 │──►│ imagen │──┐
│ hasta │ │ 8 seg │ │ hasta │ │ 10 seg │ │
│ el final│ │ │ │ el final│ │ │ │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ │
▲ │
└──────────────────── vuelve a empezar ────────────┘
```
Los videos van completos; a las imágenes se les fija cuántos segundos duran. Se administra desde **Configuración del turnero → Pantalla TV**: se suben, se reordenan arrastrando y se eliminan de a uno.
### Si la pantalla se queda pegada
Recargue la página en el televisor. Si el contenido no cambió, verifique que la lista tenga elementos activos en la configuración.
## Verificar paciente
Una consulta rápida por cédula para ver la ficha y el historial de alguien, sin abrir su turno. Útil cuando el paciente pregunta algo en el mostrador y no quiere perder lo que está haciendo.
+35 -15
View File
@@ -1,18 +1,22 @@
---
roles: recepcionista, lab_recepcion, supervisor
---
# Recepción
Guía de la pantalla de recepción del turnero: desde que llamás al paciente hasta que pasa a toma de muestras.
Guía de la pantalla de recepción del turnero: desde que llama al paciente hasta que pasa a toma de muestras.
## Tu escritorio
## Su escritorio
Si el equipo está registrado por IP o token, el menú te muestra **solo tu escritorio**. Si no lo está, ves todos y elegís.
Si el equipo está registrado por IP o token, el menú le muestra **solo su escritorio**. Si no lo está, ve todos y elige.
Esto lo configura un administrador en `Configuración del turnero`. Si estás viendo escritorios que no son el tuyo, avisá: significa que ese equipo no quedó registrado.
Esto lo configura un administrador en `Configuración del turnero`. Si está viendo escritorios que no son el tuyo, avise: significa que ese equipo no quedó registrado.
## El flujo completo
### 1. Llamar al paciente
**Llamar siguiente** toma el turno con mayor prioridad de la cola. También podés llamar a uno específico si hace falta saltarse el orden.
**Llamar siguiente** toma el turno con mayor prioridad de la cola. También puede llamar a uno específico si hace falta saltarse el orden.
El turno aparece en la pantalla de TV de la sala de espera.
@@ -20,54 +24,70 @@ Si el paciente no responde, **Marcar ausente**. Queda registrado como ausente y
### 2. Verificar o crear el paciente
Bus por cédula o nombre.
Busque por cédula o nombre.
- **Existe** → se cargan sus datos y su historial.
- **No existe** → creá la ficha. Cédula, nombre completo, fecha de nacimiento, teléfono y EPS.
- **No existe** → cree la ficha. Cédula, nombre completo, fecha de nacimiento, teléfono y EPS.
> El teléfono importa más de lo que parece: es a donde se envían los consentimientos y las encuestas. Un número mal escrito significa un consentimiento que nunca llega.
### 3. Seleccionar exámenes
Cargá los exámenes solicitados. Si viene con una orden en RIPS, se pueden importar directamente en vez de cargarlos a mano.
Cargue los exámenes solicitados. Si viene con una orden en RIPS, se pueden importar directamente en vez de cargarlos a mano.
Al elegir los exámenes, el sistema decide solo qué consentimientos hacen falta: algunos van atados a un examen concreto (VIH, por ejemplo) y otros a la estación de destino.
### 4. Datos de facturación
Valor cobrado, método de pago, número de recibo. Si es por empresa o EPS, cargá el NIT y la autorización.
Valor cobrado, método de pago, número de recibo. Si es por empresa o EPS, cargue el NIT y la autorización.
Se admite pago combinado — efectivo más tarjeta, por ejemplo.
### 5. Consentimientos
Quién firma qué, y dónde:
```
RECEPCIÓN TOMA DE MUESTRAS
┌─────────────────────┐ ┌──────────────────────┐
│ Consentimiento │ │ Datos Toma de │
│ pruebas de lab │ │ Muestras (F-LAB-08) │
│ │ │ │
│ firma: EL PACIENTE │ │ firma: EL PERSONAL │
│ vía WhatsApp │ │ en la estación │
└─────────────────────┘ └──────────────────────┘
obligatorio obligatorio SIEMPRE
salvo "solo entrega" (incluso solo entrega)
```
Los que hagan falta aparecen listados con su estado. Se envían al WhatsApp del paciente, que los firma desde el celular.
**No podés guardar la solicitud si quedan consentimientos sin firmar**, salvo que sea una visita de *solo entrega de muestras*.
**No puede guardar la solicitud si quedan consentimientos sin firmar**, salvo que sea una visita de *solo entrega de muestras*.
Si el envío por WhatsApp falla, el sistema manda un enlace en texto plano como respaldo.
### 6. Elegir destino y guardar
Seleccioná la estación de toma de muestras y guardá. El turno pasa a esa estación y el paciente sale de tu escritorio.
Seleccione la estación de toma de muestras y guarde. El turno pasa a esa estación y el paciente sale de su escritorio.
## Situaciones frecuentes
### El paciente solo viene a entregar una muestra
Mar **Solo entrega de muestras**. Se saltan los consentimientos por examen y no hace falta seleccionar exámenes.
Marque **Solo entrega de muestras**. Se saltan los consentimientos por examen y no hace falta seleccionar exámenes.
> Ojo: el formulario **Datos Toma de Muestras (F-LAB-08)** se sigue exigiendo en la estación. Ese no se omite nunca, porque recoge la historia clínica del momento de la toma.
### El paciente ya vino antes y quedó debiendo una muestra
No tenés que hacer nada especial. En la estación de toma de muestras le va a aparecer sola, marcada como *visita anterior*, junto con los exámenes de aquella orden.
No tiene que hacer nada especial. En la estación de toma de muestras le va a aparecer sola, marcada como *visita anterior*, junto con los exámenes de aquella orden.
### El paciente no recibió el consentimiento
1. Verifi el número de teléfono en su ficha.
1. Verifique el número de teléfono en su ficha.
2. Reenvialo desde la lista de consentimientos.
3. Si sigue sin llegar, avisá a un administrador: puede ser un problema de la plantilla en Meta, que no se arregla desde acá.
3. Si sigue sin llegar, avise a un administrador: puede ser un problema de la plantilla en Meta, que no se arregla desde acá.
### Hay que corregir algo después de guardar
@@ -0,0 +1,78 @@
---
roles: recepcionista, lab_recepcion, operador_bot, supervisor
---
# Chat de WhatsApp
La línea de WhatsApp del laboratorio la atiende un bot, pero cuando hace falta una persona, la conversación pasa a un operador. Esta es esa pantalla.
## Cómo se reparte el trabajo
```
MENSAJE DEL PACIENTE
┌─────────────┐ no ┌──────────────────────┐
│ ¿Aceptó los ├────────►│ Le pide aceptar y │
│ términos? │ │ no avanza hasta que │
└──────┬──────┘ │ responda ACEPTO │
│ sí └──────────────────────┘
┌─────────────┐ sí ┌──────────────────────┐
│ ¿La tomó un ├────────►│ El bot NO interviene │
│ operador? │ │ Responde usted │
└──────┬──────┘ └──────────────────────┘
│ no
┌─────────────┐ fuera ┌──────────────────────┐
│ ¿Está en ├────────►│ Responde con el │
│ horario? │ │ mensaje de fuera de │
└──────┬──────┘ │ horario │
│ dentro └──────────────────────┘
El bot responde
```
Lo importante: **cuando toma una conversación, el bot deja de responder ahí**. No hay riesgo de que le conteste encima al paciente mientras usted está escribiendo.
## Lo que el bot resuelve solo
Antes de llegar a una persona, el bot responde por su cuenta las preguntas más
frecuentes —precios, horarios, ubicación, indicaciones previas— y ofrece un menú
numerado para agendar, consultar resultados o ver el portafolio.
Ese contenido es configurable: si una respuesta quedó desactualizada o hace falta
una nueva, un administrador la cambia sin necesidad de programar nada.
## Atender una conversación
La lista muestra los hilos con mensajes recientes. Al abrir uno ve el historial completo y puede responder.
Si el paciente ya está registrado, se ve su ficha; si no, se puede crear desde ahí mismo.
## La ventana de 24 horas
Es una regla de WhatsApp, no del sistema:
> Puede escribir libremente durante **24 horas** desde el último mensaje del paciente. Pasado ese plazo, solo se le puede escribir con una **plantilla aprobada**.
Por eso a veces el sistema no le deja mandar un texto libre y ofrece plantillas. No es una falla: es la restricción de WhatsApp.
Las plantillas las crea y aprueba Meta. Si necesita una nueva para un caso que se repite, pídala a un administrador — el trámite lleva días.
## Términos y condiciones
Todo contacto nuevo debe aceptar los términos antes de que el bot converse. Responde **ACEPTO** o **NO ACEPTO**.
Se le vuelven a pedir cuando pasan 6 meses o cuando se publica una versión nueva. Si un paciente dice que no puede abrir el documento de términos, avise: puede ser que el enlace esté caído, y eso lo resuelve un administrador.
## Problemas frecuentes
**El paciente dice que escribió y nadie le respondió.**
Revise si la conversación quedó tomada por un operador que no siguió. En ese estado el bot no responde y queda esperando a una persona.
**No me deja enviar un mensaje.**
Pasaron más de 24 horas desde el último mensaje del paciente. Use una plantilla.
**El paciente no recibe los consentimientos.**
Verifique el número en su ficha. Si está bien y aun así no llegan, es problema de plantilla — avise a un administrador.
@@ -1,14 +1,18 @@
---
roles: bacteriologo, supervisor
---
# Toma de muestras
Guía de la estación de toma de muestras: atender al paciente, recibir sus muestras y firmar los formularios.
## Tu estación
## Su estación
Igual que en recepción, si el equipo está registrado por IP o token, ves **solo tu estación**. Si no, las ves todas.
Igual que en recepción, si el equipo está registrado por IP o token, ve **solo su estación**. Si no, las ve todas.
## Atender un turno
Los pacientes derivados desde recepción aparecen en tu bandeja. Al abrir uno ves su ficha completa: datos, exámenes solicitados, muestras a recibir y formularios pendientes.
Los pacientes derivados desde recepción aparecen en su bandeja. Al abrir uno ve su ficha completa: datos, exámenes solicitados, muestras a recibir y formularios pendientes.
## Recibir muestras
@@ -24,9 +28,22 @@ Al rechazar hay que indicar el motivo. Ese motivo queda registrado y se ve despu
### Muestras de visitas anteriores
Si el paciente quedó debiendo una muestra otro día, te aparece con una etiqueta ámbar **visita anterior**, e incluye los exámenes de aquella orden para que sepas de qué se trataba.
```
VISITA 1 · lunes VISITA 2 · jueves
┌────────────────────┐ ┌────────────────────┐
│ Turno A-042 │ │ Turno B-017 │
│ │ │ │
│ Sangre recibida │ │ Orina recibida │
│ Orina PENDIENTE ├───────────────►│ └ visita anterior│
└────────────────────┘ reaparece └────────────────────┘
finalizado sola │
NO se modifica ◄─────────────────────────────
quedan enlazados
```
Se reciben con un clic, igual que cualquier otra. Al hacerlo, los dos turnos quedan enlazados: desde el historial podés saltar de uno al otro.
Si el paciente quedó debiendo una muestra otro día, le aparece con una etiqueta ámbar **visita anterior**, e incluye los exámenes de aquella orden para que sepa de qué se trataba.
Se reciben con un clic, igual que cualquier otra. Al hacerlo, los dos turnos quedan enlazados: desde el historial puede saltar de uno al otro.
> **El turno original no se modifica.** Sigue finalizado como estaba. Solo se registra en qué visita se completó la muestra.
@@ -36,20 +53,36 @@ Se reciben con un clic, igual que cualquier otra. Al hacerlo, los dos turnos que
Obligatorio en todas las estaciones. Recoge la historia clínica del momento: síntomas, antecedentes familiares y personales, medicación, datos obstétricos si corresponde.
Lo firmás vos, no el paciente.
Lo firma usted, no el paciente.
**Si el paciente ya lo llenó en una visita anterior**, aparece el botón *Cargar datos de la visita anterior*. Trae las respuestas de la última vez para que solo revises y ajustes lo que cambió. **No trae la firma**: esa la ponés vos, con la fecha de hoy.
**Si el paciente ya lo llenó en una visita anterior**, aparece el botón *Cargar datos de la visita anterior*. Trae las respuestas de la última vez para que solo revises y ajustes lo que cambió. **No trae la firma**: esa la pone usted, con la fecha de hoy.
### Control de Tomas de Muestras Prolongadas (F-LAB-28)
Para exámenes que requieren varias tomas en el tiempo: curvas de glicemia, prolactina, cortisol, test de Sullivan.
```
Glicemia pre y post carga
min 0 min 30 min 60 min 120
│ │ │ │
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ ✔ │ ────► │ ✔ │ ────► │ │ ... │ │
└─────┘ 30min └─────┘ 30min └─────┘ └─────┘
07:28 08:06 pendiente bloqueada
M. Monterrosa Y. Parada ▲
└ cuenta regresiva
Cada toma guarda SU hora y QUIÉN la firmó. Si cambia
el turno del personal, cada firma conserva su nombre.
```
Cómo funciona:
1. **Mar el examen.** El formulario muestra solo las tomas de ese examen; si el paciente tiene dos exámenes seriados, muestra las de ambos.
2. **Configurá los tiempos** si te lo pide (minuto 0, 30, 60…).
3. **Firmá cada toma** a medida que la hacés. El sistema registra la hora y **quién firmó**.
4. Cuando firmás una, el sistema calcula cuándo toca la siguiente y muestra una cuenta regresiva.
1. **Marque el examen.** El formulario muestra solo las tomas de ese examen; si el paciente tiene dos exámenes seriados, muestra las de ambos.
2. **Configure los tiempos** si se lo pide (minuto 0, 30, 60…).
3. **Firme cada toma** a medida que la hace. El sistema registra la hora y **quién firmó**.
4. Cuando firma una, el sistema calcula cuándo toca la siguiente y muestra una cuenta regresiva.
> Cada toma se firma por separado y queda con el nombre de quien la hizo. Si cambia el turno del personal a mitad del protocolo, cada toma conserva el nombre correcto.
@@ -57,11 +90,11 @@ Si hay que cerrar el protocolo antes de terminar todas las tomas, se puede hacer
## Antes de finalizar
El sistema no te deja finalizar si quedan muestras sin decidir. Cada una tiene que estar recibida, pendiente o rechazada.
El sistema no le deja finalizar si quedan muestras sin decidir. Cada una tiene que estar recibida, pendiente o rechazada.
## Comentarios
Podés dejar notas en el turno. Quedan visibles para el resto del personal y en el historial.
Puede dejar notas en el turno. Quedan visibles para el resto del personal y en el historial.
## Preguntas frecuentes
@@ -69,7 +102,7 @@ Podés dejar notas en el turno. Quedan visibles para el resto del personal y en
Sí. Con el botón de deshacer vuelve a pendiente.
**El formulario me muestra secciones de exámenes que el paciente no tiene.**
Avisá a soporte. Debería mostrar únicamente las del examen marcado.
Avise a soporte. Debería mostrar únicamente las del examen marcado.
**¿Qué pasa si el paciente se va sin dar una muestra?**
Dejala en **pendiente**. Cuando vuelva —el día que sea— le va a aparecer sola a quien lo atienda.
Déjela en **pendiente**. Cuando vuelva —el día que sea— le va a aparecer sola a quien lo atienda.
@@ -0,0 +1,63 @@
---
roles: lab_readonly, recepcionista, lab_recepcion, supervisor
---
# Órdenes médicas
Las órdenes que el paciente envía por WhatsApp, casi siempre como foto de la orden en papel. Acá se revisan y se autorizan.
## El recorrido
```
PACIENTE USTED RESULTADO
│ │ │
▼ ▼ ▼
┌─────────┐ ┌───────────────┐ ┌────────────────┐
│ envía │───────────►│ PENDIENTE │───────────►│ AUTORIZADA │
│ la foto │ │ la revisa │ │ puede atenderse│
└─────────┘ └───────┬───────┘ └────────────────┘
└──────────────────► ┌────────────────┐
│ RECHAZADA │
│ con comentario│
└────────────────┘
```
Toda orden llega como **pendiente**. Queda registrado quién la revisó, quién la autorizó y cuándo.
## Revisar una orden
Al abrirla ve la imagen que envió el paciente y los campos para transcribir lo que dice:
| Dato | Por qué importa |
|---|---|
| Médico y registro | Identifica quién la solicitó |
| Fecha de la orden | Una orden vieja puede estar vencida |
| Diagnóstico | Va en los reportes y en la facturación |
| Exámenes solicitados | Define qué se le va a hacer |
| **Requiere ayuno** y horas | Determina qué se le indica al paciente antes de venir |
| Indicaciones | Cualquier preparación adicional |
> El ayuno es el dato que más consecuencias tiene si se transcribe mal: un paciente que viene sin ayunar cuando debía obliga a repetir la visita.
## Autorizar o rechazar
- **Autorizar** — la orden queda lista y el paciente puede ser atendido.
- **Rechazar** — hay que dejar un comentario explicando por qué. Ese comentario es lo que permite entender después qué pasó.
Motivos frecuentes de rechazo: foto ilegible, orden vencida, faltan datos del médico, o los exámenes no corresponden a los que presta el laboratorio.
## Si solo tiene permiso de lectura
Algunos roles ven las órdenes sin poder modificarlas. En ese caso no aparecen los botones de autorizar ni rechazar: puede consultarlas e informarse, pero la decisión la toma otra persona.
## Preguntas frecuentes
**La foto llegó ilegible.**
Rechace la orden indicándolo en el comentario, para que se le pueda pedir al paciente que la envíe de nuevo.
**El paciente dice que envió la orden y no aparece.**
Verifique en el chat de WhatsApp que el mensaje haya llegado. Si llegó pero no se registró como orden, avise a un administrador.
**Me equivoqué al transcribir un dato.**
Mientras la orden no esté autorizada se puede corregir. Si ya lo está, pídale a un administrador que la reabra.
@@ -0,0 +1,64 @@
---
roles: recepcionista, lab_recepcion, lab_readonly, supervisor
---
# Domicilios
La pantalla administrativa de las visitas domiciliarias: agendarlas, asignarles enfermero y seguirlas.
> Es distinta del **portal del enfermero**. Aquí se ve y administra todo; el portal muestra a cada enfermero solo lo suyo y está pensado para el celular.
## Agendar una visita
1. **Paciente** — búsquelo por cédula. Si no existe, se crea en el momento.
2. **Dirección** — la de su ficha con un botón, o escriba otra. Agregue indicaciones si el lugar es difícil de ubicar.
3. **Fecha y hora**.
4. **Servicio** — qué se va a hacer.
5. **Seguro y autorización**, si aplica.
6. **Valores** — domicilio y copago.
## Asignar un enfermero
Una visita agendada queda **sin asignar** hasta que se le pone un enfermero. Las sin asignar del día son las que hay que resolver primero: nadie las va a atender solo porque estén agendadas.
Al asignarla, la visita aparece en el portal de ese enfermero, que puede editarla.
## Estados
```
┌────────────┐ ┌───────────┐ ┌──────────────┐
│ programado ├──►│ en curso ├──►│ completado │
└─────┬──────┘ └───────────┘ └──────────────┘
└────────────────────────► ┌──────────────┐
│ cancelado │
└──────────────┘
```
## Notas y archivos
Cada visita admite notas de seguimiento, con fotos y archivos adjuntos. Sirve para dejar la orden médica en papel, un resultado o una observación de lo ocurrido.
Las notas quedan visibles para quien atienda después. Es el lugar correcto para dejar constancia de algo que el próximo necesita saber.
## Formularios
Se le puede enviar un formulario al paciente para que lo firme desde su celular, o copiar el enlace para hacérselo llegar por otro medio. Los ya firmados se consultan desde la misma visita.
## Si solo tiene permiso de lectura
Algunos roles ven los domicilios sin poder modificarlos: no aparecen los botones de crear ni editar. Puede consultar la agenda, el estado de cada visita y sus notas.
## Preguntas frecuentes
**Agendé una visita y el enfermero dice que no la ve.**
Verifique que le haya asignado el enfermero. Sin asignación, la visita no aparece en ningún portal.
**Hay que reprogramar.**
Edite la fecha y la hora, y avísele al paciente. Desde el portal del enfermero hay un enlace que abre WhatsApp con el mensaje ya redactado.
**El paciente cambió de dirección.**
Edite la visita. Si el cambio es permanente, actualice también la ficha del paciente, o la próxima vez volverá a aparecer la anterior.
**¿Puedo ver los domicilios de todos los enfermeros?**
Desde esta pantalla sí. El portal, en cambio, muestra a cada enfermero solo los suyos.
+18 -14
View File
@@ -1,10 +1,14 @@
---
roles: enfermero, supervisor
---
# Enfermeros — domicilios
Guía del portal del enfermero: tus visitas domiciliarias, cómo agendarlas y qué hacer en cada una.
Guía del portal del enfermero: sus visitas domiciliarias, cómo agendarlas y qué hacer en cada una.
## Tu portal
## Su portal
Al entrar vas directo al portal. Ves **tus** domicilios: los que te asignaron y los que agendaste vos.
Al entrar va directo al portal. Ve **sus** domicilios: los que le asignaron y los que agendó usted.
Está pensado para usarse desde el celular en la calle.
@@ -12,8 +16,8 @@ Está pensado para usarse desde el celular en la calle.
**Nuevo domicilio** abre el formulario:
1. **Paciente** — buscalo por cédula. Si no existe, se crea ahí mismo.
2. **Dirección** — la del paciente con un botón, o escribí otra. Agregá indicaciones si el lugar es difícil de encontrar («apto 302, tocar campanilla»).
1. **Paciente** — búsquelo por cédula. Si no existe, se crea ahí mismo.
2. **Dirección** — la del paciente con un botón, o escriba otra. Agregue indicaciones si el lugar es difícil de encontrar («apto 302, tocar campanilla»).
3. **Fecha y hora**.
4. **Servicio** — qué se va a hacer.
5. **Seguro y autorización** si aplica.
@@ -21,15 +25,15 @@ Está pensado para usarse desde el celular en la calle.
## Editar
Podés editar los domicilios **que agendaste vos** y también **los que te asignaron**. El botón *Editar* aparece mientras el domicilio no esté completado ni cancelado.
Puede editar los domicilios **que agendó usted** y también **los que le asignaron**. El botón *Editar* aparece mientras el domicilio no esté completado ni cancelado.
## Avisar al paciente por WhatsApp
Hay un enlace que abre WhatsApp con el mensaje ya escrito, presentándote como profesional del Laboratorio Ximena Caicedo. Solo revisás y enviás.
Hay un enlace que abre WhatsApp con el mensaje ya escrito, presentándote como profesional del Laboratorio Ximena Caicedo. Solo revisa y envía.
## Notas y archivos
Podés dejar dos tipos de nota en cada domicilio:
Puede dejar dos tipos de nota en cada domicilio:
- **Nota de ficha** — estructurada, para datos clínicos.
- **Nota libre** — texto suelto.
@@ -38,29 +42,29 @@ Ambas admiten fotos y archivos adjuntos, útil para órdenes médicas en papel o
## Formularios
Podés enviarle un formulario al paciente para que lo firme desde su celular, o copiar el enlace para pasárselo por otro medio. Los que ya firmó se pueden consultar desde el mismo domicilio.
Puede enviarle un formulario al paciente para que lo firme desde su celular, o copiar el enlace para pasárselo por otro medio. Los que ya firmó se pueden consultar desde el mismo domicilio.
Cuando firmás vos un formulario, queda registrado con tu **nombre y cédula**, tomados de tu ficha de enfermero.
Cuando firma usted un formulario, queda registrado con su **nombre y cédula**, tomados de su ficha de enfermero.
## Estados de un domicilio
| Estado | Significado |
|---|---|
| Programado | Agendado, sin atender |
| En curso | Estás en la visita |
| En curso | Está en la visita |
| Completado | Terminado |
| Cancelado | No se hizo |
## Preguntas frecuentes
**No puedo editar un domicilio.**
Solo se pueden editar los propios o los asignados a vos, y solo si no está completado ni cancelado.
Solo se pueden editar los propios o los asignados a usted, y solo si no está completado ni cancelado.
**El paciente cambió de dirección.**
Editá el domicilio. Si el cambio es permanente, actuali también la ficha del paciente.
Edite el domicilio. Si el cambio es permanente, actualice también la ficha del paciente.
**Necesito reprogramar.**
Editá la fecha y hora, y avisale al paciente por WhatsApp.
Edite la fecha y hora, y avísele al paciente por WhatsApp.
**¿Puedo ver domicilios de otro enfermero?**
No. El portal muestra únicamente los tuyos.
@@ -0,0 +1,71 @@
---
roles: formularios_readonly, enfermero, supervisor
---
# Formularios
Los consentimientos y fichas clínicas que firman pacientes y personal. Acá se diseñan, se consultan y se revisan los que ya se firmaron.
## Cómo está armado un formulario
Un formulario es una lista de campos en orden. El diseñador permite armarlo sin escribir nada técnico: se agregan campos, se les pone nombre y se ordenan.
| Tipo de campo | Para qué sirve |
|---|---|
| **Separador** | Divide el formulario en secciones |
| **Párrafo** | Texto fijo — es lo que se usa para el articulado legal de un consentimiento |
| **Texto**, **Área de texto**, **Número** | Respuestas escritas |
| **Fecha**, **Hora** | Fechas y horas |
| **Opción única**, **Casillas**, **Lista** | Respuestas para elegir |
| **Vinculado** | Se llena solo con un dato del paciente (nombre, documento, EPS) |
| **Firma del paciente** | La dibuja el paciente |
| **Firma del profesional** | La dibuja quien atiende |
> Los campos **vinculados** evitan que alguien escriba mal un nombre o un documento: los toma de la ficha del paciente.
## Secciones que aparecen solo a veces
Una sección puede depender de una respuesta anterior. Por ejemplo, los datos obstétricos solo aparecen si el sexo registrado es femenino; las tomas de insulina, solo si se marcó ese examen.
Se configura eligiendo de qué campo depende la sección y con qué respuestas debe mostrarse. Si la condición no se cumple, ni la sección ni sus campos aparecen.
## Quién firma qué
```
┌───────────────────────┐ ┌───────────────────────┐
│ FIRMA DEL PACIENTE │ │ FIRMA DEL PROFESIONAL │
│ │ │ │
│ la dibuja el paciente │ │ la dibuja quien atiende│
│ desde su celular │ │ en el equipo del lab │
│ │ │ │
│ llega por WhatsApp │ │ queda con nombre │
│ │ │ y cédula │
└───────────────────────┘ └───────────────────────┘
```
La firma del profesional **siempre** queda identificada con nombre y cédula. No es opcional.
## Formularios con varias firmas
Algunos formularios se firman por partes a lo largo de horas — el de tomas prolongadas es el caso típico. Cada firma se guarda por separado, con su hora y el nombre de quien la hizo, de modo que si cambia el turno del personal cada toma conserva el nombre correcto.
## Consultar formularios firmados
Se puede ver el documento tal como quedó, con las respuestas y las firmas. Los formularios firmados **no se modifican**: son un registro con valor legal.
Si un formulario tiene un error, se emite uno nuevo; no se corrige el anterior.
## Si solo tiene permiso de lectura
Puede consultar los formularios y los documentos firmados, pero no crear ni modificar plantillas. Es el permiso habitual para quien audita o revisa documentación.
## Preguntas frecuentes
**Cambié un formulario. ¿Se modifican los ya firmados?**
No. Los firmados conservan la versión con la que se firmaron. El cambio aplica de ahí en adelante.
**Un documento firmado muestra secciones que no correspondían.**
Avise a soporte con el número de turno. Debería mostrar únicamente las secciones del examen del paciente.
**No aparece el nombre de quien firmó.**
Los documentos anteriores a agosto de 2026 no guardaban ese dato y no se puede recuperar. Los posteriores sí lo registran.
@@ -1,3 +1,7 @@
---
roles: supervisor
---
# Administración
Tareas de administrador: usuarios, permisos, configuración y reportes.
@@ -8,7 +12,7 @@ Tareas de administrador: usuarios, permisos, configuración y reportes.
La convención de la casa es usar el **número de cédula como nombre de usuario** para el personal asistencial.
Cargá también la **cédula** en su ficha: es lo que aparece bajo la firma en los formularios. Si falta, el documento sale firmado sin identificación.
Cargue también la **cédula** en su ficha: es lo que aparece bajo la firma en los formularios. Si falta, el documento sale firmado sin identificación.
Los enfermeros son un caso aparte: su cédula sale de la ficha de enfermero, no del usuario.
@@ -16,7 +20,7 @@ Los enfermeros son un caso aparte: su cédula sale de la ficha de enfermero, no
> Un usuario tiene **dos** campos de rol y hay que cambiar los dos. El texto (`role`) es lo que muestra la interfaz; el vínculo (`role_id`) es de donde salen los permisos reales.
Si cambiás solo uno, el usuario ve un rol y tiene los permisos del otro. Ya pasó.
Si cambia solo uno, el usuario ve un rol y tiene los permisos del otro. Ya pasó.
Después del cambio, **el usuario debe cerrar sesión y volver a entrar**: los permisos se cargan al iniciar sesión, no en cada pantalla.
@@ -0,0 +1,70 @@
---
roles: supervisor
---
# Reportes y métricas
Dónde mirar cada cosa. Hay **dos lugares distintos** y conviene no confundirlos.
```
┌──────────────────────────┐ ┌──────────────────────────┐
│ DASHBOARD DEL TURNERO │ │ REPORTES │
│ │ │ │
│ la operación de HOY │ │ el laboratorio en │
│ │ │ general y los domicilios │
│ • facturación del día │ │ │
│ • tiempos de atención │ │ • ingresos y pagos │
│ • turnos en espera │ │ • rendimiento │
│ • asistente LIA │ │ • traslado de enfermeros │
└──────────────────────────┘ └──────────────────────────┘
```
## Facturación del día
En el dashboard del turnero, tres cifras separadas:
| Cifra | Qué incluye |
|---|---|
| **Facturado** | Turnos finalizados — plata efectivamente cobrada |
| **En proceso** | Turnos aún activos, ya cobrados pero sin cerrar |
| **Total estimado** | La suma de ambos |
Lo importante de entender: **los ausentes y cancelados no se cuentan en ninguna**. No se van a cobrar, así que sumarlos daría una cifra falsa.
La gráfica por hora usa la misma separación: azul lo finalizado, ámbar lo que sigue en curso.
## Tiempos de atención
El dashboard muestra promedios de espera, recepción y servicio, y los desglosa por persona.
En el historial, un servicio que superó los 20 minutos se resalta en rojo. **No siempre es un problema**: un examen seriado dura horas por definición. Al abrir el detalle se ve el desglose por toma y quién atendió cada una.
## Traslado de enfermeros
En Reportes. Sale de comparar las marcas de hora de las visitas domiciliarias, y sirve para detectar rutas mal armadas — mucho tiempo entre visitas suele significar que el orden geográfico no se tuvo en cuenta al agendar.
## Preguntar en lugar de buscar
En el dashboard del turnero está **LIA**, que responde sobre la operación del día:
- ¿Cuántos pacientes se atendieron hoy y cuántos faltan?
- ¿Cuánto se facturó?
- ¿Cuáles son los tiempos promedio por bacteriólogo?
- ¿Cuántos están en espera ahora?
Responde con los datos del día, no con estimaciones. Si no tiene el dato, lo dice.
## Exportar
El historial de turnos se exporta a CSV con los filtros que tenga aplicados. Sirve para analizar en una planilla lo que la pantalla no muestra.
## Preguntas frecuentes
**Los números del dashboard y de reportes no coinciden.**
Miran cosas distintas: el dashboard es la operación presencial de hoy; reportes abarca el laboratorio en general, incluidos domicilios. No son comparables directamente.
**Un turno aparece con un tiempo de servicio enorme.**
Revise si era un examen seriado. Esos duran horas por diseño y el tiempo alto es correcto.
**Necesito un reporte que no existe.**
Exporte el historial a CSV y arme el cálculo en una planilla. Si es algo que va a necesitar seguido, pídalo a un administrador.
+7 -7
View File
@@ -22,7 +22,7 @@ SELECT module_slug, permission FROM role_modules WHERE role_id = <role_id>;
Si los datos están bien → **que cierre sesión y vuelva a entrar**. Los permisos se cargan al iniciar sesión, no en cada petición.
Si `role` y `role_id` no coinciden, actuali las dos:
Si `role` y `role_id` no coinciden, actualice las dos:
```sql
UPDATE admin_users
@@ -37,13 +37,13 @@ UPDATE admin_users
## «Un enlace que enviamos por WhatsApp está roto»
Primero, comprobá si el destino responde:
Primero, compruebe si el destino responde:
```bash
curl -s -o /dev/null -w "%{http_code}\n" "<la URL>"
```
**Si devuelve 503 o no resuelve**, el dominio está caído o cambió. Revisá si el archivo existe en el dominio actual del sistema.
**Si devuelve 503 o no resuelve**, el dominio está caído o cambió. Revise si el archivo existe en el dominio actual del sistema.
Las URL que enviamos viven en lugares distintos según el caso:
@@ -71,7 +71,7 @@ UPDATE system_config
## «El mensaje salió por el número equivocado»
Las dos líneas comparten cuenta y token; lo único que cambia es el `phone_number_id`. Revisá que el envío haya especificado el canal:
Las dos líneas comparten cuenta y token; lo único que cambia es el `phone_number_id`. Revise que el envío haya especificado el canal:
```php
$wa = new WhatsAppService('turnero'); // no new WhatsAppService()
@@ -103,8 +103,8 @@ UPDATE lab_config SET valor = '0' WHERE clave = 'lia_tokens_usados';
El formulario F-LAB-28 tiene secciones para todos los exámenes posibles y muestra solo las del examen del paciente. Si aparecen de más:
1. Verifi que el documento se abra con `&embed=1&compact=1` — sin esos parámetros no se aplica el filtrado.
2. Revisá `_tomas_config` dentro de `datos_respuestas`: ahí queda qué ciclos se configuraron.
1. Verifique que el documento se abra con `&embed=1&compact=1` — sin esos parámetros no se aplica el filtrado.
2. Revise `_tomas_config` dentro de `datos_respuestas`: ahí queda qué ciclos se configuraron.
---
@@ -112,7 +112,7 @@ El formulario F-LAB-28 tiene secciones para todos los exámenes posibles y muest
Las firmas de tomas prolongadas registran el profesional **desde el 3 de agosto de 2026**. Los documentos firmados antes no tienen ese dato y **no es recuperable** — no quedó traza en ninguna tabla de auditoría.
Para los nuevos, el nombre y la cédula se resuelven en el servidor desde la sesión de quien firma. Si aparece vacío en un documento reciente, comprobá que el usuario tenga cédula:
Para los nuevos, el nombre y la cédula se resuelven en el servidor desde la sesión de quien firma. Si aparece vacío en un documento reciente, compruebe que el usuario tenga cédula:
```sql
SELECT id, username, full_name, cedula FROM admin_users WHERE id = <id>;
@@ -85,4 +85,4 @@ En **dos** lugares que hay que mantener sincronizados:
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql` e idempotentes (`IF NOT EXISTS`, guardas en `UPDATE`/`INSERT`).
Si aplicás un cambio directo en producción, **dejá también la migración**: sin ella, un entorno nuevo no tendrá ese cambio y nadie se va a enterar hasta que falle.
Si aplica un cambio directo en producción, **deje también la migración**: sin ella, un entorno nuevo no tendrá ese cambio y nadie se va a enterar hasta que falle.
@@ -37,9 +37,9 @@ UPDATE admin_users SET cedula = username
MariaDB 11.8 admite `IF NOT EXISTS` en `ALTER TABLE`. Para `UPDATE` e `INSERT`, la guarda va en el `WHERE`.
Antes de dar por buena una migración, corrila dos veces y verifi que la segunda no cambie nada.
Antes de dar por buena una migración, córrala dos veces y verifique que la segunda no cambie nada.
> Aplicar un cambio directo en producción sin dejar la migración hace que un entorno nuevo no lo tenga, y nadie se entera hasta que algo falla. Si tocás el esquema, dejá el archivo.
> Aplicar un cambio directo en producción sin dejar la migración hace que un entorno nuevo no lo tenga, y nadie se entera hasta que algo falla. Si toca el esquema, deje el archivo.
## Archivos subidos
@@ -60,7 +60,7 @@ php -l archivo.php
curl -s -o /dev/null -w "%{http_code}\n" "https://<dominio>/<ruta>"
```
Si el cambio afectó permisos, recordá que **las sesiones abiertas conservan los permisos viejos** hasta que el usuario vuelva a entrar.
Si el cambio afectó permisos, recuerde que **las sesiones abiertas conservan los permisos viejos** hasta que el usuario vuelva a entrar.
## Configuración por entorno
@@ -0,0 +1,88 @@
# Respaldos y recuperación
Qué hay que respaldar, y qué se pierde si no está.
## Las tres cosas a respaldar
```
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ BASE DE DATOS │ │ ARCHIVOS SUBIDOS │ │ CÓDIGO │
│ │ │ │ │ │
│ 91 tablas │ │ uploads/terms/ │ │ repositorio git │
│ pacientes, turnos, │ │ uploads/turnero/ │ │ │
│ formularios, │ │ │ │ ya respaldado por │
│ firmas │ │ NO están en git │ │ estar en el remoto │
└────────────────────┘ └────────────────────┘ └────────────────────┘
crítico crítico cubierto
```
El código está a salvo por estar versionado. Los otros dos **no tienen respaldo automático por el solo hecho de existir**.
## Base de datos
Es lo único irreemplazable. Contiene historia clínica, consentimientos firmados y facturación — información con valor legal y sin forma de reconstruirse.
```bash
mysqldump -h <host> -u <usuario> -p <base> \
--single-transaction --routines --triggers \
| gzip > respaldo_$(date +%F).sql.gz
```
`--single-transaction` evita bloquear las tablas mientras corre, así se puede hacer con el sistema en uso.
### Qué contiene lo crítico
| Tabla | Por qué importa |
|---|---|
| `lab_pacientes` | Fichas clínicas |
| `turnero_consentimientos`, `lab_form_envios` | **Formularios firmados** — valor legal |
| `turnero_turnos`, `turnero_solicitudes` | Historial de atención y facturación |
| `terms_acceptance` | Aceptación de términos, ~5.000 registros |
| `admin_users`, `roles`, `role_modules` | Acceso al sistema |
Las firmas se guardan como imagen **dentro** de las tablas, no como archivos sueltos. Un respaldo de la base las incluye.
## Archivos subidos
```
uploads/terms/ documentos de términos y condiciones
uploads/turnero/tv_media/ videos e imágenes de la pantalla de TV
```
**No están en el repositorio.** Al mover el sistema de servidor hay que copiarlos aparte, o los enlaces quedan apuntando a archivos que ya no existen.
Es exactamente lo que pasó con el documento de términos cuando cambió el dominio: la base seguía apuntando a una URL que ya no respondía.
## Antes de un cambio riesgoso
Si va a tocar datos en producción, respalde **solo lo que va a tocar**:
```bash
mysqldump -h <host> -u <usuario> -p <base> role_modules admin_users \
> antes_del_cambio.sql
```
Es rápido y suele alcanzar. Un respaldo completo para cambiar una columna es desproporcionado; no tener ninguno es imprudente.
## Verificar que el respaldo sirve
Un respaldo que nunca se restauró no es un respaldo, es un archivo:
```bash
gunzip -t respaldo_2026-08-03.sql.gz # ¿está íntegro?
zcat respaldo_2026-08-03.sql.gz | head -40 # ¿tiene lo que espera?
```
Lo ideal es restaurarlo de vez en cuando en una base de prueba y comprobar que el sistema arranca contra ella.
## Qué NO es recuperable
Aunque tengas respaldos, hay datos que nunca se guardaron y no hay de dónde sacarlos:
| Dato | Desde cuándo existe |
|---|---|
| Quién creó cada consentimiento del turnero | 3 de agosto de 2026 |
| Quién firmó cada toma de F-LAB-28 | 3 de agosto de 2026 |
| Cédula del personal no enfermero | 3 de agosto de 2026 |
Los registros anteriores tienen esos campos vacíos, y **no hay traza de auditoría** que permita reconstruirlos. Vale como advertencia: cuando se agrega una columna para registrar quién hizo algo, lo anterior se pierde.
@@ -54,6 +54,12 @@ $rawComps = [
Si el envío por plantilla falla, se manda un texto plano con el enlace armado desde el dominio del servidor. Ese texto **no** pasa por Meta, así que su URL puede diferir de la del botón.
## Contenido configurable
Buena parte de lo que responde el bot vive en la base, no en el código: respuestas
automáticas por palabra clave y menús interactivos. Ver
[Menús y respuestas automáticas](?m=soporte&v=documentacion&s=tecnica&d=bot-menus-y-respuestas).
## `BotService`
Decide qué hacer con cada mensaje entrante:
@@ -0,0 +1,97 @@
# Menús y respuestas automáticas del bot
Buena parte de lo que responde el bot **no está en el código**: es contenido configurable en la base. Cambiar lo que contesta no requiere tocar PHP ni desplegar.
## Respuestas automáticas
`autoresponses` — dispara una respuesta según lo que escriba el paciente.
| Columna | Para qué |
|---|---|
| `trigger_type` | Cómo se dispara: `welcome`, `keyword`, `contains` |
| `trigger_value` | Las palabras que la activan, separadas por coma |
| `response_text` | Lo que responde |
| `response_type` | `text` o plantilla |
| `template_name` | Plantilla de Meta, si aplica |
| `menu_id` | Si en vez de texto debe mostrar un menú |
| `priority` | Cuál gana si varias coinciden |
| `is_active` | Permite desactivar sin borrar |
### Tipos de disparo
| Tipo | Cuándo actúa |
|---|---|
| `welcome` | Primer contacto |
| `keyword` | El mensaje **es** una de las palabras listadas |
| `contains` | El mensaje **contiene** el texto |
`keyword` es más estricto que `contains`. Para *«hola»* conviene `keyword` — si fuera `contains` se dispararía dentro de cualquier frase que la incluya.
### Las que están activas
Cubren saludo, precios, horarios, indicaciones previas, cotización y preparación para frotis. Son las preguntas que más se repiten, resueltas sin intervención humana.
## Menús interactivos
`menu_options` — el árbol de opciones numeradas que el paciente recorre respondiendo con un número.
| Columna | Para qué |
|---|---|
| `menu_id` | A qué menú pertenece la opción |
| `option_number` | El número que marca el paciente |
| `text` | Lo que se muestra |
| `action_type` | Qué pasa al elegirla |
| `action_value` | El destino o el texto de respuesta |
| `is_active` | Permite ocultar sin borrar |
### Acciones
| `action_type` | Efecto |
|---|---|
| `menu` | Abre otro menú — `action_value` es su identificador |
| `end` | Responde con `action_value` y cierra |
| `message` | Envía el texto y sigue en el mismo menú |
### La estructura actual
```
main_menu (1)
├─ 1 Agendar toma a domicilio end
├─ 2 Consultar resultados end
├─ 3 Información sobre exámenes ─────► menu informacion_examenes (10)
│ ├─ 1 Pruebas de embarazo end
│ ├─ 2 Prueba de paternidad message
│ ├─ 3 Pruebas de aliento end
│ ├─ 4 Orina de 24 horas end
│ ├─ 5 Solicitar cotización end
│ └─ 6 Volver ──────────────────► main_menu
├─ 4 Ubicación y horarios end
├─ 5 Ver portafolio end
├─ 6 Convenios end
└─ 7 Salir end
```
> Un menú se apunta por su **identificador** (`main_menu`, `informacion_examenes`), no por su número de fila. Cambiar el orden no rompe los enlaces.
## Estado de la conversación
`user_states` recuerda en qué punto quedó cada paciente: en qué menú está, si espera un dato, o si lo tomó un operador. Es lo que permite que responder «3» signifique algo.
Es la tabla más voluminosa del bot — una fila por contacto activo.
## Envíos masivos
`broadcast_history` registra los envíos a varios destinatarios a la vez.
> Fuera de la ventana de 24 horas hay que usar plantilla aprobada. Un envío masivo a contactos que no escribieron recientemente **solo puede hacerse con plantilla**.
## Encuestas
`survey_responses` guarda las respuestas de satisfacción. La encuesta se envía desde el historial del turnero con la plantilla `encuesta_turnero`.
## Al modificar el bot
- **Primero mire si alcanza con la base.** Muchos cambios de comportamiento son una fila en `autoresponses` o `menu_options`, sin desplegar nada.
- **Use `is_active` en vez de borrar.** Permite volver atrás y conserva el historial.
- **Cuide `priority`.** Si dos respuestas coinciden, gana la de mayor prioridad; sin ella el resultado depende del orden de la consulta.
- **Verifique el árbol completo** después de tocar un menú: una opción que apunta a un menú inexistente deja al paciente sin salida.
@@ -0,0 +1,54 @@
# Registro de exámenes
Registro de exámenes realizados en sede: creación de órdenes, toma de muestras e ingreso de resultados. Es de los módulos nuevos — todo su código vive dentro de `modules/registro_exams/`.
## Vistas
| Vista | Qué hace |
|---|---|
| `lista` | Órdenes con filtros y estado |
| `nueva_orden` | Alta de una orden |
| `orden` | Detalle: ítems, estados e ingreso de resultados |
| `etiqueta` | Etiqueta imprimible para rotular la muestra |
## Endpoints
| Endpoint | Qué hace |
|---|---|
| `get_ordenes.php` | Lista con filtros |
| `get_orden.php` | Detalle de una orden |
| `save_orden.php` | Crea o actualiza |
| `save_resultado.php` | Guarda el resultado de un ítem |
| `cambiar_estado_item.php` | Avanza el estado de un ítem individual |
Usan `api/_helpers.php` del módulo, con las mismas convenciones que el turnero.
## Flujo
```
nueva_orden orden orden
│ │ │
▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌─────────────┐
│ crear ├──────►│ muestra ├──────────►│ resultado │
│ orden │ │ tomada │ │ ingresado │
└─────────┘ └───────────┘ └─────────────┘
│ │
▼ ▼
etiqueta se rotula
imprimible la muestra
```
El estado se lleva **por ítem**, no por orden completa: una orden puede tener unos exámenes resueltos y otros pendientes.
## Relación con otros módulos
| Módulo | Vínculo |
|---|---|
| `lab_examenes` | De ahí sale el catálogo y los valores de referencia |
| `lab_pacientes` | El paciente de la orden |
| `medicos` | El médico que la solicitó |
## Al tocarlo
Los resultados de laboratorio son información clínica: un ítem mal guardado o un valor de referencia equivocado tienen consecuencias reales. Cualquier cambio en `save_resultado.php` merece probarse con una orden de prueba antes de subirlo.
@@ -0,0 +1,61 @@
# Órdenes médicas, reportes y configuración
Tres módulos de la generación anterior: el código vive en archivos de la raíz y el módulo solo aporta el registro en el menú.
| Módulo | Archivo real |
|---|---|
| `lab_ordenes` | `lab_ordenes.php` |
| `lab_reportes` | `lab_reportes.php` |
| `lab_configuracion` | `lab_configuracion.php` |
| `lab_dashboard` | `index.php` |
## Órdenes médicas
Las órdenes que el paciente envía por WhatsApp, normalmente como foto de la orden en papel. El módulo permite revisarlas y autorizarlas.
### Datos
`lab_ordenes_medicas` guarda tanto la imagen recibida como lo que el personal extrae de ella:
| Grupo | Columnas |
|---|---|
| Origen | `conversation_id`, `whatsapp_media_id`, `local_file` |
| Vínculos | `paciente_id`, `domicilio_id` |
| Contenido de la orden | `medico_nombre`, `medico_registro`, `fecha_orden`, `diagnostico`, `examenes_solicitados` |
| Preparación | `requiere_ayuno`, `horas_ayuno`, `indicaciones` |
| Trazabilidad | `revisada_por`, `revisada_at`, `autorizada_por`, `autorizada_at`, `comentario_revision` |
### Estados
```
pendiente ──► autorizada
└──────► rechazada
(con comentario)
```
La orden llega en `pendiente`. Al revisarla se autoriza o se rechaza, y en ambos casos queda **quién** lo hizo y **cuándo** — es de los pocos flujos que registró trazabilidad desde el principio.
`requiere_ayuno` y `horas_ayuno` importan porque condicionan lo que se le indica al paciente antes de la toma.
## Reportes
`lab_reportes.php` — informes de ingresos, rendimiento y pagos.
Incluye el **tiempo de traslado del enfermero**, que sale de comparar las marcas de hora de las visitas domiciliarias. Es la métrica que permite ver si una ruta está mal armada.
> Para métricas del turnero (tiempos de atención, facturación del día) el lugar es el **dashboard del turnero**, no este módulo. Los dos existen y cubren cosas distintas: este mira el laboratorio en general y los domicilios; aquel mira la operación presencial del día.
## Configuración del laboratorio
`lab_configuracion.php` edita lo que aparece en el encabezado de todos los documentos generados: nombre, subtítulo, dirección, ciudad, teléfono, correo, logo y color.
Se guarda en `lab_config`, con las claves `empresa_*` y `doc_*`. Ver [Configuraciones críticas](?m=soporte&v=documentacion&s=operacion&d=configuraciones-criticas) para el detalle de qué se configura desde aquí y qué no.
> Desde aquí **no** se cambian las URL que llegan por WhatsApp en los botones de plantilla: esas viven en Meta.
## Dashboard
`index.php` es el panel principal del laboratorio y, a la vez, el punto de entrada histórico del sistema. Convive con `erp.php`, que es el del ERP modular.
Que el archivo de entrada original siga siendo una pantalla activa es una de las consecuencias de la [migración gradual](?m=soporte&v=documentacion&s=arquitectura&d=decisiones-y-deuda).
@@ -0,0 +1,67 @@
# Catálogos del laboratorio
Los módulos que administran las tablas maestras. Se agrupan acá porque comparten la misma forma: una pantalla de listado con alta, edición y baja sobre una tabla.
## Los módulos
| Módulo | Tabla principal | Qué administra |
|---|---|---|
| `lab_examenes` | `exam_tipos` | Catálogo de exámenes, valores de referencia y tarifas |
| `medicos` | `medicos` | Médicos solicitantes |
| `lab_pacientes` | `lab_pacientes` | Fichas clínicas e historial |
| `lab_enfermeras` | `lab_enfermeras` | Personal clínico |
| `lab_eps` | `lab_eps` | EPS y aseguradoras |
| `lab_empresas` | `lab_empresas` | Empresas, convenios, subgrupos y tarifas |
| `lab_ciudades` | `lab_ciudades` | Ciudades de pacientes |
| `usuarios` | `admin_users`, `roles` | Usuarios y asignación de roles |
## Los que tienen endpoints propios
La mayoría son puentes al sistema anterior. Dos tienen código propio:
### `lab_examenes`
| Endpoint | Qué hace |
|---|---|
| `list.php` / `get.php` | Listado y detalle |
| `save.php` | Alta y edición del examen |
| `save_item.php` | Ítems que componen un examen |
| `get_tarifas.php` / `save_tarifa.php` | Tarifas por empresa o convenio |
Es el catálogo del que dependen el turnero y el registro de exámenes. Un examen mal configurado se propaga a todo lo demás: consentimientos que no se piden, tarifas que no se aplican.
**`exam_tipo_consentimientos`** vincula un examen con los formularios que obliga a firmar. Es una de las dos fuentes de consentimientos del turnero; la otra es la estación destino.
### `medicos`
`list.php`, `save.php`, `delete.php`. El médico se asocia a la solicitud del turnero y sale impreso en los documentos.
## Pacientes
`lab_pacientes` es el más consultado de todos: lo usan el turnero, domicilios, registro de exámenes y el bot.
Campos que otros módulos dan por sentados:
| Campo | Quién lo usa |
|---|---|
| `numero_documento` | Búsqueda en todas las pantallas |
| `telefono` | Envío de consentimientos y encuestas |
| `nombre_completo`, `tipo_documento` | Encabezado de todos los documentos |
| `fecha_nacimiento` | Cálculo de edad en formularios |
| `eps` | Facturación |
Un teléfono mal cargado se manifiesta lejos de donde se originó: como un consentimiento que nunca llegó.
## Usuarios
Ver [Roles y permisos](?m=soporte&v=documentacion&s=arquitectura&d=roles-y-permisos) para el detalle del control de acceso.
Lo esencial al crear o editar un usuario:
- **`role` y `role_id` deben cambiarse juntos.** La interfaz lee uno, los permisos salen del otro.
- **Cargue la cédula.** Es lo que aparece bajo la firma en los formularios.
- El usuario debe **volver a iniciar sesión** para que un cambio de permisos surta efecto.
## Al agregar un catálogo nuevo
Si es un ABM simple, siga el patrón de `medicos`: un `views/index.php` y tres endpoints (`list`, `save`, `delete`). Registralo en `SYSTEM_MODULES` y concédalo a los roles que corresponda, o nadie lo verá.
+3 -3
View File
@@ -27,10 +27,10 @@ Cada módulo tiene su `api/_helpers.php` con lo común. Los archivos que empieza
### Reglas al agregar uno
1. **Verifi permisos en el propio endpoint.** Que la vista haya ocultado el botón no protege nada.
2. **Resolvé la identidad en el servidor.** Para saber quién hace una acción, usá `adminId()`, no un valor que mande el navegador.
1. **Verifique permisos en el propio endpoint.** Que la vista haya ocultado el botón no protege nada.
2. **Resuelva la identidad en el servidor.** Para saber quién hace una acción, use `adminId()`, no un valor que mande el navegador.
3. **Consultas preparadas siempre.**
4. **Dejá un comentario de cabecera** describiendo qué hace y qué recibe: es lo que aparece en la tabla de abajo.
4. **Deje un comentario de cabecera** describiendo qué hace y qué recibe: es lo que aparece en la tabla de abajo.
## Inventario
+3 -13
View File
@@ -5,23 +5,13 @@
* de secciones (técnica, arquitectura, operación) solo para administradores.
*/
$_sopEsAdmin = in_array($_SESSION['admin_user']['role'] ?? '', ['admin', 'superadmin'], true);
// Una sola entrada en el menú: la navegación por secciones y documentos vive
// dentro de la propia página. Tener aquí un enlace por sección hacía que al
// abrir una quedaran dos entradas marcadas como activas a la vez.
$_sopLinks = [
['name' => 'Documentación', 'icon' => 'fas fa-book', 'route' => '/erp.php?m=soporte&v=documentacion'],
['name' => 'Manual de usuario', 'icon' => 'fas fa-book-reader',
'route' => '/erp.php?m=soporte&v=documentacion&s=manual'],
];
if ($_sopEsAdmin) {
$_sopLinks[] = ['name' => 'Documentación técnica', 'icon' => 'fas fa-code',
'route' => '/erp.php?m=soporte&v=documentacion&s=tecnica'];
$_sopLinks[] = ['name' => 'Arquitectura', 'icon' => 'fas fa-sitemap',
'route' => '/erp.php?m=soporte&v=documentacion&s=arquitectura'];
$_sopLinks[] = ['name' => 'Operación y soporte', 'icon' => 'fas fa-life-ring',
'route' => '/erp.php?m=soporte&v=documentacion&s=operacion'];
}
return [
'slug' => 'soporte',
'name' => 'Soporte',
+218 -2
View File
@@ -24,7 +24,8 @@ $cuerpo = '';
$toc = [];
if ($archivo) {
$md = Generadores::expandir((string)file_get_contents($archivo));
[, $crudo] = DocIndex::leer($archivo); // descarta la cabecera de permisos
$md = Generadores::expandir($crudo);
$cuerpo = Markdown::render($md);
$toc = Markdown::indice($md);
foreach ($arbol[$seccion]['docs'] ?? [] as $d) {
@@ -46,6 +47,10 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
.doc-nav .buscador input { width:100%; font-size:.84rem; padding:7px 11px; border:1px solid #cbd5e1;
border-radius:8px; background:#fff; }
.doc-nav .buscador input:focus { outline:none; border-color:#2563eb; box-shadow:0 0 0 3px rgba(37,99,235,.12); }
.doc-nav .btn-pdf { display:block; margin-top:8px; text-align:center; font-size:.78rem;
padding:6px 10px; border:1px solid #cbd5e1; border-radius:8px;
color:#475569; text-decoration:none; background:#fff; }
.doc-nav .btn-pdf:hover { border-color:#2563eb; color:#1d4ed8; }
.doc-nav-sec { padding:10px 16px 4px; font-size:.68rem; font-weight:800; text-transform:uppercase;
letter-spacing:.08em; color:#64748b; display:flex; align-items:center; gap:6px; }
.doc-nav a { display:block; padding:5px 16px 5px 30px; font-size:.83rem; color:#334155;
@@ -134,10 +139,85 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
.doc-body { padding:20px 18px 60px; }
}
@media print {
.doc-nav, .doc-toc, .doc-ruta { display:none !important; }
.doc-nav, .doc-toc, .doc-ruta, #btnAI, #aiPanel { display:none !important; }
.doc-body { max-width:none; padding:0; }
.doc-body pre { background:#f8fafc; color:#0f172a; border:1px solid #cbd5e1; }
}
/* ── Panel LIA — mismo diseño que el dashboard del turnero ── */
#aiPanel {
position:fixed; bottom:0; right:24px; width:400px; z-index:1080;
box-shadow:0 -8px 40px rgba(0,0,0,.22); border-radius:20px 20px 0 0;
background:#fff; border:1px solid #e2e8f0; border-bottom:none;
transform:translateY(100%); transition:transform .35s cubic-bezier(.4,0,.2,1);
display:flex; flex-direction:column; max-height:580px;
}
#aiPanel.open { transform:translateY(0); }
#aiPanel.minimized .ai-body { display:none; }
#aiPanel.minimized { max-height:none; }
.ai-header {
background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1);
color:#fff; padding:14px 18px; border-radius:20px 20px 0 0;
display:flex; align-items:center; gap:12px; flex-shrink:0; cursor:pointer; user-select:none;
}
.lia-logo {
width:44px; height:44px; border-radius:50%;
background:rgba(255,255,255,.15); border:2px solid rgba(255,255,255,.4);
display:flex; align-items:center; justify-content:center;
font-size:1rem; font-weight:900; letter-spacing:-1px; flex-shrink:0;
transition:transform .3s;
}
.lia-logo:hover { transform:scale(1.08); }
.lia-logo .lia-i { color:#7dd3fc; }
.ai-title { font-weight:800; font-size:1.05rem; letter-spacing:.5px; }
.btn-tts { background:rgba(255,255,255,.15); border:1px solid rgba(255,255,255,.3);
color:rgba(255,255,255,.7); cursor:pointer; padding:5px 9px; border-radius:8px;
font-size:.88rem; transition:all .2s; }
.btn-tts:hover { background:rgba(255,255,255,.25); color:#fff; }
.ai-header .btn-close { filter:invert(1) brightness(2); opacity:.7; }
.ai-header .btn-close:hover { opacity:1; }
.ai-body { display:flex; flex-direction:column; flex:1; overflow:hidden; }
.ai-quick { display:flex; gap:7px; flex-wrap:wrap; padding:10px 14px;
border-bottom:1px solid #f1f5f9; flex-shrink:0; background:#fafbff; }
.ai-chip { background:#fff; border:1.5px solid #e2e8f0; border-radius:99px; padding:6px 13px;
font-size:.82rem; cursor:pointer; color:#334155; white-space:nowrap;
transition:all .15s; font-weight:500; }
.ai-chip:hover { background:#eff6ff; border-color:#93c5fd; color:#1d4ed8; }
.ai-messages { flex:1; overflow-y:auto; padding:14px 16px; display:flex; flex-direction:column; gap:10px; }
.ai-msg { max-width:92%; padding:10px 14px; border-radius:16px; font-size:.9rem;
line-height:1.55; animation:msg-in .2s ease; word-wrap:break-word; }
@keyframes msg-in { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
.ai-msg.user { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff;
align-self:flex-end; border-radius:16px 16px 2px 16px; }
.ai-msg.bot { background:#f1f5f9; color:#1e293b; align-self:flex-start; border-radius:16px 16px 16px 2px; }
.ai-msg.bot.typing::after { content:'●●●'; animation:dots 1.2s infinite; letter-spacing:2px; }
@keyframes dots { 0%,100%{opacity:.3} 50%{opacity:1} }
.ai-msg.bot strong { color:#1565c0; }
.ai-msg.bot ul, .ai-msg.bot ol { margin:4px 0 4px 16px; padding:0; }
.ai-msg.bot code { background:#e2e8f0; color:#be185d; padding:1px 4px; border-radius:3px; font-size:.9em; }
.ai-msg.bot pre { background:#0f172a; color:#e2e8f0; padding:9px 11px; border-radius:8px;
overflow-x:auto; font-size:.76rem; margin:6px 0; }
.ai-fuentes { font-size:.68rem; color:#94a3b8; align-self:flex-start; margin:-6px 0 0 4px; }
.ai-footer { padding:10px 14px; border-top:1px solid #f1f5f9; display:flex; gap:8px;
flex-shrink:0; background:#fafbff; }
.ai-footer input { flex:1; border:1.5px solid #e2e8f0; border-radius:10px; padding:9px 14px;
font-size:.9rem; outline:none; background:#fff; }
.ai-footer input:focus { border-color:#1565c0; }
.ai-footer .btn-send { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff; border:none;
border-radius:10px; padding:9px 16px; cursor:pointer; font-size:.88rem; min-width:42px; }
.ai-footer .btn-send:disabled { opacity:.5; cursor:default; }
#btnAI {
position:fixed; bottom:24px; right:24px; z-index:1079;
background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1);
color:#fff; border:none; border-radius:50%; width:58px; height:58px;
font-size:.78rem; font-weight:900; letter-spacing:-.5px;
cursor:pointer; box-shadow:0 4px 20px rgba(21,101,192,.45);
transition:transform .2s,box-shadow .2s;
display:flex; align-items:center; justify-content:center;
}
#btnAI:hover { transform:scale(1.1); box-shadow:0 6px 28px rgba(21,101,192,.55); }
#btnAI.hidden { display:none; }
@media(max-width:600px) { #aiPanel { width:100%; right:0; border-radius:16px 16px 0 0; } }
</style>
<div class="doc-wrap">
@@ -145,6 +225,9 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
<nav class="doc-nav">
<div class="buscador">
<input type="search" id="q" placeholder="Buscar en la documentación…" autocomplete="off">
<a class="btn-pdf" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir" target="_blank">
<i class="fas fa-file-pdf"></i> Exportar a PDF
</a>
</div>
<div id="resultados" style="display:none"></div>
<div id="arbol">
@@ -231,6 +314,33 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
</div>
</div>
<!-- ── Panel LIA — asistente sobre la documentación ── -->
<button id="btnAI" onclick="toggleAI()" title="Preguntar a LIA">LIA</button>
<div id="aiPanel">
<div class="ai-header" onclick="minimizarIA(event)">
<div class="lia-logo" id="liaLogo">
<span>L</span><span class="lia-i">I</span><span>A</span>
</div>
<div style="flex:1;line-height:1.1">
<div class="ai-title">LIA</div>
<div style="font-size:.68rem;opacity:.75;font-weight:400">Asistente de Documentación</div>
</div>
<button class="btn-tts" onclick="event.stopPropagation();minimizarIA(event)" title="Minimizar" id="btnMin">
<i class="fas fa-minus" id="iconMin"></i>
</button>
<button class="btn-close" onclick="event.stopPropagation();toggleAI()"></button>
</div>
<div class="ai-body" id="aiBody">
<div class="ai-quick" id="aiQuick"></div>
<div class="ai-messages" id="aiMessages"></div>
<div class="ai-footer">
<input type="text" id="aiInput" placeholder="Pregunta cómo hacer algo…" maxlength="400"
onkeydown="if(event.key==='Enter')enviarIA()">
<button class="btn-send" onclick="enviarIA()"><i class="fas fa-paper-plane"></i></button>
</div>
</div>
</div>
<script>
/* ── Buscador: índice completo servido con la página ── */
const DOCS = <?= json_encode(DocIndex::indiceBusqueda(), JSON_UNESCAPED_UNICODE) ?>;
@@ -280,6 +390,112 @@ function esc(s) {
return d.innerHTML;
}
/* ── LIA: asistente sobre la documentación ──
Mismo comportamiento y nombres que el panel del dashboard del turnero. */
const AI_API = '<?= BASE_URL ?>modules/soporte/api/ai_docs.php';
let _liaHistorial = [];
function toggleAI() {
const panel = document.getElementById('aiPanel');
const fab = document.getElementById('btnAI');
const abre = !panel.classList.contains('open');
panel.classList.toggle('open', abre);
panel.classList.remove('minimized');
fab.classList.toggle('hidden', abre);
if (abre) {
if (!document.getElementById('aiMessages').children.length) saludarIA();
document.getElementById('aiInput').focus();
}
}
function minimizarIA(e) {
if (e) e.stopPropagation();
const panel = document.getElementById('aiPanel');
panel.classList.toggle('minimized');
const icono = document.getElementById('iconMin');
icono.className = panel.classList.contains('minimized') ? 'fas fa-chevron-up' : 'fas fa-minus';
}
function saludarIA() {
agregarMsg('bot', 'Hola, soy LIA. Pregúnteme cómo hacer algo en el sistema y le respondo con la documentación.');
// Atajos armados con los documentos que este usuario realmente puede ver
const quick = document.getElementById('aiQuick');
quick.innerHTML = DOCS.slice(0, 4)
.map(d => `<button class="ai-chip" onclick="enviarRapido('¿Qué explica ${esc(d.t)}?')">${esc(d.t)}</button>`)
.join('');
}
function enviarRapido(texto) {
document.getElementById('aiInput').value = texto;
enviarIA();
}
function agregarMsg(tipo, html) {
const msgs = document.getElementById('aiMessages');
const div = document.createElement('div');
div.className = 'ai-msg ' + tipo;
div.innerHTML = html;
msgs.appendChild(div);
msgs.scrollTop = msgs.scrollHeight;
return div;
}
/* Formato mínimo de la respuesta: negritas, código, listas y saltos. */
function mdToHtml(md) {
let h = esc(md);
h = h.replace(/```[\s\S]*?```/g, m => '<pre>' + m.replace(/```\w*\n?/g, '') + '</pre>');
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
h = h.replace(/^\s*[-*]\s+(.*)$/gm, '<li>$1</li>');
h = h.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
return h.replace(/\n{2,}/g, '<br><br>').replace(/\n/g, '<br>');
}
async function enviarIA() {
const input = document.getElementById('aiInput');
const pregunta = input.value.trim();
if (!pregunta) return;
document.getElementById('aiQuick').innerHTML = '';
agregarMsg('user', esc(pregunta));
const typing = agregarMsg('bot typing', '');
input.value = '';
input.disabled = true;
try {
const res = await fetch(AI_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pregunta, historial: _liaHistorial }),
});
const d = await res.json();
typing.className = 'ai-msg bot';
if (d.ok) {
typing.innerHTML = mdToHtml(d.respuesta);
if (d.fuentes && d.fuentes.length) {
const f = document.createElement('div');
f.className = 'ai-fuentes';
f.textContent = 'Según: ' + d.fuentes.join(' · ');
document.getElementById('aiMessages').appendChild(f);
}
_liaHistorial.push({ rol: 'user', texto: pregunta }, { rol: 'model', texto: d.respuesta });
if (_liaHistorial.length > 6) _liaHistorial = _liaHistorial.slice(-6);
} else {
typing.textContent = d.error || 'No pude responder.';
}
} catch (_) {
typing.className = 'ai-msg bot';
typing.textContent = 'Error de conexión';
} finally {
input.disabled = false;
input.focus();
const msgs = document.getElementById('aiMessages');
msgs.scrollTop = msgs.scrollHeight;
}
}
const inputQ = document.getElementById('q');
let _t = null;
inputQ.addEventListener('input', () => {
+270
View File
@@ -0,0 +1,270 @@
<?php
/**
* modules/soporte/views/imprimir.php
* Documentación completa en un solo documento, maquetado para imprimir a PDF.
*
* ?s=<seccion> limita la exportación a una sección
* (sin parámetro) exporta todo lo que el usuario puede ver
*
* Se sirve sin el layout del ERP: la idea es que la página sea el entregable.
*/
require_once APP_ROOT . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
require_once __DIR__ . '/../Markdown.php';
require_once __DIR__ . '/../DocIndex.php';
require_once __DIR__ . '/../Generadores.php';
$arbol = DocIndex::arbol();
// Filtro opcional por sección
$soloSeccion = preg_replace('/[^a-z0-9-]/', '', $_GET['s'] ?? '');
if ($soloSeccion && isset($arbol[$soloSeccion])) {
$arbol = [$soloSeccion => $arbol[$soloSeccion]];
}
// Datos del laboratorio para la portada
$emp = ['empresa_nombre' => 'Laboratorio Clínico', 'empresa_subtitulo' => '', 'doc_logo_base64' => ''];
try {
$rows = Database::getInstance()->getConnection()->query(
"SELECT clave, valor FROM lab_config
WHERE clave IN ('empresa_nombre','empresa_subtitulo','doc_logo_base64')"
)->fetchAll(PDO::FETCH_KEY_PAIR);
$emp = array_merge($emp, $rows ?: []);
} catch (\Throwable $_) {}
// Numeración: 1. Sección → 1.1 Documento
$capitulos = [];
$nSec = 0;
foreach ($arbol as $sec => $cfg) {
$nSec++;
$docs = [];
$nDoc = 0;
foreach ($cfg['docs'] as $d) {
$nDoc++;
[, $cuerpo] = DocIndex::leer($d['archivo']);
$md = Generadores::expandir($cuerpo);
// El H1 del documento se reemplaza por el encabezado numerado
$md = preg_replace('/^#\s+.*$/m', '', $md, 1);
$docs[] = [
'num' => "{$nSec}.{$nDoc}",
'titulo' => $d['titulo'],
'id' => 'doc-' . $sec . '-' . $d['slug'],
'html' => Markdown::render($md),
];
}
$capitulos[] = ['num' => (string)$nSec, 'titulo' => $cfg['titulo'],
'resumen' => $cfg['resumen'], 'id' => 'cap-' . $sec, 'docs' => $docs];
}
$totalDocs = array_sum(array_map(fn($c) => count($c['docs']), $capitulos));
$esc = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES);
?><!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Documentación del Sistema — <?= $esc($emp['empresa_nombre']) ?></title>
<style>
@page {
size: A4;
margin: 18mm 16mm 16mm 16mm;
}
@page :first { margin: 0; }
* { box-sizing: border-box; }
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body {
font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 10.5pt; line-height: 1.55; color: #1e293b;
margin: 0; padding: 0; background: #fff;
}
/* ── Barra de acciones (no se imprime) ── */
.barra {
position: sticky; top: 0; z-index: 10;
background: #0f172a; color: #fff; padding: 11px 20px;
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
}
.barra .t { font-weight: 700; flex: 1; font-size: .95rem; }
.barra button, .barra a {
background: #2563eb; color: #fff; border: none; border-radius: 7px;
padding: 7px 15px; font-size: .84rem; cursor: pointer; text-decoration: none;
}
.barra a.sec { background: rgba(255,255,255,.14); }
.barra button:hover { background: #1d4ed8; }
.hoja { max-width: 195mm; margin: 0 auto; padding: 14mm 12mm 20mm; }
/* ── Portada ── */
.portada {
display: flex; flex-direction: column; justify-content: center;
min-height: 247mm; padding: 0 22mm; text-align: center;
page-break-after: always; break-after: page;
}
.portada img { max-width: 170px; margin: 0 auto 26px; }
.portada .lab { font-size: 13pt; color: #64748b; letter-spacing: .06em;
text-transform: uppercase; margin-bottom: 6px; }
.portada .sub { font-size: 10.5pt; color: #94a3b8; margin-bottom: 46px; }
.portada h1 { font-size: 30pt; font-weight: 800; color: #0d47a1;
margin: 0 0 10px; line-height: 1.15; letter-spacing: -.5px; }
.portada .lin { width: 76px; height: 3px; background: #0d47a1;
margin: 22px auto 24px; border-radius: 2px; }
.portada .desc { font-size: 11pt; color: #475569; max-width: 118mm;
margin: 0 auto 50px; line-height: 1.65; }
.portada .meta { font-size: 9.5pt; color: #64748b; line-height: 1.9; }
.portada .meta b { color: #334155; }
/* ── Índice ── */
.indice { page-break-after: always; break-after: page; }
.indice h2 { font-size: 17pt; color: #0d47a1; margin: 0 0 22px;
padding-bottom: 10px; border-bottom: 2px solid #e2e8f0; }
.idx-cap { margin-bottom: 16px; }
.idx-cap > a { display: block; font-size: 11.5pt; font-weight: 700; color: #0f172a;
text-decoration: none; margin-bottom: 3px; }
.idx-cap .res { font-size: 9pt; color: #94a3b8; margin: 0 0 7px 20px; }
.idx-doc { display: block; font-size: 10pt; color: #334155; text-decoration: none;
margin: 0 0 3px 20px; }
.idx-doc .n { color: #94a3b8; display: inline-block; min-width: 30px; }
/* ── Capítulo ── */
.cap { page-break-before: always; break-before: page; padding-top: 26mm; text-align: center; }
.cap .n { font-size: 46pt; font-weight: 800; color: #dbeafe; line-height: 1; }
.cap h2 { font-size: 22pt; color: #0d47a1; margin: 6px 0 12px; font-weight: 800; }
.cap p { color: #64748b; font-size: 10.5pt; max-width: 115mm; margin: 0 auto; }
/* ── Documento ── */
.doc { page-break-before: always; break-before: page; }
.doc-h {
border-bottom: 2px solid #0d47a1; padding-bottom: 8px; margin-bottom: 16px;
page-break-after: avoid; break-after: avoid;
}
.doc-h .n { font-size: 9pt; color: #2563eb; font-weight: 700; letter-spacing: .08em; }
.doc-h h3 { font-size: 16pt; color: #0f172a; margin: 2px 0 0; font-weight: 700; }
h2, h3, h4 { page-break-after: avoid; break-after: avoid; }
.doc h2 { font-size: 12.5pt; color: #1565c0; margin: 17px 0 7px; font-weight: 700; }
.doc h3 { font-size: 11pt; color: #334155; margin: 13px 0 5px; font-weight: 700; }
.doc h4 { font-size: 10pt; color: #475569; margin: 11px 0 4px; font-weight: 700; }
.doc p { margin: 0 0 8px; text-align: justify; }
.doc ul, .doc ol { margin: 0 0 9px; padding-left: 20px; }
.doc li { margin-bottom: 2px; }
.doc a { color: #1d4ed8; text-decoration: none; }
.doc strong { color: #0f172a; }
code { background: #f1f5f9; color: #be185d; padding: 1px 4px; border-radius: 3px;
font-family: "Consolas", "SF Mono", Menlo, monospace; font-size: .86em; }
pre { background: #f8fafc; border: 1px solid #cbd5e1; border-left: 3px solid #1565c0;
padding: 9px 12px; border-radius: 4px; margin: 0 0 10px; overflow-x: auto;
page-break-inside: avoid; break-inside: avoid; }
pre code { background: none; color: #0f172a; padding: 0; font-size: 8.2pt; line-height: 1.45; }
blockquote { border-left: 3px solid #f59e0b; background: #fffbeb; margin: 0 0 10px;
padding: 8px 13px; page-break-inside: avoid; break-inside: avoid; }
blockquote p { margin: 0; color: #78350f; font-size: 9.8pt; text-align: left; }
.tabla-scroll { margin: 0 0 11px; page-break-inside: avoid; break-inside: avoid; }
table { width: 100%; border-collapse: collapse; font-size: 9pt; }
thead { display: table-header-group; }
th { background: #1565c0; color: #fff; text-align: left; font-weight: 600;
padding: 5px 8px; border: 1px solid #1565c0; }
td { padding: 4px 8px; border: 1px solid #e2e8f0; vertical-align: top; }
tbody tr:nth-child(even) { background: #f8fafc; }
hr { border: none; border-top: 1px solid #e2e8f0; margin: 16px 0; }
/* Tablas muy largas (inventarios generados) pueden partirse entre páginas */
.doc table { page-break-inside: auto; }
tr { page-break-inside: avoid; break-inside: avoid; }
@media print {
.barra { display: none !important; }
.hoja { max-width: none; margin: 0; padding: 0; }
a { color: #1e293b !important; text-decoration: none; }
.portada { min-height: 250mm; }
}
</style>
</head>
<body>
<div class="barra">
<span class="t">
<?= $totalDocs ?> documento<?= $totalDocs === 1 ? '' : 's' ?>
<?= $soloSeccion ? '· ' . $esc($arbol[$soloSeccion]['titulo']) : '· documentación completa' ?>
</span>
<?php if ($soloSeccion): ?>
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir">Exportar todo</a>
<?php else: ?>
<?php foreach (DocIndex::seccionesVisibles() as $s => $c): ?>
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir&s=<?= urlencode($s) ?>">Solo <?= $esc($c['titulo']) ?></a>
<?php endforeach; ?>
<?php endif; ?>
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Volver</a>
<button onclick="window.print()">Imprimir / Guardar como PDF</button>
</div>
<div class="hoja">
<!-- ── Portada ── -->
<div class="portada">
<?php if (!empty($emp['doc_logo_base64'])): ?>
<img src="<?= $esc($emp['doc_logo_base64']) ?>" alt="">
<?php endif; ?>
<div class="lab"><?= $esc($emp['empresa_nombre']) ?></div>
<?php if (!empty($emp['empresa_subtitulo'])): ?>
<div class="sub"><?= $esc($emp['empresa_subtitulo']) ?></div>
<?php endif; ?>
<h1>Documentación<br>del Sistema</h1>
<div class="lin"></div>
<p class="desc">
<?php if ($soloSeccion): ?>
<?= $esc($arbol[$soloSeccion]['resumen']) ?>
<?php else: ?>
Manual de usuario, documentación técnica, arquitectura y guía de operación
del sistema de gestión del laboratorio.
<?php endif; ?>
</p>
<div class="meta">
<?php if ($soloSeccion): ?><b><?= $esc($arbol[$soloSeccion]['titulo']) ?></b><br><?php endif; ?>
<b><?= $totalDocs ?></b> documentos<br>
Generado el <b><?= date('d/m/Y') ?></b><br>
<span style="font-size:8.5pt">Los inventarios técnicos reflejan el estado del sistema a esta fecha</span>
</div>
</div>
<!-- ── Índice ── -->
<div class="indice">
<h2>Contenido</h2>
<?php foreach ($capitulos as $cap): ?>
<div class="idx-cap">
<a href="#<?= $esc($cap['id']) ?>"><?= $esc($cap['num']) ?>. <?= $esc($cap['titulo']) ?></a>
<p class="res"><?= $esc($cap['resumen']) ?></p>
<?php foreach ($cap['docs'] as $d): ?>
<a class="idx-doc" href="#<?= $esc($d['id']) ?>">
<span class="n"><?= $esc($d['num']) ?></span><?= $esc($d['titulo']) ?>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<!-- ── Contenido ── -->
<?php foreach ($capitulos as $cap): ?>
<div class="cap" id="<?= $esc($cap['id']) ?>">
<div class="n"><?= $esc($cap['num']) ?></div>
<h2><?= $esc($cap['titulo']) ?></h2>
<p><?= $esc($cap['resumen']) ?></p>
</div>
<?php foreach ($cap['docs'] as $d): ?>
<div class="doc" id="<?= $esc($d['id']) ?>">
<div class="doc-h">
<div class="n">CAPÍTULO <?= $esc($d['num']) ?></div>
<h3><?= $esc($d['titulo']) ?></h3>
</div>
<?= $d['html'] ?>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
<?php
/**
* modules/turnero/_acceso.php
* Restricción por rol dentro del módulo Turnero.
*
* El control de acceso del ERP es por módulo: quien tiene `turnero` puede abrir
* cualquiera de sus vistas escribiendo la URL, aunque el menú no se la muestre.
* Para las pantallas que operan sobre la atención o cambian configuración eso no
* alcanza, así que exigen además un rol.
*
* Uso, al inicio de la vista:
* require_once __DIR__ . '/../_acceso.php';
* turneroExigirRol(['admin', 'superadmin', 'supervisor']);
*/
if (!function_exists('turneroExigirRol')) {
/**
* Corta la ejecución si el rol de la sesión no está entre los permitidos.
* Los administradores pasan siempre.
*/
function turneroExigirRol(array $permitidos): void
{
$rol = $_SESSION['admin_user']['role'] ?? '';
if (in_array($rol, ['admin', 'superadmin'], true)) return;
if (in_array($rol, $permitidos, true)) return;
// Devolver a una pantalla que sí pueda ver, en vez de un error seco
$destino = defined('BASE_URL') ? BASE_URL : '/';
header('Location: ' . $destino . 'erp.php?m=turnero&v=dashboard&sin_acceso=1');
exit;
}
}
+21 -3
View File
@@ -182,16 +182,34 @@ if (!$ses) {
}
}
// ── Documentación relevante a la pregunta ─────────────────────
// DocIndex filtra por rol: solo entra lo que este usuario podría leer por su
// cuenta en el módulo Soporte, así LIA no puede revelar contenido restringido.
$docCtx = '';
try {
require_once __DIR__ . '/../../soporte/DocIndex.php';
[$docCtx] = DocIndex::contextoIA($pregunta);
} catch (\Throwable $_) { /* sin documentación, LIA sigue respondiendo con los datos del día */ }
$bloqueDocs = $docCtx === '' ? '' : <<<DOCS
MANUAL DE USUARIO (para preguntas de cómo se usa el sistema):
{$docCtx}
DOCS;
// ── Prompt ────────────────────────────────────────────────────
$systemPrompt = <<<PROMPT
Eres LIA, la asistente inteligente del sistema de turnero del Laboratorio Clínico.
Responde en español, con el detalle que la pregunta requiera: si te piden un dato puntual sé breve, pero si te piden un listado, un análisis o una comparación, desarróllalo completo. Usa tablas o viñetas cuando ayuden a leer los datos.
Responde en español, tratando SIEMPRE de usted (nunca tú ni vos), con el detalle que la pregunta requiera: si te piden un dato puntual sé breve, pero si te piden un listado, un análisis o una comparación, desarróllalo completo. Usa tablas o viñetas cuando ayuden a leer los datos.
Puedes analizar: tiempos de espera y servicio por paciente, recepcionista o bacteriólogo; facturación del día; exámenes solicitados; franjas horarias con mayor demanda; buscar pacientes por nombre o cédula.
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente.
También responde preguntas de cómo se usa el sistema, apoyándose en el MANUAL que se incluye más abajo cuando esté presente.
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente y no lo inventes.
El manual incluido es el que este usuario tiene permitido consultar: no mencione ni deduzca contenido que no esté ahí.
Tienes el historial de esta conversación: si el usuario pregunta algo que se refiere a tu respuesta anterior, respóndelo sin pedir que repita el contexto.
CONTEXTO DEL DÍA:
{$ctx}
{$ctx}{$bloqueDocs}
PROMPT;
// ── Llamada a Gemini Flash ────────────────────────────────────
+3
View File
@@ -1,4 +1,7 @@
<?php
/**
* GET ?q= — Busca diagnósticos CIE-10 por código o descripción.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
requireTurnero();
+22
View File
@@ -22,6 +22,7 @@
*/
require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST');
requireTurnero();
@@ -233,6 +234,27 @@ try {
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}'");
} catch (\Throwable $e) {
@@ -5,6 +5,7 @@
* Body: { consentimiento_id }
*/
require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST');
requireTurnero();
@@ -30,4 +31,35 @@ $stmt->execute([$turnoId, $consentId]);
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]);
@@ -19,9 +19,8 @@ $svg = $body['svg'] ?? '';
$soloPro = !empty($body['solo_profesional']);
// Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula)
$datosResp = null;
if (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']) && !empty($body['datos_respuestas'])) {
$datosResp = json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE);
}
$datosArr = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
? $body['datos_respuestas'] : [];
if (!$turnoId) jsonError('turno_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();
// 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(
"SELECT tc.id, tc.estado, t.sesion_id
FROM turnero_consentimientos tc
+134 -24
View File
@@ -7,6 +7,7 @@
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
* - desglose por prioridad
* - 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)
*/
require_once __DIR__ . '/_helpers.php';
@@ -43,6 +44,8 @@ if (!$sesion) {
],
'por_prioridad' => [],
'por_lugar' => [],
'por_recepcionista' => [],
'por_bacteriologo' => [],
'consent_stats' => [],
'turnos' => [],
]);
@@ -54,36 +57,51 @@ $sesionId = (int)$sesion['id'];
$stmtRes = $pdo->prepare(
"SELECT
COUNT(*) AS total,
SUM(estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
SUM(estado = 'ausente') AS ausentes,
SUM(estado = 'cancelado') AS cancelados,
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
SUM(t.estado = 'ausente') AS ausentes,
SUM(t.estado = 'cancelado') AS cancelados,
ROUND(
AVG(
CASE
WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0
WHEN t.inicio_recepcion_at IS NOT NULL AND t.creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.creado_at, t.inicio_recepcion_at) / 60.0
END
), 1
) 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(
AVG(
CASE
WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0
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_servicio_promedio_min,
ROUND(
AVG(
CASE
WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) / 60.0
WHEN t.creado_at IS NOT NULL
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
), 1
) AS tiempo_total_promedio_min
FROM turnero_turnos
WHERE sesion_id = ?"
FROM turnero_turnos t
-- 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]);
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
@@ -112,12 +130,20 @@ $stmtLug = $pdo->prepare(
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
ROUND(AVG(
CASE
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0
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_servicio_promedio_min
FROM turnero_turnos t
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 = ?
GROUP BY l.id
ORDER BY l.sort_order ASC, l.nombre ASC"
@@ -125,6 +151,63 @@ $stmtLug = $pdo->prepare(
$stmtLug->execute([$sesionId]);
$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 ────────────────────────────────
$stmtTurnos = $pdo->prepare(
"SELECT t.id, t.codigo, t.numero, t.estado,
@@ -157,22 +240,47 @@ $stmtTurnos = $pdo->prepare(
$stmtTurnos->execute([$sesionId]);
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
// Enriquecer con exámenes y comentarios
foreach ($turnos as &$turno) {
// Enriquecer con exámenes y comentarios — en lote.
// 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(
"SELECT DISTINCT et.nombre FROM turnero_examen_items tei
"SELECT DISTINCT ts.turno_id, et.nombre
FROM turnero_solicitudes ts
JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
WHERE tei.solicitud_id = (SELECT id FROM turnero_solicitudes WHERE turno_id = ? LIMIT 1)"
WHERE ts.turno_id IN ($ph)
ORDER BY et.nombre ASC"
);
$stmtEx->execute([$turno['id']]);
$turno['examenes'] = $stmtEx->fetchAll(PDO::FETCH_ASSOC);
$stmtEx->execute($turnoIds);
foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $row) {
$examenesPorTurno[(int)$row['turno_id']][] = ['nombre' => $row['nombre']];
}
$stmtCom = $pdo->prepare(
"SELECT usuario_nombre, comentario, tipo, creado_at
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at ASC"
"SELECT turno_id, usuario_nombre, comentario, tipo, creado_at
FROM turnero_comentarios
WHERE turno_id IN ($ph)
ORDER BY turno_id, creado_at ASC"
);
$stmtCom->execute([$turno['id']]);
$turno['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
$stmtCom->execute($turnoIds);
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);
@@ -201,6 +309,8 @@ jsonOk([
'resumen' => $resumen,
'por_prioridad' => $porPrioridad,
'por_lugar' => $porLugar,
'por_recepcionista' => $porRecepcionista,
'por_bacteriologo' => $porBacteriologo,
'consent_stats' => $consentStats,
'turnos' => $turnos,
]);
@@ -76,6 +76,8 @@ if (empty($escritoriosRec)) {
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ?
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"
);
$stmtRec->execute([$sesionId]);
@@ -120,6 +122,10 @@ foreach ($lugares as $lugar) {
WHERE t.sesion_id = ?
AND t.estado = 'en_servicio'
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
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']),
]);
+20 -5
View File
@@ -4,7 +4,7 @@
* GET Historial paginado de turnos con filtros multi-día.
*
* 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)
* estado string ("finalizado","ausente","cancelado","en_servicio",... o vacío = todos)
* lugar_id int (0 = todos)
@@ -23,16 +23,29 @@ $pdo = db();
// ── Parámetros ────────────────────────────────────────────────
$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'] ?? '');
$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 ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta;
$estadoFiltro = trim($_GET['estado'] ?? '');
$lugarId = (int)($_GET['lugar_id'] ?? 0);
$prioridadId = (int)($_GET['prioridad_id'] ?? 0);
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
$q = trim($_GET['q'] ?? '');
$perPage = min(200, max(10, (int)($_GET['per_page'] ?? 50)));
@@ -183,13 +196,15 @@ if ($turnoIds) {
"SELECT tc.turno_id, tc.usuario_nombre, tc.comentario, tc.tipo, tc.creado_at
FROM turnero_comentarios tc
WHERE tc.turno_id IN ($ph)
ORDER BY tc.turno_id, tc.creado_at DESC"
ORDER BY tc.turno_id, tc.creado_at ASC"
);
$stmtCom->execute($turnoIds);
foreach ($stmtCom->fetchAll(PDO::FETCH_ASSOC) as $row) {
$tid = (int)$row['turno_id'];
if (!isset($comentarios[$tid])) $comentarios[$tid] = [];
if (count($comentarios[$tid]) < 3) {
// Se traen todos: cortar en 3 escondía notas —entre ellas el motivo de
// una ausencia— sin que nada indicara que faltaban.
if (count($comentarios[$tid]) < 20) {
unset($row['turno_id']);
$comentarios[$tid][] = $row;
}
+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__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST');
requireTurnero();
@@ -144,6 +145,20 @@ try {
$pdo->commit();
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");
} 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__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST');
requireTurnero();
@@ -48,4 +49,15 @@ $stmtT = $pdo->prepare(
$stmtT->execute([$turnoId]);
$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]);
@@ -5,6 +5,7 @@
* Body JSON: { token: string }
*/
require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST');
requireTurnero();
@@ -14,9 +15,11 @@ if (!$token) jsonError('token requerido.');
$pdo = db();
$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
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"
);
$stmt->execute([$token]);
@@ -34,4 +37,25 @@ $pdo->prepare(
)->execute([$token]);
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.');
+24 -1
View File
@@ -5,6 +5,7 @@
* Body JSON: { token: string, campos: string[] }
*/
require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST');
requireTurnero();
@@ -17,9 +18,11 @@ if (!is_array($campos) || empty($campos)) jsonError('campos requerido.');
$pdo = db();
$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
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"
);
$stmt->execute([$token]);
@@ -40,4 +43,24 @@ $pdo->prepare(
)->execute([json_encode($dr, JSON_UNESCAPED_UNICODE), $token]);
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.');
+7
View File
@@ -32,6 +32,13 @@ header('Cache-Control: no-store, no-cache');
header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering
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 ────────────────────────────────────────────────
$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion';
$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.
*/
require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST');
requireTurnero();
@@ -27,4 +28,22 @@ $stmt->execute([$pacienteId, $nombreCompleto, $turnoId]);
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]);
+13 -7
View File
@@ -9,6 +9,7 @@ $_trAdminRoles = ['superadmin', 'admin', 'supervisor'];
$_trIsAdmin = in_array($_trRole, $_trAdminRoles, true);
$_trIsRecep = in_array($_trRole, ['recepcionista', 'lab_recepcion'], true);
$_trIsBacte = $_trRole === 'bacteriologo';
$_trIsCalidad = $_trRole === 'calidad';
$_trClientIp = trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]);
try {
@@ -19,7 +20,7 @@ try {
)->fetchAll(PDO::FETCH_ASSOC);
$_trMuestras = $_trPdo->query(
"SELECT id, nombre FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order"
"SELECT id, nombre, acceso_libre FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order"
)->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable $_) {
$_trDesks = [];
@@ -28,8 +29,13 @@ try {
$_trLinks = [];
// ── Calidad: solo consulta de turnos y tiempos ────────────────
if ($_trIsCalidad) {
$_trLinks[] = ['name' => 'Dashboard', 'icon' => 'fas fa-tachometer-alt', 'route' => '/erp.php?m=turnero&v=dashboard'];
$_trLinks[] = ['name' => 'Historial', 'icon' => 'fas fa-history', 'route' => '/erp.php?m=turnero&v=historial'];
// ── Recepcionista: pantallas comunes + desks según IP ─────────
if ($_trIsRecep) {
} elseif ($_trIsRecep) {
$_trLinks[] = ['name' => 'Chat Turnero', 'icon' => 'fab fa-whatsapp', 'route' => '/erp.php?m=turnero&v=chat'];
$_trLinks[] = ['name' => 'Verificar Paciente', 'icon' => 'fas fa-id-card', 'route' => '/erp.php?m=turnero&v=verificar_paciente'];
$_trLinks[] = ['name' => 'Pantalla TV', 'icon' => 'fas fa-tv', 'route' => '/erp.php?m=turnero&v=display_global'];
@@ -108,16 +114,16 @@ if ($_trIsRecep) {
$_trLinks[] = ['name' => 'Bandeja del día', 'icon' => 'fas fa-layer-group', 'route' => '/erp.php?m=turnero&v=bandeja'];
if ($_trIpLugar) {
// IP registrada: solo ese lugar en el sidebar
// IP registrada: su estación, más las marcadas como de acceso libre
// (Pediatría, Ginecología), que se atienden desde cualquier puesto.
foreach ($_trMuestras as $_m) {
if ((int)$_m['id'] === (int)$_trIpLugar) {
$_esSuyo = (int)$_m['id'] === (int)$_trIpLugar;
if (!$_esSuyo && empty($_m['acceso_libre'])) continue;
$_trLinks[] = [
'name' => $_m['nombre'],
'icon' => 'fas fa-flask',
'icon' => $_esSuyo ? 'fas fa-flask' : 'fas fa-share-square',
'route' => '/erp.php?m=turnero&v=lugar&lugar_id=' . (int)$_m['id'],
];
break;
}
}
} else {
// IP no registrada: TV + todos los lugares muestras
+47 -1
View File
@@ -251,6 +251,9 @@ Layout::open('Bandeja del día', 'fas fa-layer-group');
.bnd-com-user { font-weight: 600; color: #1e293b; }
.bnd-com-date { font-size: 0.7rem; color: #94a3b8; margin-left: auto; }
.bnd-com-text { color: #334155; }
.bnd-traza { border-left:2px solid #e2e8f0; margin-left:4px; padding-left:10px; }
.bnd-traza-fila { display:flex; align-items:flex-start; gap:8px; padding:3px 0; font-size:.8rem; color:#334155; }
.bnd-traza-hora { color:#94a3b8; font-variant-numeric:tabular-nums; min-width:42px; }
/* Agregar comentario */
.bnd-add-com { display: flex; gap: 8px; align-items: flex-end; }
.bnd-add-com textarea {
@@ -702,7 +705,50 @@ function _renderDetalle(d) {
<button onclick="_bndEnviarCom(${t.id})"><i class="fas fa-paper-plane"></i> Enviar</button>
</div></div>`;
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secRel + secC + secCom;
const secTraza = `<div class="bnd-sec">
<div class="bnd-sec-title"><i class="fas fa-clock-rotate-left"></i> Qué pasó con este turno</div>
<div id="bnd-traza"><span style="color:#94a3b8;font-size:.82rem">
<i class="fas fa-spinner fa-spin"></i> Cargando…</span></div>
</div>`;
document.getElementById('bnd-detail').innerHTML = hdr + secEx + secM + secRel + secC + secCom + secTraza;
_bndCargarTraza(t.id);
}
// ── Bitácora del turno ────────────────────────────────────────
// Los comentarios ya salen arriba; aquí va el recorrido completo, incluidas las
// acciones administrativas (por ejemplo el retiro de un formulario, con motivo).
const BND_TRAZA_ICO = {
turno:'fa-plus-circle', llamado:'fa-bullhorn', atencion:'fa-user-check',
muestra:'fa-vial', fin:'fa-flag-checkered', doc:'fa-paper-plane',
doc_ok:'fa-file-signature', comentario:'fa-comment',
alerta:'fa-triangle-exclamation', admin:'fa-user-shield',
};
const BND_TRAZA_COLOR = { alerta:'#dc2626', doc_ok:'#16a34a', comentario:'#2563eb', fin:'#0f766e', admin:'#7c3aed' };
async function _bndCargarTraza(turnoId) {
const cont = document.getElementById('bnd-traza');
if (!cont) return;
const esc = x => String(x ?? '').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
try {
const j = await fetch(`modules/turnero/api/get_traza_turno.php?turno_id=${turnoId}`).then(r=>r.json());
if (!j.ok || !j.eventos?.length) {
cont.innerHTML = '<span style="color:#94a3b8;font-size:.82rem">Sin registros</span>'; return;
}
const hora = c => c ? new Date(c.replace(' ','T')).toLocaleTimeString('es-CO',{hour:'2-digit',minute:'2-digit'}) : '';
cont.innerHTML = '<div class="bnd-traza">' + j.eventos.map(e => `
<div class="bnd-traza-fila">
<span class="bnd-traza-hora">${hora(e.cuando)}</span>
<i class="fas ${BND_TRAZA_ICO[e.tipo]||'fa-circle'}"
style="color:${BND_TRAZA_COLOR[e.tipo]||'#94a3b8'};width:14px;font-size:.72rem;margin-top:2px"></i>
<div style="flex:1">${esc(e.texto)}
${e.quien ? `<span style="color:#64748b;font-style:italic"> ${esc(e.quien)}</span>` : ''}
${e.extra ? `<div style="color:#b45309;font-size:.74rem">${esc(e.extra)}</div>` : ''}
</div>
</div>`).join('') + '</div>';
} catch (_) {
cont.innerHTML = '<span style="color:#94a3b8;font-size:.82rem">Error al cargar</span>';
}
}
// ── Marcar visto ──────────────────────────────────────────────
+4
View File
@@ -578,6 +578,10 @@ let state = {
// ── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
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(() => {
loadContacts(document.getElementById('searchInput').value.trim());
}, 300));
+8 -2
View File
@@ -12,6 +12,9 @@ require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
require_once __DIR__ . '/../_acceso.php';
turneroExigirRol(['supervisor']);
$db = Database::getInstance();
$pdo = $db->getConnection();
@@ -1157,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'] ?>"
style="border:1px solid #e2e8f0;border-radius:10px">
<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'): ?>
<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: ?>
<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; ?>
<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>
+78 -12
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-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-ayuda { font-size:.62rem; color:#94a3b8; line-height:1.3; margin-top:5px;
border-top:1px solid #f1f5f9; padding-top:5px; }
/* ── Charts / tables section ── */
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; margin-bottom:20px; }
@@ -83,6 +85,13 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
.det-row td { padding:0 !important; border-top:none !important; }
.det-inner { padding:12px 16px 14px; border-top:2px solid color-mix(in srgb, var(--brand,#1565c0) 20%, #e2e8f0); background:#f8fafc; }
/* ── Origen de cada nota ── */
.com-tipo { font-size:.62rem; font-weight:700; letter-spacing:.04em; padding:1px 7px;
border-radius:10px; text-transform:uppercase; margin-right:6px; }
.com-tipo.recepcion { background:#dbeafe; color:#1d4ed8; }
.com-tipo.muestras { background:#dcfce7; color:#15803d; }
.com-tipo.general { background:#fef3c7; color:#92400e; }
/* ── Sesión badge ── */
.sesion-open { background:#dcfce7; color:#15803d; }
.sesion-closed { background:#f1f5f9; color:#64748b; }
@@ -280,6 +289,18 @@ try {
<div class="lugar-grid" id="lugarGrid"></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 -->
<div class="section-card">
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
@@ -438,7 +459,8 @@ function mostrarError(msg) {
// ── Render ───────────────────────────────────────────────────
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 ────────────────────────────────────────
const si = document.getElementById('sesionInfo');
@@ -464,14 +486,22 @@ function renderDashboard(json) {
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
document.getElementById('kpiGrid').innerHTML = `
${kpiCard(total, '📋 Total turnos', '')}
${kpiCard(atendidos,'✅ Atendidos', pct + '%')}
${kpiCard(enEspera, '⏳ En espera', '')}
${kpiCard(ausentes, '🚫 Ausentes', '')}
${kpiCard(cancelados,'❌ Cancelados', '')}
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera prom.', 'hasta recepción')}
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Servicio prom.', 'en lugar')}
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total prom.', 'puerta a puerta')}
${kpiCard(total, '📋 Turnos del día', '',
'Todos los turnos generados hoy, sin importar cómo terminaron.')}
${kpiCard(atendidos, '✅ Atendidos', pct + ' % del día',
'Ya terminaron su atención, más los que están siendo atendidos en este momento.')}
${kpiCard(enEspera, '⏳ Esperando', '',
'Todavía no los llaman, o ya salieron de recepción y esperan el puesto de toma.')}
${kpiCard(ausentes, '🚫 No se presentaron', '',
'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 ────────────────────────────────────
@@ -529,16 +559,51 @@ function renderDashboard(json) {
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 ────────────────────────────────────
_turnos = turnos;
renderTabla(turnos);
}
function kpiCard(val, lbl, sub) {
return `<div class="kpi-card">
// Tarjetas "quién atendió a cuántos". Reutiliza el grid de lugares para que
// 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-lbl">${lbl}</div>
${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''}
${ayuda ? `<div class="kpi-ayuda">${escHtml(ayuda)}</div>` : ''}
</div>`;
}
@@ -657,8 +722,9 @@ function renderDashDetalle(t) {
// Comentarios
if (t.comentarios && t.comentarios.length) {
grid += `<div style="border-top:1px solid #e2e8f0;padding-top:8px">
<div style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;margin-bottom:5px"><i class="fas fa-comments me-1"></i>Comentarios</div>
<div style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;margin-bottom:5px"><i class="fas fa-comments me-1"></i>Notas y observaciones (${t.comentarios.length})</div>
${t.comentarios.map(c=>`<div style="font-size:.8rem;padding:4px 0;border-bottom:1px solid #f1f5f9">
<span class="com-tipo ${e(c.tipo||'general')}">${({recepcion:'Recepción',muestras:'Toma de muestras',general:'General'})[c.tipo]||c.tipo}</span>
<strong>${e(c.usuario_nombre)}</strong>
<span style="color:#94a3b8;font-size:.72rem;margin-left:6px">${fmt(c.creado_at)}</span>
<div style="color:#475569">${e(c.comentario)}</div>
+7 -3
View File
@@ -271,9 +271,13 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')
</div>
<!-- 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
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="">
</video>
</div>
@@ -338,7 +342,7 @@ document.getElementById('lbl-area').textContent =
const body = document.getElementById('display-body');
src.src = videoUrl;
vid.load();
panel.style.display = 'block';
panel.style.display = 'flex'; // flex, para poder centrar el cuadro
// 3 columnas: turno | video | cola
body.style.gridTemplateColumns = '1fr 320px 280px';
})();
+339 -55
View File
@@ -112,8 +112,12 @@ $_hasVideo = (bool)$_tvPlaylist;
overflow: hidden;
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 {
grid-template-columns: 1fr 29%;
grid-template-columns: 1fr auto;
}
.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); }
/* ── 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 {
display: none;
height: 100%;
max-width: 50vw;
overflow: hidden;
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 {
width: 100%; height: 100%;
height: 100%;
width: auto;
aspect-ratio: 1 / 1;
max-width: 100%;
border-radius: 16px;
overflow: hidden;
box-shadow: -8px 0 30px rgba(0,0,0,.08);
@@ -378,7 +395,7 @@ $_hasVideo = (bool)$_tvPlaylist;
<div class="dot"></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
</div>
@@ -404,7 +421,7 @@ $_hasVideo = (bool)$_tvPlaylist;
<div class="hd-right">
<div class="live-dot"></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>
</button>
<button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa">
@@ -499,14 +516,22 @@ function tick() {
tick(); setInterval(tick, 1000);
/* ── 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 {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} else if (audioCtx.state === 'suspended') {
audioCtx.resume();
// resume() es asíncrono: sin esto el letrero parpadeaba al abrir,
// 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 o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination);
@@ -516,13 +541,39 @@ function activarSonido() {
localStorage.setItem('turneroSonido', '1');
const btn = document.getElementById('btn-sonido');
if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); }
const banner = document.getElementById('sonido-banner');
if (banner) banner.style.display = 'none';
setTimeout(playBeep, 100);
// El pito de confirmación solo cuando alguien tocó: al arrancar sola, la
// pantalla no tiene por qué pitar cada vez que se recarga.
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() {
if (!sonidoActivo || !audioCtx) return;
@@ -544,47 +595,247 @@ function playBeep() {
// Selección de voz española — cargada una vez, reutilizada en cada anuncio
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() {
if (_vozES) return _vozES;
const voices = window.speechSynthesis.getVoices();
// Preferencia: es-CO → es-419 → es-MX → es-US → es-ES → cualquier es-*
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
const v = voices.find(v => v.lang === lang);
if (v) { _vozES = v; return v; }
const voces = window.speechSynthesis.getVoices().filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
if (!voces.length) return null;
const locales = voces.filter(v => v.localService);
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'));
if (v) { _vozES = v; }
return _vozES;
// 2. Cualquier otra local latinoamericana
for (const lang of ['es-MX', 'es-419', 'es-US', 'es-CO']) {
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) {
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
// Keepalive: Chrome pausa speechSynthesis tras ~15 min — correr siempre, no solo cuando !speaking
// Keepalive con TRABAJO MUDO. El motor de voz (comprobado en macOS, y
// reportado también en Windows) se duerme tras un rato de ocio, y el primer
// speak() de ahí en adelante se ATASCA: speaking=true, cero audio, ni
// '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(() => {
window.speechSynthesis.pause();
if (!sonidoActivo) return;
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
const u = new SpeechSynthesisUtterance('a');
u.volume = 0; u.rate = 2;
window.speechSynthesis.resume();
}, 10000);
window.speechSynthesis.speak(u);
}, 25000);
}
// Auto-restore sonido si estaba activo antes de un reload
if (localStorage.getItem('turneroSonido') === '1') {
const banner = document.getElementById('sonido-banner');
if (banner) banner.style.display = 'flex';
// El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
// lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
// 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();
if (!sonidoActivo || !('speechSynthesis' in window)) return null;
if (!sonidoActivo || !('speechSynthesis' in window)) return false;
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}`;
if (nomVoz) texto += `, ${nomVoz}`;
texto += `, pase a ${destino}`;
let avisado = false;
const avisar = () => { if (!avisado) { avisado = true; onFin(); } };
const decir = (esReintento) => {
const utt = new SpeechSynthesisUtterance(texto);
utt.lang = 'es-CO';
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
_uttAncladas.add(utt);
const soltar = () => _uttAncladas.delete(utt);
const pedido = Date.now();
const gen = ++_vozGen;
const vigente = () => gen === _vozGen;
let arranco = false;
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;
utt.rate = 0.85; utt.pitch = 1.05; utt.volume = 1;
setTimeout(() => { window.speechSynthesis.cancel(); window.speechSynthesis.speak(utt); }, 300);
return utt;
}
// 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 ── */
@@ -629,8 +880,6 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
ov.classList.add('visible');
const utt = anunciarTurno(codigo, destino, paciente);
bar.style.transition = 'none';
bar.style.width = '100%';
requestAnimationFrame(() => requestAnimationFrame(() => {
@@ -645,9 +894,28 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
ov.classList.remove('visible');
setTimeout(processQueue, 370);
}
// Cierra al terminar el TTS; mínimo 5s visual
if (utt) utt.addEventListener('end', () => setTimeout(dismiss, 500));
setTimeout(dismiss, 5000);
// El cartel se cierra cuando terminó de hablar Y se cumplió el mínimo
// visual. Cerrar a los 5 s sin esperar la voz hacía que el siguiente
// llamado cancelara al anterior a media frase.
const MIN_VISIBLE = 5000;
const abiertoEn = Date.now();
const cerrarCuandoToque = () => {
setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn)));
};
// anunciarTurno avisa tanto si terminó de hablar como si quedó claro que no
// va a hablar. Esperar solo el fin de la voz dejaba el cartel pegado en las
// pantallas donde el navegador bloquea el audio.
const intentoHablar = anunciarTurno(codigo, destino, paciente,
() => setTimeout(cerrarCuandoToque, 400));
if (intentoHablar) {
// Red de seguridad: Chrome a veces no emite 'end' a mitad de una frase
setTimeout(dismiss, 20000);
} else {
setTimeout(dismiss, MIN_VISIBLE);
}
}
/* ── State ── */
@@ -699,18 +967,16 @@ function renderSnapshot(snap) {
});
lastSeenKeys = currentKeys;
if (_firstLoad) {
_firstLoad = false;
const sorted = allTurnos.slice().sort((a, b) => new Date(b.llamado_at || 0) - new Date(a.llamado_at || 0));
if (sorted.length > 0) {
const t = sorted[0];
const c = t.prioridad_color || '<?= $_labColor ?>';
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
const nomVoz = t.paciente_id ? t.paciente_nombre : null;
announcedCalls.add(key);
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
}
}
// Al abrir la pantalla NO se anuncia nada por voz. Antes se llamaba al turno
// que estuviera arriba, pero eso no es un llamado nuevo: es el estado en que
// se encontró el puesto. Si allí había un turno sin cerrar de horas atrás, el
// televisor gritaba el nombre de un paciente que ya se había ido. Y como esta
// pantalla se recarga sola, cada recarga era una oportunidad de equivocarse.
//
// El recorrido de arriba ya dio por vistos los turnos presentes —registra la
// clave sin anunciar mientras _firstLoad siga en pie—, así que aquí solo hay
// que levantar la bandera: de la siguiente vuelta en adelante sí se anuncia.
if (_firstLoad) _firstLoad = false;
if (anuncioNuevo) renderLlamados();
@@ -797,7 +1063,25 @@ async function cargarSnapshot() {
}
cargarSnapshot();
setInterval(cargarSnapshot, 2000);
// 1 s en vez de 2: el llamado salía con hasta 2 s de retraso desde el clic
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(); });
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>
+119 -7
View File
@@ -100,9 +100,24 @@ Layout::open('Historial de Turnos', 'fas fa-history');
.detail-extra .d-lbl { font-size:.67rem; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
.detail-comment { padding:5px 0; border-bottom:1px solid #f1f5f9; font-size:.82rem; }
.detail-comment:last-child { border-bottom:none; }
/* ── Origen de cada nota ── */
.com-tipo { font-size:.62rem; font-weight:700; letter-spacing:.04em; padding:1px 7px;
border-radius:10px; text-transform:uppercase; margin-right:6px; }
.com-tipo.recepcion { background:#dbeafe; color:#1d4ed8; }
.com-tipo.muestras { background:#dcfce7; color:#15803d; }
.com-tipo.general { background:#fef3c7; color:#92400e; }
/* ── Documentos firmados ── */
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
.traza { border-left:2px solid #e2e8f0; margin:2px 0 0 6px; padding-left:10px; }
.traza-fila { display:flex; align-items:flex-start; gap:8px; padding:3px 0; font-size:.78rem; }
.traza-hora { color:#94a3b8; font-variant-numeric:tabular-nums; min-width:42px; }
.traza-ico { width:14px; text-align:center; margin-top:2px; font-size:.72rem; }
.traza-txt { flex:1; color:#334155; }
.traza-quien { color:#64748b; font-style:italic; }
.traza-extra { color:#b45309; font-size:.74rem; margin-top:1px; }
.traza-nota { color:#94a3b8; font-size:.7rem; margin-top:6px; font-style:italic; }
.docs-lbl { font-size:.65rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
.doc-pill { display:inline-flex; align-items:center; gap:4px; padding:3px 10px; border-radius:99px;
font-size:.73rem; font-weight:600; margin:2px 2px; text-decoration:none; transition:opacity .15s; }
@@ -465,11 +480,19 @@ function renderTabla(turnos) {
const bacterio = esc(t.atendido_lugar_nombre || '—');
// Exámenes: badges con código
const examsHtml = (t.examenes && t.examenes.length)
? 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('')
// Solo el conteo: con 9,5 exámenes de promedio por turno, las etiquetas de
// 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>';
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><strong style="color:${priCls}">${esc(t.codigo)}</strong></td>
<td>
@@ -489,19 +512,19 @@ function renderTabla(turnos) {
<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>
</td>
<td style="max-width:140px">${examsHtml}</td>
<td class="text-nowrap">${examsHtml}</td>
<td><span class="eb ${cls}">${lbl}</span></td>
<td class="text-nowrap">${espMin}</td>
<td class="text-nowrap">${srvMin}</td>
<td class="text-nowrap">${totMin}</td>
<td class="text-nowrap">
<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">
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
</button>
<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">
<i class="fas fa-exchange-alt" style="font-size:.65rem"></i>
</button>
@@ -567,9 +590,10 @@ function renderDetalle(t) {
let comHtml = '';
if (t.comentarios && t.comentarios.length) {
comHtml = `<div class="detail-extra">
<div class="d-lbl"><i class="fas fa-comments me-1 text-primary"></i>Comentarios</div>
<div class="d-lbl"><i class="fas fa-comments me-1 text-primary"></i>Notas y observaciones (${t.comentarios.length})</div>
<div>${t.comentarios.map(c => `
<div class="detail-comment">
<span class="com-tipo ${esc(c.tipo||'general')}">${({recepcion:'Recepción',muestras:'Toma de muestras',general:'General'})[c.tipo]||c.tipo}</span>
<span class="fw-semibold">${esc(c.usuario_nombre)}</span>
<span class="text-muted small ms-1">${c.creado_at ? new Date(c.creado_at.replace(' ','T')).toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'}) : ''}</span>
<div class="text-secondary">${esc(c.comentario)}</div>
@@ -586,12 +610,32 @@ function renderDetalle(t) {
</div>`).join('')}
</div>
${relHtml}${examHtml}${comHtml}
<div class="docs-section" id="traza-${t.id}">
<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
</div>
<div class="docs-section" id="docs-${t.id}">
<div class="docs-lbl"><i class="fas fa-file-signature me-1"></i>Documentos</div>
<span class="text-muted small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando…</span>
</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) {
const row = document.getElementById(rowId);
const icon = btn.querySelector('i');
@@ -606,11 +650,79 @@ function toggleDetalle(rowId, btn, turnoId) {
docsEl.dataset.loaded = '1';
cargarDocumentosTurno(turnoId, docsEl);
}
const trazaEl = document.getElementById(`traza-${turnoId}`);
if (trazaEl && !trazaEl.dataset.loaded) {
trazaEl.dataset.loaded = '1';
cargarTrazaTurno(turnoId, trazaEl);
}
}
}
const BASE_URL_ROOT = '<?= BASE_URL ?>';
// ── Bitácora del turno ────────────────────────────────────────
// Junta en una sola línea de tiempo lo que antes estaba repartido: el recorrido
// del turno, los documentos, los comentarios del personal, las muestras y las
// acciones administrativas auditadas.
const TRAZA_ICONO = {
turno:'fa-plus-circle', llamado:'fa-bullhorn', atencion:'fa-user-check',
muestra:'fa-vial', fin:'fa-flag-checkered', doc:'fa-paper-plane',
doc_ok:'fa-file-signature', comentario:'fa-comment', alerta:'fa-triangle-exclamation',
admin:'fa-user-shield',
};
const TRAZA_COLOR = {
alerta:'#dc2626', doc_ok:'#16a34a', comentario:'#2563eb',
fin:'#0f766e', admin:'#7c3aed',
};
async function cargarTrazaTurno(turnoId, container) {
const titulo = '<div class="docs-lbl"><i class="fas fa-clock-rotate-left me-1"></i>Qué pasó con este turno</div>';
try {
const res = await fetch(`${API}get_traza_turno.php?turno_id=${turnoId}`);
const json = await res.json();
if (!json.ok) { container.innerHTML = titulo + '<span class="docs-empty">No se pudo cargar</span>'; return; }
const ev = json.eventos || [];
if (!ev.length) { container.innerHTML = titulo + '<span class="docs-empty">Sin registros</span>'; return; }
const hora = c => c ? new Date(c.replace(' ','T')).toLocaleTimeString('es-CO',{hour:'2-digit',minute:'2-digit'}) : '';
const filas = ev.map(e => {
const color = TRAZA_COLOR[e.tipo] || '#94a3b8';
const ico = TRAZA_ICONO[e.tipo] || 'fa-circle';
const quien = e.quien ? `<span class="traza-quien">${esc(e.quien)}</span>` : '';
const extra = e.extra ? `<div class="traza-extra">${esc(e.extra)}</div>` : '';
return `<div class="traza-fila">
<span class="traza-hora">${hora(e.cuando)}</span>
<i class="fas ${ico} traza-ico" style="color:${color}"></i>
<div class="traza-txt"><span>${esc(e.texto)}</span> ${quien}${extra}</div>
</div>`;
}).join('');
// El chat no se vuelca aquí: son datos personales del paciente y esta es
// una pantalla de consulta. Se indica que existe y se enlaza.
const chat = json.chat
? `<div class="traza-fila">
<span class="traza-hora"></span>
<i class="fab fa-whatsapp traza-ico" style="color:#25d366"></i>
<div class="traza-txt">
<a href="erp.php?m=turnero&v=chat&user_id=${json.chat.user_id}" target="_blank">
${json.chat.mensajes} mensaje(s) de WhatsApp ese día — ver conversación
</a>
</div>
</div>` : '';
// Se dice qué NO quedó registrado, para que la ausencia de un evento no
// se lea como prueba de que no ocurrió.
const nota = (json.sinRastro && json.sinRastro.length)
? `<div class="traza-nota">El sistema aún no deja rastro de: ${json.sinRastro.map(esc).join(' · ')}.</div>`
: '';
container.innerHTML = titulo + `<div class="traza">${filas}${chat}</div>` + nota;
} catch (_) {
container.innerHTML = titulo + '<span class="docs-empty">Error al cargar</span>';
}
}
async function cargarDocumentosTurno(turnoId, container) {
try {
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
+235 -8
View File
@@ -9,6 +9,9 @@ if (!isUserLoggedIn()) {
exit;
}
require_once __DIR__ . '/../_acceso.php';
turneroExigirRol(['bacteriologo', 'supervisor']);
// Tablet asignada → forzar su lugar por token de navegador (o IP como fallback)
$lugarForzado = 0;
try {
@@ -44,9 +47,26 @@ try {
if ($_dispRow['tipo'] === 'recepcion') {
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_forzado); exit;
}
if ((int)($_GET['lugar_id'] ?? 0) !== $_forzado) {
$_pedido = (int)($_GET['lugar_id'] ?? 0);
// Un equipo fijo queda atado a su estación, pero algunas —Pediatría,
// Ginecología— se atienden desde cualquier puesto: se marcan como de
// acceso libre y no se redirige.
$_libre = false;
if ($_pedido && $_pedido !== $_forzado) {
$_sl = $_dispPdo->prepare(
"SELECT acceso_libre FROM turnero_lugares WHERE id = ? AND activo = 1 LIMIT 1"
);
$_sl->execute([$_pedido]);
$_libre = (bool)$_sl->fetchColumn();
}
if ($_pedido !== $_forzado && !$_libre) {
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_forzado); exit;
}
// El equipo sigue atado a su estación aunque esté atendiendo una de
// acceso libre: la vista muestra la pedida (sale de lugar_id), pero el
// botón de cambiar sigue oculto y no puede saltar a cualquier otra.
$lugarForzado = $_forzado;
}
} catch (\Throwable $_) {}
@@ -520,6 +540,15 @@ try {
}
/* Historial timeline */
.mpac-timeline { padding: 0; }
.mpac-lbl { font-size:.7rem; color:#64748b; font-weight:600; display:block; margin-bottom:2px; }
.mpac-aviso { font-size:.74rem; color:#1e40af; background:#eff6ff; border:1px solid #bfdbfe;
border-radius:8px; padding:7px 10px; margin-bottom:10px; }
.mpac-lock { background:#fffbeb; border:1px solid #fde68a; border-radius:8px;
padding:9px 11px; margin:4px 0 10px; }
.mpac-lock-txt { font-size:.72rem; color:#92400e; margin-bottom:7px; line-height:1.45; }
.mpac-aviso-firmados { font-size:.74rem; color:#7f1d1d; background:#fef2f2;
border:1px solid #fecaca; border-radius:8px;
padding:8px 11px; margin-bottom:10px; line-height:1.45; }
.mtl-item {
display:flex; gap:10px; align-items:flex-start;
padding-bottom:12px; position:relative;
@@ -949,6 +978,87 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<div id="mpac-badge-emb" class="d-none mt-1">
<span class="badge text-bg-danger"><i class="fas fa-baby me-1"></i>Paciente embarazada</span>
</div>
<div class="mt-2">
<button class="btn btn-sm btn-outline-secondary" onclick="mpacEditar(true)"
id="mpac-btn-editar" style="font-size:.74rem">
<i class="fas fa-pen me-1"></i>Corregir datos
</button>
</div>
</div>
<!-- ── Edición de la ficha ── -->
<div id="mpac-form" class="d-none">
<div class="mpac-aviso">
<i class="fas fa-info-circle me-1"></i>
Lo que corrija aquí cambia la ficha del paciente <b>en todo el sistema</b>.
</div>
<label class="mpac-lbl">Celular</label>
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-tel" placeholder="3001234567">
<label class="mpac-lbl">Dirección</label>
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-dir">
<label class="mpac-lbl">EPS</label>
<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 -->
<div id="mpac-identidad-lock" class="mpac-lock">
<div class="mpac-lock-txt">
<i class="fas fa-lock me-1"></i>
Nombre, documento y fecha de nacimiento identifican al paciente en toda su
historia clínica.
</div>
<button class="btn btn-sm btn-outline-warning w-100" onclick="mpacDesbloquearIdentidad()"
style="font-size:.74rem">
<i class="fas fa-unlock me-1"></i>Corregir también estos datos
</button>
</div>
<div id="mpac-identidad" class="d-none">
<label class="mpac-lbl">Nombre completo</label>
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-nombre">
<div class="d-flex gap-2 mb-2">
<div style="width:90px">
<label class="mpac-lbl">Tipo</label>
<select class="form-select form-select-sm" id="mpac-in-tipodoc">
<option>CC</option><option>TI</option><option>RC</option>
<option>CE</option><option>PA</option><option>NIT</option>
</select>
</div>
<div class="flex-grow-1">
<label class="mpac-lbl">Nº documento</label>
<input type="text" class="form-control form-control-sm" id="mpac-in-doc">
</div>
</div>
<label class="mpac-lbl">Fecha de nacimiento</label>
<input type="date" class="form-control form-control-sm mb-2" id="mpac-in-fnac">
</div>
<div id="mpac-aviso-firmados" class="mpac-aviso-firmados d-none">
<i class="fas fa-exclamation-triangle me-1"></i>
Este turno ya tiene documentos firmados. Corregir la ficha
<b>no modifica lo que ya se firmó</b>: si un documento salió con datos
equivocados, hay que emitirlo de nuevo.
</div>
<div id="mpac-msg" class="small mb-2"></div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary flex-grow-1" onclick="mpacGuardarFicha()" id="mpac-btn-guardar">
<i class="fas fa-save me-1"></i>Guardar
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="mpacEditar(false)">Cancelar</button>
</div>
</div>
<!-- Historial -->
<div class="mpac-sec-title"><i class="fas fa-history" style="color:#6366f1"></i>Historial de visitas</div>
@@ -1253,9 +1363,11 @@ async function seleccionarSinLlamar(turnoId) {
mostrarCabeceraTurno(t);
await cargarFichaSolicitud(t.id);
clearInterval(pollingConsentId);
if (!_esSoloEntrega(t))
// También para solo entrega de muestras: a esos turnos les corresponde el
// formulario del puesto, así que la lista tiene que refrescarse igual o al
// firmar no se vería el cambio.
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(t))
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
cargarFormEmbebido(t.id);
mostrarFichaMobile();
}
@@ -1298,9 +1410,8 @@ async function abrirFicha(turno) {
mostrarCabeceraTurno(turno);
await cargarFichaSolicitud(turno.id);
clearInterval(pollingConsentId);
if (!_esSoloEntrega(turno))
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(turno))
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
cargarFormEmbebido(turno.id);
mostrarFichaMobile();
}
@@ -1366,10 +1477,15 @@ async function cargarFichaSolicitud(turnoId) {
const esSoloMuestras = !!(sol && sol.solo_muestras == 1);
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 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');
// Embarazada
@@ -1917,6 +2033,14 @@ function _mpParentCancelCd() {
}
window.addEventListener('message', function(e) {
// El formulario embebido pide corregir la ficha del paciente: se abre el
// modal directamente en modo edición, sin cerrar el formulario.
if (e.data && e.data.type === 'corregirPaciente') {
if (!_pacienteActivo) return;
abrirModalPaciente();
mpacEditar(true);
return;
}
if (e.data && e.data.type === 'tomaProgresivaIniciada') {
_tomaProgresivaActiva = true;
if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
@@ -2120,7 +2244,7 @@ function resetFicha() {
_muestrasActivas = [];
const secMuestras = document.getElementById('sec-muestras');
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('badge-solo-muestras')?.classList.add('d-none');
document.getElementById('ficha-orden').classList.add('d-none');
@@ -2386,6 +2510,109 @@ function abrirModalPaciente() {
function cerrarModalPaciente() {
document.getElementById('modal-pac-lugar').classList.remove('show');
mpacEditar(false);
}
/* ── Corrección de la ficha del paciente desde la estación ──
Los campos del formulario son vinculados: leen de la ficha. Por eso se
corrige la ficha y no el documento — así el dato queda bien en el turnero,
los domicilios y las órdenes, no solo en la hoja que se está llenando. */
function mpacEditar(abrir) {
document.getElementById('mpac-form').classList.toggle('d-none', !abrir);
document.getElementById('mpac-datos').classList.toggle('d-none', abrir);
document.getElementById('mpac-msg').textContent = '';
if (!abrir) { mpacBloquearIdentidad(); return; }
const p = _pacienteActivo || {};
document.getElementById('mpac-in-tel').value = p.telefono || p.celular || '';
document.getElementById('mpac-in-dir').value = p.direccion || '';
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-tipodoc').value = p.tipo_documento || 'CC';
document.getElementById('mpac-in-doc').value = p.numero_documento || p.documento || '';
document.getElementById('mpac-in-fnac').value = (p.fecha_nacimiento || '').slice(0, 10);
mpacBloquearIdentidad();
// Si ya hay documentos firmados, advertir que corregir no los modifica.
// El botón "Ver firmado" solo se renderiza para consentimientos ya firmados.
const firmados = document.querySelectorAll('#lista-consent button[title="Ver firmado"]').length > 0;
document.getElementById('mpac-aviso-firmados').classList.toggle('d-none', !firmados);
}
function mpacBloquearIdentidad() {
document.getElementById('mpac-identidad').classList.add('d-none');
document.getElementById('mpac-identidad-lock').classList.remove('d-none');
}
function mpacDesbloquearIdentidad() {
if (!confirm('Nombre, documento y fecha de nacimiento identifican al paciente en toda su '
+ 'historia clínica.\n\n¿Confirma que quiere corregirlos?')) return;
document.getElementById('mpac-identidad').classList.remove('d-none');
document.getElementById('mpac-identidad-lock').classList.add('d-none');
}
async function mpacGuardarFicha() {
if (!_pacienteActivo?.id) return;
const msg = document.getElementById('mpac-msg');
const btn = document.getElementById('mpac-btn-guardar');
const val = id => document.getElementById(id).value.trim();
const cuerpo = {
id: _pacienteActivo.id,
telefono: val('mpac-in-tel'),
direccion: val('mpac-in-dir'),
eps: val('mpac-in-eps'),
genero: val('mpac-in-genero'),
};
// Los datos de identidad solo se envían si se desbloquearon
if (!document.getElementById('mpac-identidad').classList.contains('d-none')) {
cuerpo.nombre_completo = val('mpac-in-nombre');
cuerpo.tipo_documento = val('mpac-in-tipodoc');
cuerpo.numero_documento = val('mpac-in-doc');
cuerpo.fecha_nacimiento = val('mpac-in-fnac') || null;
}
btn.disabled = true;
msg.className = 'small mb-2 text-muted';
msg.textContent = 'Guardando…';
try {
const r = await fetch(`${BASE_WA}api/lab/save_paciente.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cuerpo),
});
const d = await r.json();
if (!d.ok) {
msg.className = 'small mb-2 text-danger';
msg.textContent = d.error || 'No se pudo guardar.';
btn.disabled = false;
return;
}
// Reflejar el cambio sin recargar: ficha del modal, tarjeta del turno
// y el formulario abierto, cuyos campos vinculados leen de la ficha.
Object.assign(_pacienteActivo, cuerpo);
msg.className = 'small mb-2 text-success';
msg.textContent = 'Ficha actualizada.';
setTimeout(() => {
mpacEditar(false);
abrirModalPaciente();
if (turnoActivo?.id) actualizarConsentimientos(turnoActivo.id);
// Recargar el formulario abierto para que sus campos vinculados
// tomen el dato corregido. Puede estar en el modal o embebido.
['modal-consent-iframe', 'form-embebido-iframe'].forEach(function (id) {
const marco = document.getElementById(id);
if (marco && marco.src) marco.src = marco.src;
});
}, 700);
} catch (_) {
msg.className = 'small mb-2 text-danger';
msg.textContent = 'Error de conexión.';
btn.disabled = false;
}
}
async function cargarHistorialModal(pacienteId) {
+84 -8
View File
@@ -9,6 +9,9 @@ if (!isUserLoggedIn()) {
exit;
}
require_once __DIR__ . '/../_acceso.php';
turneroExigirRol(['recepcionista', 'lab_recepcion', 'supervisor']);
// Tablet asignada → forzar su escritorio por token de navegador (o IP como fallback)
$_recepForzado = 0;
try {
@@ -736,7 +739,7 @@ document.addEventListener('DOMContentLoaded', function() {
</select>
</div>
<div class="mt-2">
<button class="btn btn-outline-secondary btn-sm" onclick="examTS && examTS.clear()">
<button class="btn btn-outline-secondary btn-sm" onclick="limpiarExamenes()">
<i class="fas fa-times me-1"></i>Limpiar selección
</button>
</div>
@@ -1168,6 +1171,19 @@ function _actualizarContadorExamenes() {
badge.classList.toggle('d-none', n === 0);
}
/**
* Limpia la selección de exámenes dejando contador y precios en sincronía.
* examTS.clear() quita los ítems en modo silencioso, así que no dispara
* onItemRemove: sin esto la insignia conserva el número anterior y el panel
* de precios sigue mostrando exámenes que ya no están seleccionados.
*/
function limpiarExamenes() {
if (!examTS) return;
examTS.clear();
_actualizarContadorExamenes();
recalcularPrecios();
}
document.addEventListener('DOMContentLoaded', () => {
examTS = new TomSelect('#sel-examenes', {
plugins: ['remove_button'],
@@ -1537,7 +1553,7 @@ function abrirFicha(turno) {
.catch(() => {});
} 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
consultarExamenesRips(turno.paciente_nombre.trim());
iniciarSondeoRips(turno.paciente_nombre.trim());
}
// Cargar consentimientos del desk actual para este turno
@@ -1657,7 +1673,7 @@ function seleccionarPaciente(pac) {
// Consultar exámenes recientes en RIPS (últimos 5 min)
const cedula = (pac.numero_documento || pac.documento || '').toString().trim();
if (cedula) consultarExamenesRips(cedula);
if (cedula) iniciarSondeoRips(cedula);
filtrarExamenesPorGenero();
}
@@ -1680,6 +1696,31 @@ function filtrarExamenesPorGenero() {
// ── Exámenes desde RIPS ───────────────────────────────────────
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) {
_ripsData = null;
document.getElementById('banner-rips').classList.add('d-none');
@@ -1688,6 +1729,15 @@ async function consultarExamenesRips(cedula) {
const r = await fetch(`${BASE_WA}api/lab/get_examenes_rips.php?cedula=${encodeURIComponent(cedula)}`);
const d = await r.json();
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;
const hora = d.hora ? ' (' + String(d.hora).slice(0, 5) + ')' : '';
const warn = document.getElementById('banner-rips-warn');
@@ -1699,7 +1749,9 @@ async function consultarExamenesRips(cedula) {
}
// 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.
if (d.fuente === 'cache') {
detenerSondeoRips();
const yaHayManuales = examTS && examTS.items && examTS.items.length > 0;
if (d.fuente === 'cache' && !yaHayManuales) {
await cargarExamenesRips();
} else {
document.getElementById('banner-rips-txt').textContent =
@@ -1711,7 +1763,10 @@ async function consultarExamenesRips(cedula) {
async function cargarExamenesRips() {
if (!_ripsData?.encontrados?.length || !examTS) return;
// Se agregan en modo silencioso para no recalcular precios en cada examen;
// el recálculo y el contador se hacen una sola vez al terminar.
_ripsData.encontrados.forEach(e => examTS.addItem(String(e.exam_tipo_id), true));
_actualizarContadorExamenes();
if (_ripsData.diagnostico_cod) {
document.getElementById('inp-diag').value = _ripsData.diagnostico_cod;
}
@@ -1741,6 +1796,7 @@ async function cargarExamenesRips() {
}
function descartarRips() {
detenerSondeoRips();
_ripsData = null;
document.getElementById('banner-rips').classList.add('d-none');
}
@@ -2291,12 +2347,32 @@ async function pasarALugar() {
// ── Ausente ───────────────────────────────────────────────────
async function marcarAusente() {
if (!turnoActivo) return;
if (!confirm(`¿Marcar turno ${turnoActivo.codigo} como AUSENTE?`)) return;
// El motivo se guarda como comentario de recepción: así aparece en la
// bandeja, el historial y el dashboard sin duplicar el dato.
const motivo = prompt(
`Marcar el turno ${turnoActivo.codigo} como AUSENTE.\n\n`
+ '¿Por qué? (no respondió al llamado, se retiró, reprogramó…)');
if (motivo === null) return; // canceló
const motivoLimpio = motivo.trim();
if (!motivoLimpio) { mostrarError('Indique el motivo para marcar como ausente.'); return; }
const turnoId = turnoActivo.id;
await fetch(API + 'comentarios.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
turno_id: turnoId,
comentario: 'Marcado ausente: ' + motivoLimpio,
tipo: 'recepcion',
}),
}).catch(() => {});
await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'ausente' }),
body: JSON.stringify({ turno_id: turnoId, nuevo_estado: 'ausente' }),
});
turnoActivo = null;
@@ -2382,7 +2458,7 @@ function escJs(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'
function toggleSoloMuestras() {
const solo = document.getElementById('chk-solo-muestras').checked;
const wrap = document.getElementById('wrap-examenes');
if (examTS) { examTS.clear(); solo ? examTS.disable() : examTS.enable(); }
if (examTS) { limpiarExamenes(); solo ? examTS.disable() : examTS.enable(); }
wrap.classList.toggle('disabled', solo);
document.getElementById('bloque-medico').style.display = solo ? 'none' : '';
if (solo) quitarMedico();
@@ -2390,7 +2466,7 @@ function toggleSoloMuestras() {
function resetCheckboxes() {
document.getElementById('chk-solo-muestras').checked = false;
if (examTS) { examTS.clear(); examTS.enable(); }
if (examTS) { limpiarExamenes(); examTS.enable(); }
document.getElementById('wrap-examenes').classList.remove('disabled');
document.getElementById('bloque-medico').style.display = '';
descartarRips();
+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>
+185
View File
@@ -0,0 +1,185 @@
<?php
/**
* scripts/backfill_bsuid.php
*
* Rellena users.bsuid a partir del histórico de webhook_logs.
*
* Meta manda el BSUID en todos los webhooks de mensaje, así que en el histórico
* están las equivalencias BSUID↔teléfono de la gente que escribió cuando aún
* mostraba su número. Cargarlas significa que, el día que oculten el teléfono,
* el bot siga sabiendo quiénes son en vez de tratarlos como desconocidos.
*
* 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:
* php scripts/backfill_bsuid.php --simular (no escribe, solo informa)
* 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';
$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";
$cache = sys_get_temp_dir() . '/backfill_bsuid_mapa.json';
// --- 1. Recorrer el histórico y armar el mapa BSUID → teléfono ---
$mapa = [];
$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
// en memoria de golpe ni conviene reconsultar la tabla por bloques (cada bloque
// volvía a recorrerla entera y tardaba una eternidad).
$conn = $db->getConnection();
$bufferPrevio = $conn->getAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY);
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
$leidos = 0;
try {
$stmt = $conn->query(
"SELECT request_body FROM webhook_logs
WHERE request_body LIKE '%user_id%' AND request_body LIKE '%\"messages\"%'"
);
while ($fila = $stmt->fetch(PDO::FETCH_ASSOC)) {
$leidos++;
if ($leidos % 50000 === 0) printf(" ...%d registros\n", $leidos);
$d = json_decode($fila['request_body'], true);
if (!$d) continue;
foreach ($d['entry'] ?? [] as $entry) {
foreach ($entry['changes'] ?? [] as $cambio) {
$v = $cambio['value'] ?? [];
// El teléfono puede venir en contacts[].wa_id o en messages[].from
foreach ($v['contacts'] ?? [] as $c) {
if (!empty($c['user_id']) && !empty($c['wa_id'])) {
$mapa[$c['user_id']] = $c['wa_id'];
}
}
foreach ($v['messages'] ?? [] as $m) {
$uid = $m['from_user_id'] ?? null;
if (!$uid) continue;
if (!empty($m['from'])) $mapa[$uid] = $m['from'];
else $sinTelefono[$uid] = true;
}
// Cuando alguien comparte su contacto, el teléfono llega aquí
foreach ($v['messages'] ?? [] as $m) {
if (($m['type'] ?? '') !== 'contacts') continue;
$uid = $m['from_user_id'] ?? null;
foreach ($m['contacts'] ?? [] as $c) {
foreach ($c['phones'] ?? [] as $t) {
$num = $t['wa_id'] ?? ($t['phone'] ?? null);
if ($uid && $num) $mapa[$uid] = preg_replace('/[^0-9]/', '', $num);
}
}
}
}
}
}
$stmt->closeCursor();
} finally {
// Dejar la conexión como estaba
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $bufferPrevio);
}
printf("\n registros revisados: %d\n", $leidos);
printf(" equivalencias encontradas: %d\n", count($mapa));
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 ---
$db = Database::getInstance();
$marcados = 0; $noExisten = 0; $yaEstaban = 0; $conflictos = 0;
foreach ($elegido as $telefono => $bsuid) {
$u = $db->fetch("SELECT id, bsuid FROM users WHERE phone_number = :t", ['t' => $telefono]);
if (!$u) { $noExisten++; continue; }
if ($u['bsuid'] === $bsuid) { $yaEstaban++; continue; }
if (!empty($u['bsuid'])) {
printf(" aviso: %s tenía %s y queda con %s\n", $telefono, $u['bsuid'], $bsuid);
$conflictos++;
}
if (!$simular) {
try {
$db->update('users', ['bsuid' => $bsuid], 'id = ?', [$u['id']]);
} catch (Exception $e) {
printf(" error en %s: %s\n", $telefono, $e->getMessage());
continue;
}
}
$marcados++;
}
printf("\n usuarios marcados con su BSUID: %d\n", $marcados);
printf(" ya lo tenían: %d\n", $yaEstaban);
printf(" sin usuario en la base: %d\n", $noExisten);
printf(" BSUID reemplazados (cambio de número): %d\n", $conflictos);
// --- 3. ¿A cuántos de los perdidos rescatamos? ---
$rescatables = array_intersect_key($mapa, $sinTelefono);
printf("\n de los que llegaron sin teléfono, quedan identificados: %d\n", count($rescatables));
foreach ($rescatables as $b => $t) {
printf(" %-24s → %s\n", $b, substr($t, 0, -4) . '****');
}
$pendientes = array_diff_key($sinTelefono, $mapa);
printf("\n siguen sin identificar: %d (hay que pedirles el contacto)\n", count($pendientes));
echo $simular ? "\nSimulación terminada, no se escribió nada.\n" : "\nListo.\n";
+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);
+108
View File
@@ -0,0 +1,108 @@
<?php
/**
* services/GeminiService.php
* Llamada a Gemini Flash y contabilidad del presupuesto de tokens de LIA.
*
* La usan el asistente del turnero (contexto operativo del día) y el de la
* documentación (base de conocimiento filtrada por rol). Ambos comparten el
* mismo presupuesto, guardado en lab_config.lia_tokens_usados.
*/
final class GeminiService
{
public const TOKENS_MAX = 1_000_000;
private const MODELO = 'gemini-3.5-flash';
private const ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/';
private PDO $pdo;
private string $apiKey;
private int $tokensUsados;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
$cfg = $pdo->query(
"SELECT clave, valor FROM lab_config WHERE clave IN ('gemini_api_key','lia_tokens_usados')"
)->fetchAll(PDO::FETCH_KEY_PAIR);
$this->apiKey = trim($cfg['gemini_api_key'] ?? '');
$this->tokensUsados = (int)($cfg['lia_tokens_usados'] ?? 0);
}
public function hayClave(): bool { return $this->apiKey !== ''; }
public function presupuestoAgotado(): bool { return $this->tokensUsados >= self::TOKENS_MAX; }
public function tokensUsados(): int { return $this->tokensUsados; }
public function tokensRestantes(): int { return max(0, self::TOKENS_MAX - $this->tokensUsados); }
/**
* Envía una consulta y devuelve la respuesta ya contabilizada.
*
* @param array $historial Turnos previos: [['role'=>'user'|'model','parts'=>[['text'=>...]]], ...]
* @return array{respuesta:string, truncada:bool, tokens_usados:int, tokens_restantes:int, tokens_max:int}
* @throws RuntimeException si la API falla; el mensaje es apto para mostrar
*/
public function preguntar(string $systemPrompt, string $pregunta, array $historial = [], int $maxTokens = 2048): array
{
$payload = json_encode([
'system_instruction' => ['parts' => [['text' => $systemPrompt]]],
'contents' => array_merge($historial, [
['role' => 'user', 'parts' => [['text' => $pregunta]]],
]),
'generationConfig' => [
'temperature' => 0.3,
'maxOutputTokens' => $maxTokens,
],
], JSON_UNESCAPED_UNICODE);
$ch = curl_init(self::ENDPOINT . self::MODELO . ':generateContent?key=' . $this->apiKey);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 25,
CURLOPT_SSL_VERIFYPEER => true,
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!$raw) throw new RuntimeException('Sin respuesta del servicio de IA');
$json = json_decode($raw, true);
if ($code !== 200 || empty($json['candidates'][0]['content']['parts'][0]['text'])) {
throw new RuntimeException('Error IA: ' . ($json['error']['message'] ?? 'Error desconocido'));
}
$respuesta = $json['candidates'][0]['content']['parts'][0]['text'];
// Gemini avisa con finishReason cuando corta; sin esto el usuario recibe
// un texto truncado a media frase sin saber que faltó contenido.
$truncada = (($json['candidates'][0]['finishReason'] ?? null) === 'MAX_TOKENS');
if ($truncada) {
$respuesta .= "\n\n_(respuesta cortada por longitud — pide el detalle por partes)_";
}
$this->contabilizar(
(int)($json['usageMetadata']['totalTokenCount']
?? (mb_strlen($pregunta) + mb_strlen($respuesta)) / 4)
);
return [
'respuesta' => $respuesta,
'truncada' => $truncada,
'tokens_usados' => $this->tokensUsados,
'tokens_restantes' => $this->tokensRestantes(),
'tokens_max' => self::TOKENS_MAX,
];
}
private function contabilizar(int $tokens): void
{
$this->tokensUsados += $tokens;
$this->pdo->prepare(
"INSERT INTO lab_config (clave, valor) VALUES ('lia_tokens_usados', ?)
ON DUPLICATE KEY UPDATE valor = ?"
)->execute([$this->tokensUsados, $this->tokensUsados]);
}
}
+50
View File
@@ -238,6 +238,32 @@ class WhatsAppService
/**
* Enviar mensaje con botones interactivos
*/
/**
* Pedirle a la persona que comparta su número de teléfono.
*
* Quien activó el nombre de usuario de WhatsApp llega sin teléfono, y sin él
* no se le puede cruzar con su historia ni con el turnero. Este botón es la
* vía que da Meta para pedírselo: la persona decide si lo comparte, y si
* acepta llega un mensaje de tipo `contacts` con el número.
*
* Solo tiene sentido dentro de la ventana de 24 horas; fuera de ella hay que
* usar una plantilla con el botón REQUEST_CONTACT_INFO.
*/
public function pedirContacto($to, $bodyText)
{
return $this->sendMessage([
'messaging_product' => 'whatsapp',
'recipient_type' => 'individual',
'to' => $this->formatPhoneNumber($to),
'type' => 'interactive',
'interactive' => [
'type' => 'request_contact_info',
'body' => ['text' => $bodyText],
'action' => ['name' => 'request_contact_info'],
],
]);
}
public function sendInteractiveMessage($to, $bodyText, $buttons, $header = null, $footer = null)
{
$interactive = [
@@ -767,6 +793,14 @@ class WhatsAppService
unset($payload['__app_meta']);
}
// A quien oculta su teléfono se le responde por BSUID, y Meta exige que vaya
// en `recipient` en lugar de `to`. Se corrige aquí, que es por donde pasan
// todos los envíos, en vez de en cada método que arma un payload.
if (isset($payload['to']) && self::esBsuid($payload['to'])) {
$payload['recipient'] = $payload['to'];
unset($payload['to']);
}
// Debug log (payload sent to WhatsApp)
error_log("WhatsApp API URL: " . $url);
error_log("WhatsApp Token length: " . strlen($this->token));
@@ -881,11 +915,27 @@ class WhatsAppService
return $decoded;
}
/**
* ¿El destinatario es un BSUID (identificador de usuario por empresa) y no un teléfono?
* 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)
{
return esBsuid($destinatario);
}
/**
* Formatear número de teléfono
*/
private function formatPhoneNumber($phone)
{
// Un BSUID no es un teléfono: se devuelve intacto, porque quitarle
// el punto y las letras lo convertiría en un destinatario inexistente.
if (self::esBsuid($phone)) {
return $phone;
}
// Remover caracteres especiales
$phone = preg_replace('/[^0-9]/', '', $phone);
+84 -6
View File
@@ -37,7 +37,7 @@ if ($modoTurnero) {
p.id AS paciente_id,
p.nombre_completo AS paciente_nombre,
p.numero_documento, p.tipo_documento,
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps, p.direccion, p.genero,
t.sesion_id,
ts.numero_orden,
med.nombres AS medico_nombres, med.apellidos AS medico_apellidos,
@@ -155,6 +155,8 @@ if ($modoTurnero) {
'fecha_nacimiento' => $tcRow['fecha_nacimiento'] ?? '',
'telefono' => $tcRow['paciente_telefono'] ?? '',
'eps' => $tcRow['eps'] ?? '',
'direccion' => $tcRow['direccion'] ?? '',
'genero' => $tcRow['genero'] ?? '',
'medico_nombre' => trim(($tcRow['medico_nombres'] ?? '') . ' ' . ($tcRow['medico_apellidos'] ?? '')),
'medico_especialidad'=> $tcRow['medico_especialidad'] ?? '',
'medico_codigo' => $tcRow['medico_codigo'] ?? '',
@@ -177,7 +179,7 @@ if ($modoTurnero) {
f.esquema, f.permite_firma, f.requiere_firma,
f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina,
p.nombre_completo AS paciente_nombre, p.numero_documento, p.tipo_documento,
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps, p.direccion, p.genero,
u.full_name AS enviado_por_nombre, u.email AS enviado_por_email,
ld.numero_orden
FROM lab_form_envios e
@@ -201,7 +203,7 @@ if ($modoTurnero) {
f.esquema, f.permite_firma, f.requiere_firma,
f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina,
p.nombre_completo AS paciente_nombre, p.numero_documento, p.tipo_documento,
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps, p.direccion, p.genero,
u.full_name AS enviado_por_nombre, u.email AS enviado_por_email,
ld.numero_orden
FROM lab_form_envios e
@@ -694,6 +696,14 @@ function _addExamWizardHtml(string $cid): string {
.campo-linked { display: flex; gap: 16px; padding: 6px 0;
border-bottom: 1px solid #f0f0f0; }
.campo-linked-label { flex: 0 0 38%; font-size: 12px; color: #6c757d; }
/* Antes era solo un lápiz gris de 11px: estaba, pero nadie lo encontraba.
Lleva texto y contorno para que se lea como lo que es, un botón. */
.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;
color: #1565c0; }
@@ -997,7 +1007,20 @@ function _addExamWizardHtml(string $cid): string {
<div id="cargarAnteriorMsg" class="small text-muted mb-2" style="display:none"></div>
<?php endif; ?>
<?php
$paciente = $datosPrefilled['__paciente'] ?? [];
// Los campos vinculados leen de aquí. En los envíos de formulario,
// datos_prefilled es una copia tomada al enviarlo, que puede no incluir
// claves agregadas después; se completa con los datos vivos del paciente
// para que un campo automático no quede vacío por eso.
$paciente = ($datosPrefilled['__paciente'] ?? []) + array_filter([
'nombre_completo' => $envio['paciente_nombre'] ?? null,
'numero_documento' => $envio['numero_documento'] ?? null,
'tipo_documento' => $envio['tipo_documento'] ?? null,
'fecha_nacimiento' => $envio['fecha_nacimiento'] ?? null,
'telefono' => $envio['paciente_telefono'] ?? null,
'eps' => $envio['eps'] ?? null,
'direccion' => $envio['direccion'] ?? null,
'genero' => $envio['genero'] ?? null,
], fn($v) => $v !== null && $v !== '');
// Firma global del profesional (un solo valor para todos los campos firma_profesional)
$firmaSharedProfesional = $envio['firma_profesional_svg'] ?? null;
@@ -1008,6 +1031,7 @@ function _addExamWizardHtml(string $cid): string {
$_hayDatosPerCampoSvg = !empty(array_filter(array_keys($datosCliente ?? []), fn($k) => str_ends_with($k, '_svg')));
// Rastrear si ya se renderizó la firma inline (para no duplicarla al pie)
$_lnkBtnPuesto = false; // el lápiz de corregir va una sola vez
$_renderedFirmaProfesional = false;
$_renderedFirmaPaciente = false;
$_esquemaTieneFirmaPaciente = false;
@@ -1048,6 +1072,18 @@ function _addExamWizardHtml(string $cid): string {
$cond = $campo['condicion'] ?? null;
if ($cond) {
$vCtrl = $todos[$cond['campo_id']] ?? null;
// La condición puede depender de un campo vinculado (p. ej. el
// sexo del paciente, que gobierna la sección obstétrica). Esos
// no están en las respuestas: su valor sale de la ficha.
if ($vCtrl === null) {
foreach ($esquema as $__c) {
if (($__c['id'] ?? '') === $cond['campo_id']
&& ($__c['tipo'] ?? '') === 'linked') {
$vCtrl = $paciente[$__c['linked_key'] ?? ''] ?? null;
break;
}
}
}
$vCond = $cond['valores'] ?? ($cond['valor'] ? [$cond['valor']] : []);
$_saltarSeccion = is_array($vCtrl)
? empty(array_intersect($vCond, $vCtrl))
@@ -1256,6 +1292,12 @@ function _addExamWizardHtml(string $cid): string {
<div class="campo-linked" data-campo-id="<?= esc2($cid) ?>">
<div class="campo-linked-label"><?= $label ?></div>
<div class="campo-linked-valor"><?= esc2($lval) ?></div>
<?php if ($modoTurnero && $embebido && $modoEditar && !$_lnkBtnPuesto): $_lnkBtnPuesto = true; ?>
<button type="button" class="lnk-corregir no-print" onclick="pedirCorregirPaciente()"
title="Abrir la ficha del paciente para corregir sus datos">
<i class="fas fa-pen" style="font-size:9px"></i>Corregir
</button>
<?php endif; ?>
</div>
<?php endif; continue; endif; // linked
if ($tipo === 'textarea'):
@@ -2230,6 +2272,12 @@ document.addEventListener('DOMContentLoaded', function() {
}, 350);
});
<?php endif; ?>
/* El formulario va embebido en la estación: no puede abrir el modal de
corrección por su cuenta, le pide al contenedor que lo haga. */
function pedirCorregirPaciente() {
try { window.parent.postMessage({ type: 'corregirPaciente' }, '*'); } catch (e) {}
}
window._fpwPreFirma = <?= json_encode($firmaProfPreguardada) ?>;
window._profNombre = <?= json_encode($profNombre) ?>;
window._profDocumento = <?= json_encode($profDocumento) ?>;
@@ -2259,6 +2307,8 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
return;
}
var datos = json.datos_respuestas || {};
var tocados = [], llenos = 0;
document.querySelectorAll('[name]').forEach(function(el) {
var raw = el.name, isArr = raw.slice(-2) === '[]', name = isArr ? raw.slice(0, -2) : raw;
if (!(name in datos)) return;
@@ -2271,9 +2321,25 @@ window._profDocumento = <?= json_encode($profDocumento) ?>;
} else {
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.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() {
btn.disabled = false;
@@ -3193,13 +3259,25 @@ const topaz = (() => {
} else {
var radio = ctrl.querySelector('input[type="radio"]:checked');
var sel = ctrl.querySelector('select');
var v = radio ? radio.value : (sel ? sel.value : '');
var txt = ctrl.querySelector('input[type="text"], textarea');
// Un campo vinculado se muestra como texto, no como control:
// es el caso del sexo, que gobierna la sección obstétrica.
var lnk = ctrl.classList.contains('campo-linked')
? ctrl.querySelector('.campo-linked-valor') : null;
var v = radio ? radio.value
: sel ? sel.value
: lnk ? lnk.textContent.trim()
: txt ? txt.value : '';
activo = valoresCond.includes(v);
}
var ids = [sec.sepId].concat(sec.elemIds);
ids.forEach(function(id) {
var el = document.querySelector('[data-campo-id="' + id + '"]');
if (!el) return;
// El PHP marca con display:none los campos de secciones cuya
// condición no se cumple. Esta función anima con max-height y
// opacity, así que sin quitar el display el campo nunca aparece.
if (activo) el.style.removeProperty('display');
el.style.transition = 'opacity .2s, max-height .3s';
el.style.maxHeight = activo ? '2000px' : '0';
el.style.opacity = activo ? '1' : '0';