195 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
Lizandro GuarnizoandClaude Opus 5 32c209710c Documentación completa del proyecto en el módulo Soporte
21 documentos en cuatro secciones, escritos sobre el comportamiento real del
sistema —incluidos los casos que costaron diagnosticar esta semana.

Manual de usuario (visible para todos): primeros pasos, recepción, toma de
muestras, portal del enfermero y administración. Orientado a tareas concretas,
no a describir pantallas.

Documentación técnica: índice de módulos, turnero, formularios y firma digital,
WhatsApp y bot, domicilios, webhook (migrado de WEBHOOK_ENDPOINTS.md) e
inventario de endpoints.

Arquitectura: visión general, enrutamiento y registro de módulos, roles y
permisos, modelo de datos, integración con WhatsApp, y decisiones tomadas con
su deuda técnica asociada.

Operación: runbook de incidentes ordenado por síntoma, configuraciones críticas
—incluido qué vive en Meta y no en la base— y despliegue.

Se documentan explícitamente las trampas conocidas: role/role_id que hay que
mantener sincronizados, las columnas can_* que el control de acceso no lee, las
URL de plantilla que no se cambian desde el código, y las columnas históricas
que quedaron en NULL sin forma de recuperarlas.

README_DOCS.md apunta al módulo y explica cómo agregar páginas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:14:32 -05:00
Lizandro GuarnizoandClaude Opus 5 018fb13332 Módulo Soporte: visor de documentación con renderizado Markdown y buscador
Nuevo módulo `soporte` con la documentación del proyecto en cuatro secciones:
manual de usuario (visible para todos), y documentación técnica, arquitectura
y operación (solo administradores).

- Markdown.php: renderizador propio del subconjunto que usa la documentación
  (encabezados, listas anidadas, tablas, código, citas). Escapa todo el texto
  antes de aplicar formato, así que los .md no pueden inyectar HTML. Se
  prefirió un archivo auditable a incorporar una dependencia externa.
- DocIndex.php: descubre los .md, arma el árbol, resuelve acceso por sección
  y construye el índice del buscador.
- Generadores.php: expande marcadores {{modulos}}, {{endpoints}}, {{tablas}},
  {{roles}} y {{servicios}} leyendo el código y la base en cada carga, para
  que los inventarios no puedan quedar desactualizados.

Se registra en SYSTEM_MODULES y se concede a los 12 roles con permission=read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:07:06 -05:00
Lizandro GuarnizoandClaude Opus 5 8948ea67d1 Tomas Prolongadas: ocultar los campos junto a su sección y aplicar visibilidad al abrir
Al ver F-LAB-28 desde bandeja aparecían secciones que no correspondían al
examen del paciente (Insulina · Minuto 0/30/60 con sus firmas sueltas).
Tres causas:

1. Una sección condicional que no se cumple ocultaba solo su encabezado
   mediante CSS; sus campos (hora, observaciones, firma) se renderizaban
   visibles y sin título. Ahora los campos llevan data-sep-id y heredan el
   estado oculto de su sección.

2. _mpApplyExamVisibility solo corría al cambiar el examen o al usar el panel
   de configuración, nunca al cargar. Un formulario ya configurado abierto
   desde bandeja o historial quedaba como lo dejó el PHP. Se llama ahora en
   el DOMContentLoaded existente.

3. _mpGetExamName usaba querySelector y devolvía solo el primer examen
   marcado, así que un paciente con Glicemia y Prolactina perdía las tomas
   del segundo. Se agrega _mpGetExamNames (todos) y la visibilidad acepta
   una lista; se corrige también la ruta de restauración desde sessionStorage,
   que restauraba varios exámenes pero aplicaba visibilidad con uno.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 09:51:11 -05:00
Lizandro GuarnizoandClaude Opus 5 d79d33db36 Facturación del día: finalizados vs en proceso, en vez de extrapolación lineal
La proyección estimaba el cierre del día multiplicando el ritmo promedio por
las horas que faltaban hasta un cierre fijo a las 17:00. Eso sobreestimaba
(el laboratorio concentra las tomas en la mañana por el ayuno), dependía de
una hora de cierre escrita a mano, y en fechas pasadas usaba date('G') —la
hora actual real— así que proyectaba facturación futura sobre días ya
cerrados.

Ahora son dos cifras concretas, sin estimar: Facturado = turnos finalizados;
En proceso = turnos aún activos, cuyo total_cobrado ya está registrado pero
no han cerrado. Ausentes y cancelados quedan fuera de ambos. La gráfica por
hora usa la misma separación.

Hoy: $22.779.145 en 92 finalizados + $667.000 en 2 en proceso (antes
proyectaba $28.740.490).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:08:28 -05:00
Lizandro GuarnizoandClaude Opus 5 d448ccc707 Registrar en migrations los cambios de esquema aplicados hoy
Los cambios del 2026-08-03 (admin_users.cedula, turnero_consentimientos.creado_por,
turnero_muestras.recibida_en_turno_id y la tabla turnero_tv_media) se aplicaron
directo en producción y no quedaron en el repo, así que un entorno nuevo no
los tendría. Se registran como migración idempotente (IF NOT EXISTS + guardas
en el UPDATE/INSERT); verificada contra producción sin efectos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:05:05 -05:00
Lizandro GuarnizoandClaude Opus 5 25a099c6ef LIA: historial de conversación y prompt que no fuerza brevedad
Cada pregunta se enviaba sola, sin los intercambios previos, así que un
seguimiento del tipo "¿y de esos cuántos en la mañana?" no tenía contexto.
Ahora el dashboard acumula los últimos 4 intercambios y los envía; el
backend los valida y antepone a la pregunta actual (recortados para no
inflar el consumo).

El prompt pedía "responde de forma concisa" en todos los casos, lo que
sumado al tope de tokens producía respuestas cortadas. Ahora ajusta el
detalle a lo que pide la pregunta y sabe que tiene historial disponible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:03:43 -05:00
Lizandro GuarnizoandClaude Opus 5 b1a8b1abf8 LIA: subir maxOutputTokens a 2048 y avisar cuando Gemini corta la respuesta
El límite de salida estaba en 600 tokens (~450 palabras), que truncaba a
media frase las respuestas largas — listados de turnos del día, comparativas
por bacteriólogo. Gemini Flash admite hasta 8192; 2048 cubre esos casos sin
inflar el consumo del presupuesto.

Además el código nunca leía finishReason, así que una respuesta cortada por
MAX_TOKENS llegaba al usuario indistinguible de una completa. Ahora se marca
en el texto y se expone el flag `truncada` en la respuesta JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:58:45 -05:00
Lizandro GuarnizoandClaude Opus 5 f6d4ee9bdd Capturar firmante de cada toma en el servidor; igualar render del modal en historial
guardar_toma.php (única ruta de firma de F-LAB-28 Tomas Prolongadas) nunca
registró quién firmaba: solo guardaba el SVG por campo. De los 111 registros
del formulario, 0 tienen identidad del firmante y no hay traza en ninguna
tabla de auditoría, así que no es recuperable retroactivamente. Ahora el
nombre/cédula se resuelven desde la sesión en el servidor (adminId()) y se
guardan por toma — cada toma puede firmarla un profesional distinto, y esta
es la única fuente confiable. El intento anterior dependía de que el
navegador enviara window._profNombre.

historial.php abría el modal sin compact=1&zoom=1.1, que sí usa bandeja;
por eso el mismo documento se veía en lista larga en vez de la grilla con
tarjetas de toma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:55:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e480e14974 Corregir fuga de tomas_detalle entre formularios del mismo turno
\$tomasDetalle solo se inicializaba dentro del if(\$esTomaProg), así que un
formulario que NO es de toma progresiva (ej. "Datos Toma de Muestras")
heredaba el arreglo del formulario anterior en el mismo turno (ej.
"Control de Tomas de Muestras Prolongadas") si ese sí lo era. Se reinicia
en cada vuelta del loop junto con tomas_total/tomas_firmadas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-03 14:39:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e8bf1febda Quitar respaldo al usuario de sesión actual al mostrar firmante de toma
El modal de documento (usado desde bandeja) mostraba el nombre de quien
estuviera viendo el documento cuando la toma no tenía guardado el firmante
real — inconsistente con historial, que ya mostraba "—" en ese caso. Ahora
ambas vistas coinciden: solo se muestra un nombre cuando quedó realmente
guardado por esa toma (desde el fix de guardar_toma.php); si no, ninguna
de las dos adivina.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-03 14:37:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6254e45e8f Pantalla TV del turnero: playlist de videos e imágenes en vez de un solo video
Nueva tabla turnero_tv_media (tipo, url, orden, duracion_segundos) para
soportar múltiples videos/imágenes reproducidos en secuencia y en bucle.
El video que ya estaba cargado se migró como primer ítem de la playlist.

- save_tv_media.php / delete_tv_media.php / reorder_tv_media.php
  reemplazan a save_tv_video.php (eliminado).
- configuracion.php (tab TV): subida múltiple, lista reordenable por
  arrastre, duración editable por imagen.
- display_global.php: el <video> único se reemplaza por un reproductor
  JS que recorre la playlist — video hasta 'ended', imagen por su
  duración configurada, y vuelve a empezar al terminar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-03 14:33:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2ba13add34 Corregir Tomas Prolongadas: filtrado de secciones, firmante por toma, detalle en historial
Tres bugs relacionados en el formulario "Control de Tomas de Muestras
Prolongadas" (F-LAB-28), detectados en el turno E017:

1. El modal de documentos del historial abría el documento sin &embed=1,
   por lo que el filtrado de tomas irrelevantes (según examen seleccionado)
   nunca se aplicaba — se mostraban las 16 secciones posibles en vez de
   solo las 2-3 que aplicaban, con textos "Pendiente de firma" de más.

2. El guardado de firma en tomas progresivas usa un endpoint distinto
   (_guardarFirmaMP → guardar_toma.php) que no pasaba por el fix de
   nombre/cédula del firmante aplicado antes solo a _guardarFirmaPro.
   Como además una toma progresiva puede pasar por varios profesionales
   en el transcurso de horas, ahora se guarda identidad por-toma
   (campoId + '_pro_nombre'/'_pro_cedula'), no solo global.

3. El historial no mostraba quién atendió cada toma. get_consentimientos.php
   ahora expone tomas_detalle (etiqueta, hora, firmante) por consentimiento
   de toma progresiva, y el panel de documentos del historial lo renderiza
   en una tabla debajo del pill del formulario.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-03 14:26:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ec552136bf Incluir muestras rechazadas al volver, resaltar servicio prolongado en historial
Las consultas de "muestras pendientes de visita anterior" solo incluían
estado='pendiente'; ahora también 'rechazada', así una muestra rechazada
en una visita aparece igual cuando el paciente vuelve, y se puede recibir
directo (sin el paso extra de revertir a pendiente primero).

En historial.php, la columna Servicio se resalta en rojo con ícono de
alerta cuando supera 20 minutos (SERVICIO_PROLONGADO_MIN), para detectar
turnos prolongados a simple vista.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-03 14:19:35 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 538ee8ad25 Vincular turnos por muestra pendiente y precargar Datos Toma de Muestras
El turno original no se modifica (queda finalizado igual que antes) — solo
se agrega trazabilidad: turnero_muestras.recibida_en_turno_id registra en
qué turno se completó una muestra que había quedado pendiente en otra
visita. Historial, bandeja y lugar.php ahora muestran un chip/botón "Turno
vinculado" en ambos sentidos (origen ⇄ seguimiento) con acceso directo.

Además, al abrir un formulario "Datos Toma de Muestras" pendiente para una
visita de seguimiento, aparece un botón para cargar las respuestas del
último formulario firmado de ese mismo paciente (sin firma ni identidad
del profesional anterior) — evita reescribir la historia clínica; la
firma y fecha siguen siendo las de hoy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-03 13:10:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8e1f46904c Registrar quién crea cada consentimiento del turnero para el pie "Enviado por"
turnero_consentimientos nunca guardó quién lo generó; el pie del documento
mostraba el nombre de quien tuviera sesión abierta al verlo, no de quien
realmente lo atendió (por eso siempre aparecía el mismo usuario). Se agrega
la columna creado_por, se llena en los 6 puntos donde se crea un registro
(recepción, envío por WhatsApp, anexar formulario extra, autocreación por
examen/lugar), y ver_formulario_enviado.php ahora prioriza ese dato real
sobre el usuario de la sesión actual. Los documentos ya firmados antes de
este cambio siguen sin ese dato y usan el fallback anterior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-03 12:50:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 aacf26168b Toma de muestras: mostrar exámenes de la visita anterior en muestras pendientes
Cuando el paciente vuelve a completar una muestra pendiente, el panel de
lugar.php ahora lista los exámenes de la solicitud original debajo del
badge "visita anterior", con la misma clase visual que el motivo de
rechazo (hereda color según estado: pendiente/recibida/rechazada).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-03 12:41:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fd8b83a588 Exigir Datos Toma de Muestras en solo_muestras; nombre+cédula del firmante en todos los formatos
get_consentimientos.php ya no salta la autocreación del formulario F-LAB-08
(Datos Toma de Muestras) cuando la visita es "solo entrega de muestras" —
solo se omitían los formularios por examen, los de lugar deben seguir
exigiéndose igual.

ver_formulario_enviado.php cargaba nombre/cédula del profesional firmante
solo dentro del turnero embebido; se amplía también a la firma directa
fuera de turnero (staff logueado). firmar_profesional.php (usado por los
formatos no-turnero) no recibía esos datos del frontend y se perdían;
ahora los guarda en datos_cliente igual que el flujo turnero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-03 12:24:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 86aea30f62 Historial turnero: usar el formulario real de tomas prolongadas (F-LAB-28)
El desglose anterior se basaba en turnero_muestras, que no representa el
formulario "Control de Tomas de Muestras Prolongadas" que el laboratorio
ya usa para tomas seriadas (curvas de glicemia, etc.). Ahora el doc-pill
del historial muestra "X/Y tomas" usando es_toma_progresiva/tomas_firmadas
que get_consentimientos.php ya calculaba, sin duplicar lógica.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-03 11:57:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0b8418ec76 Historial turnero: desglose de tomas de muestra y modal para documentos firmados
Muestra el detalle de cada toma (tipo, quien la recibió, tiempo) cuando un
turno tuvo varias, para explicar tiempos de servicio prolongados. Los
documentos firmados ahora se abren en un modal en vez de nueva pestaña,
preservando los filtros de búsqueda activos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-03 11:44:31 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 397dc208a0 feat(roles): rol lab_recepcion tiene acceso a vista de recepcion turnero
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 19:38:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 006772f265 fix(encuesta): usar plantilla encuesta_turnero en lugar de encuesta
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 22:14:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 17a9f05005 fix(portal): agregar 'del Laboratorio Ximena Caicedo' en mensaje WhatsApp del enfermero
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 21:00:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 79a5438531 feat(domicilios): enfermero puede editar domicilios asignados desde el portal
- save_domicilio.php: permite editar si el enfermero está asignado (no solo si lo creó)
- enfermero_portal.php: botón Editar en domcard, método abrirEditar() pre-carga el modal existente

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:44:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1efdc7bbad fix(portal): permitir superadmin ver portal enfermero y simular agenda
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:34:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3fa92e8813 fix(LIA): cargar tokens reales desde PHP al abrir el panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:21:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e1738d3fa4 feat(LIA): botón minimizar visible con ícono — / ▲ en header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:19:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f2ce60137a fix(LIA): inicia con sonido silenciado por defecto
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:16:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4010e6e6c6 fix(LIA): voz femenina en español y quitar emoji de saludo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:12:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 320b47797a feat(LIA): barra de tokens 1M con bloqueo al agotarse
- ai_chat.php: cuenta tokens reales desde Gemini usageMetadata, persiste en lab_config, bloquea con HTTP 402 al agotar 1.000.000
- dashboard.php: _actualizarTokens() actualiza barra visual, _bloquearLIA() deshabilita input+mic y muestra aviso de soporte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:09:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 34d0fed21b feat(dashboard): LIA voz+saludo, proyección Chart.js, Excel, informe WA
- LIA: saludo de voz al abrir (1x/día), auto-escucha 3s, olas animadas
- Gráfica de barras (Chart.js) con facturación real + proyección lineal
- Exportar a Excel (.xls con HTML table, se abre directo en Excel)
- Botón "Informe WA": envía resumen del día por WhatsApp a número elegido
- Diseño accesible: texto más grande, botones claros para usuario adulto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 20:03:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a8752b9e9d feat(dashboard): asistente IA con voz, markdown y preguntas rápidas
- Audio input: botón micrófono con SpeechRecognition (es-CO)
- Audio output: TTS con SpeechSynthesis, toggle para silenciar
- Markdown: **negrita** → <strong>, * items → <ul> en respuestas
- Chips de preguntas rápidas: Resumen, Facturación, Tiempos, En espera
- Panel minimizable: clic en header colapsa/expande el cuerpo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 19:55:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 941b6740f0 feat(recepcion): panel muestras muestra último turno atendido por letra
API: agrega por_prioridad con último código llamado a muestras por letra.
Vista: badges con color de prioridad mostrando "A: A-023", "B: B-011", etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 19:51:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 498a144c18 feat(historial): mostrar nombre kiosko y datos BD (nombre+doc) en paciente
- API: agrega tipo_documento y numero_documento al SELECT
- Vista: línea 1 = nombre BD + CC/doc, línea 2 gris = Kiosko: nombre

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 19:49:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1a3ff7219e fix(encuesta): leer language_code de message_templates igual que consentimiento
Reemplaza 'es_CO' hardcodeado por consulta a message_templates,
con fallback a 'es'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 19:45:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 44b737c253 feat(tiempos): agregar tiempo total puerta a puerta en historial y dashboard
- APIs: total_min = creado_at → fin_lugar_at (solo finalizados)
- Dashboard: KPI card 'Total prom. puerta a puerta' + columna Total en tabla
- Historial: columna Total en tabla

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 18:14:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c357f18b61 fix(resumen-muestras): obtenerOCrearSesionHoy() retorna int no array
Usaba ['id'] sobre un int → sesionId=0 → sin resultados.
Cambiado a sesionHoy() que sí retorna array con 'id'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 18:06:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fb448d2c2f fix(bandeja): labels de muestras de vuelta a 'pendientes' en UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 18:04:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 22ed64d8ad fix(bandeja): filtrar muestras rechazadas en vez de pendientes
- get_bandeja.php: contar estado='rechazada' (no 'pendiente')
- chip renombrado a "Muestras rechaz." con ícono fa-flask
- card muestra badge rojo con ícono y cantidad cuando hay rechazadas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 18:04:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0e0dc4bd44 refactor(encuesta): usar paciente_cel directo igual que send_consentimiento
Elimina el lookup innecesario en users/$userId que nunca se usaba.
Ahora sigue el mismo patrón que recepción: BD telefono → paciente_cel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 17:57:36 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9a18184ad7 fix(encuesta): usar paciente_cel directamente si no está en tabla users
El paciente siempre tiene celular registrado; el error ocurría porque se
requería match en users. Ahora usa paciente_cel como fallback final.
Revierte la guarda del frontend del commit anterior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 17:55:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a4ddc63b6f fix(encuesta): ocultar barra si paciente no tiene celular registrado
Si turnoActivo.paciente_cel está vacío, _ofrecerEncuesta retorna sin
mostrar la barra, evitando el error de WA al intentar enviar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 17:55:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 90e3125e45 feat(historial): mostrar nombre kiosko y nombre BD en columna paciente
Si el nombre registrado en BD difiere del ingresado en kiosko, muestra
ambos: el BD como principal y el kiosko en gris debajo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 17:52:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7e44fd4050 fix(historial): corregir SyntaxError por backtick/backslash en renderTabla
- esc() ahora escapa backticks (&#96;) para evitar que nombres de paciente
  con backticks rompan el template literal
- onclick simplificado: eliminado .replace(/'/g,\\'\\\\\\'\\'') que era JS
  inválido (backslash fuera de string literal dentro de ${}); esc() ya
  convierte comillas simples a &#39;

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 16:43:44 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 25bdf9aed2 fix: resetear toma limpia siguiente_toma_at y toma_inicio_at
Al olvidar una toma los tiempos del ciclo anterior quedaban en la DB,
haciendo que al re-firmar la toma reseteada las demás aparecieran
inmediatamente (sin countdown) y el profesional tuviera que firmar 3
veces en vez de 1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 16:04:51 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 90d4e1bc49 fix: nombre profesional bajo firma firmada; ver paciente desde espera toma
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 21:32:03 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 309c1c93c6 fix: nombre y cédula del profesional en cada toma de muestras prolongada
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 21:24:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2455417bb4 feat: encuesta de satisfacción post-finalizar toma de muestras
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 21:23:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4a4cbc31b8 fix: permitir guardar borrador en estado enviado desde turnero
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 21:16:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c85181ad56 feat: mostrar nombre y cédula del profesional bajo la firma
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 21:10:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 806ae0b552 fix: colapsar márgenes !important de secciones condicionales ocultas
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 21:01:51 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8a384088c3 fix: radios con 3 opciones span full en modal compact
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:55:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4fee7f753a fix(turnero): anexar_formulario usa UUID() en vez de bin2hex para el token
bin2hex(random_bytes(16)) genera hex sin guiones que no pasa la validación
de ver_formulario_enviado.php — el formulario adicional daba token inválido
al intentar firmar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:50:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fcfdcccba9 fix(consentimiento): resetear firma conserva datos_respuestas para re-firma
El usuario puede modificar y volver a firmar sin perder lo llenado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:49:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bf8478244b fix(formulario): reducir espacios en blanco en modo compact del modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:44:36 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8937a46362 fix(formulario): radio options en fila horizontal — override display:flex + fix form_cliente
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:42:29 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7bb69f6efd fix(formulario): opciones de radio en fila horizontal en vez de vertical
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:40:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ad5a4c834f fix(lugar): alarma de toma progresiva suena en panel de espera entre muestras
El timer de 1s solo escaneaba chips en la cola principal. Los pacientes
en espera entre extracciones (muestra_espera_at IS NOT NULL) viven en el
panel de espera sin chip, por lo que nunca sonaba. Se añade un chip oculto
con data-toma-at en cada fila del panel; el timer existente lo detecta y
dispara banner + sonido en todas las tablets del grupo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:34:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2da5960596 fix(bandeja): default hoy + etiqueta Hoy/Ayer/fecha en cada registro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:32:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 00f6e7f759 fix(historial): default hoy en vez de 7 días para evitar timeout inicial
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:30:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e3216faaa5 feat(bandeja): chips clickeables para muestras pend. y prog. en espera
- Chip "Muestras pend." y nuevo "Prog. en espera" son toggles de filtro
- Se resaltan con borde al activarse; combinables entre sí y con el dropdown
- API retorna muestras_pendientes (turnos con ≥1 muestra pendiente) y
  prolongadas_pendientes (toma progresiva en espera entre extracciones)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:29:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 118e98b360 feat(bandeja): filtros por fecha, nombre, # orden y estado
- Inputs desde/hasta pasan al API y filtran por rango de fechas
- Buscador cubre nombre, número de orden, código y examen
- API usa fecha cuando se pasa, sesión actual como fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:22:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4cb133010a feat(bandeja): filtro de muestras pendientes
Añade conteo de muestras_pendientes por turno en la lista y opción
de filtro en el selector de estados de la bandeja del día.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 20:21:06 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2e3534ab02 feat(bandeja): filtro por estado en la bandeja del día
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 12:02:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7d444699d3 feat(turnero): panel de estado de muestras en recepción
Recepción ve en una barra inferior qué turno atiende cada estación de
toma de muestras (verde = ocupada, gris = libre), cuántos están en
espera y cuántos en espera entre muestras progresivas. Se refresca cada 7 s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 11:59:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7b599b28ef fix(turnero): bacterióloga sin token va al dashboard, no a lugar_id=1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 11:55:30 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8472c916b0 fix(turnero): llamar_desde_espera actualiza lugar y dispara display
Al llamar un paciente de toma progresiva desde la espera, ahora se
actualiza lugar_destino_id al puesto que llama, se pone llamado_lugar_at
para que el display anuncie al paciente, y se notifica por SSE. El JS
abre la ficha y muestra el toast igual que un llamar normal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 11:49:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3d26700e16 feat(recepcion): mostrar contador de exámenes seleccionados
Badge junto al título "Exámenes solicitados" que muestra cuántos
exámenes lleva el turno actual. Se actualiza en tiempo real al
agregar o quitar ítems del TomSelect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 09:41:06 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fa591873a9 fix(auth): redirigir admins/supervisores al dashboard del turnero tras login
login.php y index.php enviaban a v=recepcion a cualquier usuario con el
módulo turnero que no fuera bacteriólogo, incluyendo superadmin, admin
y supervisor. Esto causaba que al cerrar sesión y volver a entrar, el
admin aterrizaba en recepción en vez del dashboard.

Ahora:
- bacteriologo → lugar_id=1
- superadmin/admin/supervisor → v=dashboard
- recepcionista/otros → v=recepcion (comportamiento previo correcto)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 09:30:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 925b29b00b fix(turnero): incluir solo_muestras en llamar_turno y bypass consent para entrega
- llamar_turno.php: ambos SELECT de refresco hacen LEFT JOIN con
  turnero_solicitudes para devolver COALESCE(ts.solo_muestras,0).
  Sin este JOIN, JS recibía undefined y _esSoloEntrega() retornaba
  false → polling de consentimientos arrancaba → hayPendientes=true
  → finalizar bloqueado (caso turno E047 ALVARO TIRADO).
- lugar.php renderConsentimientos: hayPendientes se fuerza a false
  cuando _esSoloEntrega(turnoActivo) es true, para que un
  consentimiento huérfano previo no bloquee la finalización en
  turnos de solo entrega de muestras.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 09:20:56 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 dffb242522 perf(historial): reemplazar N+1 queries por 3 batch queries
Las 3 subqueries por turno (examenes, consentimientos, comentarios)
× 50 turnos = 150 round-trips al servidor remoto (~83ms c/u) sumaban
12.5s de espera, causando timeouts o página sin carga.

Ahora se agrupan en 3 queries con IN(?) sobre todos los IDs del lote.
Tiempo total: ~357ms (35x más rápido).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 08:54:04 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 70fda3dacc fix(turnero): dos correcciones de estado y redirección
1. App::bacteDefaultUrl() ahora busca también por token cookie (antes
   solo buscaba por IP), consistente con login.php y lugar.php.

2. cambiarEstadoTurno(): cuando el servidor devuelve 422 (transición
   inválida porque otro operador cambió el estado), muestra mensaje
   claro y refresca la UI en lugar de mostrar el error técnico bruto
   "Transición 'en_recepcion' → 'finalizado' no está permitida".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 08:40:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 88fd0e4151 fix(turnero): login detecta dispositivo por token antes de buscar por IP
La detección de tablet fija solo buscaba por IP; tablets con token
configurado no hacían match y caían al redirect de rol (recepcion).
Ahora busca primero por cookie turnero_token y como fallback por IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 08:16:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e41663cff0 fix(turnero): bacteriologo inicia en lugar_id=1 en lugar de dashboard
El rol bacteriologo iba a v=dashboard al hacer login. dashboard.php solo
redirige automáticamente si la IP está registrada como dispositivo; sin
IP registrada, la bacterióloga se quedaba en el dashboard sin usar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 08:14:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 afa7cc6812 feat(turnero): anexar formulario adicional a turno desde lugar.php
Permite al operador agregar un consentimiento informado extra (fuera de
los asignados por defecto) mientras el turno está en servicio.

- PHP: consulta lab_formularios activos al cargar lugar.php
- JS: modal de selección con lista de formularios
- Botón visible solo cuando turno está en_servicio, oculto en resetFicha
- API: modules/turnero/api/anexar_formulario.php crea el consentimiento
  y notifica SSE para que el dispositivo lo vea inmediatamente
- Fix: _olvidarConsentimiento usaba cargarConsentimientos() inexistente;
  corregido a actualizarConsentimientos(turnoActivo.id)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 07:49:06 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ff52fa5ba7 feat: olvidar firma, editar tiempos tomas, obs en header, fix dispositivos
- feat(turnero): Botón "Olvidar" en lugar.php y recepcion.php para descartar firma de consentimiento completo (nuevo endpoint resetear_consentimiento.php)
- feat(turnero): Botón "Olvidar" por toma individual en prolongadas para borrar firma+hora de una toma específica (nuevo endpoint resetear_toma.php)
- feat(turnero): Editar tiempos en tomas en_progreso: hora_campo signed muestra readonly con botón olvidar, permite re-editar tras olvidar
- feat(turnero): Guardar tiempos (draft save) también en estado en_progreso
- feat(turnero): Observaciones de tomas prolongadas se muestran en header de tarjeta firmada (campo obs_campo en _mpMap)
- fix(turnero): Token del equipo se sobreescribía en tabla — ip permitía solo un registro con ip='' (UNIQUE); ahora ip es nullable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 07:37:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8e9d7750a7 fix: revertir selección de voz a es-CO (online) — mejor calidad
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 06:49:35 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 cbcc0a9d8d fix: voz TTS no sonaba en display_global por voz online y falta de activación
- getVozES() prefiere voces locales (offline) sobre Microsoft Online que
  fallan silenciosamente sin internet o con latencia
- cancel() antes de speak() para limpiar cola atascada
- keepalive sin check !speaking, cada 10s
- sonidoActivo persiste en localStorage; banner rojo si no activado
- AudioContext se resume si suspended antes de reproducir

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 06:47:19 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4b054c5afc fix: meta ignorado en sendTemplateMessage con rawComponents
Cuando se usaban rawComponents, el parámetro $meta no se agregaba
al payload, causando que los mensajes del turnero se guardaran con
canal='bot' y aparecieran en conversations.php.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 01:14:53 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a08e2002c4 chore: modelo Gemini 2.5-flash → 3.5-flash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 01:11:14 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f5ef28e2c7 chore: actualizar modelo Gemini 2.0-flash → 2.5-flash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 01:09:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4142c67a5d fix: modal cerrar anticipado no abría por d-none !important
Bootstrap d-none usa display:none !important que gana sobre
modal.style.display = 'flex' del JS. Se quita la clase d-none;
el display:none del style inline es suficiente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 01:02:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ab8b0702a7 fix: handle nested _tomas_config format in guardar_toma.php
PHP 8.1+ throws TypeError when using an array as array key.
Old flat-format loop iterated nested { examName: { group: [fids] } }
and tried $allowed[$arrayValue] = true, causing a fatal error and
returning ok:false ("Error al guardar") on every toma signing attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 00:48:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 70077d7fd4 fix: campos condicionales ocultos no dejan espacio en blanco
- JS evaluar(): añade margin:0; padding:0 al ocultar elementos con maxHeight:0
  para que .campo-edit no deje margen residual
- PHP render: baked-in separator oculto incluye margin:0;padding:0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 00:32:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c9ea76f782 feat: cierre anticipado de tomas prolongadas con justificación
- DB: añade estado 'cerrado_anticipado' al enum de turnero_consentimientos
- ver_formulario_enviado.php: POST handler mp_cerrar_anticipado guarda motivo
  en _cierre_anticipado y cambia estado; botón "Cerrar anticipadamente" con modal
  textarea; bloque display para estado cerrado_anticipado mostrando el motivo;
  modoEditar y check de estado actualizados para excluir cerrado_anticipado
- get_consentimientos.php: expone cierre_anticipado en respuesta; mueve parse
  de datos_respuestas fuera del if(esTomaProg) para ambos loops
- lugar.php: CONSENT_IC/LBL incluye cerrado_anticipado; ya[] lo incluye;
  hayPendientes lo excluye; fila muestra motivo en banner amarillo
- recepcion.php: CONSENT_META incluye cerrado_anticipado; ya[] lo incluye;
  motivo visible en la tarjeta del consentimiento

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 00:29:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 915fa3ba5c feat: config anidada por examen en tomas prolongadas + F-LAB-08 mejoras
- configurar_tomas.php: almacena _tomas_config como { examName: { groupKey: [fids] } }
  en vez de flat, detecta y resetea formato antiguo, valida exam_type requerido
- ver_formulario_enviado.php: rendering aplana nested→flat para filtrar secciones;
  JS lee _mpTomasConfig[examName][gk] en inSaved y _mpRenderBadges; auto-open
  verifica per-examen; _mpActiveConfigExam evita race entre exámenes simultáneos
- get_consentimientos.php: flatten de nested o flat para calcular allowedFids
  en ambos loops de tomas progresivas
- F-LAB-08 (id=16): añade 'Grupo sanguíneo' a opciones de _m5n865g;
  agrega campos condicionales '¿Qué tipo de cáncer?' y 'Especifique grupo sanguíneo'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 00:21:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0ea411ce62 Fix: preservar todos los exámenes seleccionados al configurar tiempos
Al guardar config de un examen se sobreescribía _c8j2g16 con solo ese examen,
perdiendo los demás en el reload. Ahora se envía exam_types (array completo),
se guarda como array en datos_respuestas y sessionStorage restaura todos los
checkboxes marcados al recargar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 23:48:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b13cc3f8ea Fix: config tiempos por examen con grupos compartidos
- every en lugar de some: examen configurado solo cuando TODOS sus grupos
  tienen config (no solo alguno compartido)
- inSaved sin requisito isModify: grupos compartidos muestran selección
  previa al abrir panel de otro examen, evitando sobreescribir config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 23:42:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9ebd22e41c Fix: auto-open panel tiempos solo cuando hay examen sin configurar
El fallback que abría el primer examen en modo editar cuando todos estaban
configurados causaba un loop infinito — el panel nunca se cerraba y el
usuario no podía llegar a firmar. Ahora solo se auto-abre si hay un examen
pendiente de configuración.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 23:36:19 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 abbd5bde16 Fix: get_especialidades muestra pacientes en_espera_lugar
Antes solo aparecían pacientes con inicio_lugar_at IS NOT NULL (ya llamados).
Ahora incluye estado IN ('en_espera_lugar','en_servicio') para mostrar
pacientes enviados a ginecología/pediatría antes de ser llamados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 23:30:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a3c19bf4e7 Turnero: toma progresiva por examen, Rubeola solo-profesional, kiosko
- configurar_tomas.php: merge config por examen en lugar de overwrite
- ver_formulario_enviado.php: auto-abrir panel de tiempos al cargar; soporte formularios solo_profesional con botón Guardar y completar
- get_consentimientos.php: leer campo solo_profesional de lab_formularios
- recepcion.php: formularios sin firma paciente muestran badge "Completa en laboratorio"
- lugar.php: botón "Completar" para formularios solo_profesional

DB: ALTER TABLE lab_formularios ADD solo_profesional; UPDATE Rubeola id=8; UPDATE descripcion Niños kiosko

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 23:22:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4012ae97be turnero: sala de espera entre muestras (toma progresiva)
- DB: columna muestra_espera_at en turnero_turnos
- Panel 'Esperando siguiente muestra' en la cola de lugar.php
  · Muestra pacientes en espera con countdown a siguiente_toma_at
  · Botón 'Llamar' los devuelve a la cola activa
- Botón 'Mandar a espera' en la ficha (visible cuando toma progresiva activa)
  · Desaparece de la cola principal, queda en sección de espera
- get_cola.php: excluye muestra_espera_at IS NOT NULL de cola y activo
  · Agrega array en_espera_muestra en la respuesta
- Nuevas APIs: poner_en_espera_muestra.php, llamar_desde_espera.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 22:41:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e634eb8932 turnero: panel especialidades (ginecología/pediatría) en toma de muestras
- Sección en la parte superior de lugar.php muestra pacientes esperando
  en Ginecología y Pediatría (inicio_lugar_at IS NOT NULL, fin_lugar_at IS NULL)
- Chips de acceso rápido (botones) al tope de la cola para navegar directo
- Contadores de espera actualizados cada 30s
- Clicking en un paciente o "Ir →" navega a esa vista completa para atenderlo
- Solo aparece si existen esos lugares en DB y hay pacientes esperando
- Nuevo: get_especialidades.php para consultar pacientes de lugares especiales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 22:31:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b1d1c0896f turnero: kiosko — niños placeholder+edad, muestras desc, numpad +5%
- Prioridad A (Niños): placeholder cedula → 'Tarjeta de Identidad del menor (si tiene)'
- Prioridad A: age check via check_paciente.php → toast aviso si paciente ≥ 10 años
- Prioridad F: descripción → 'Muestras de días anteriores' (BD + fallback PHP)
- Numpad: padding .6rem → .63rem .6rem (+5% alto)
- Nuevo: check_paciente.php (endpoint público, solo devuelve fecha_nacimiento)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 22:18:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 80b61f88be turnero: renombrar estado 'rechazada' → 'Pendiente' en UI
Muestra que el paciente trae después no se muestra como rechazo.
Colores rojo → naranja, botón/etiqueta/íconos actualizados en lugar.php y bandeja.php.
DB/API internos sin cambio (enum 'rechazada' se mantiene).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 22:08:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 045fd64113 feat(bandeja): modal de consentimientos + badges visibles confirmados
- Consentimientos abren en modal iframe (ver_formulario_enviado.php?embed=1)
- Igual que en toma de muestras: spinner de carga, click fuera para cerrar
- Badges de comentarios (Recep./Toma/Gral.) y estado (Finalizado/En servicio)
  confirmados visibles en tarjeta

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:48:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b76c2f11e9 feat(bandeja): badges de comentarios y estado en tarjeta de lista
- Indicadores por tipo: Recep. (azul) / Toma (verde) / Gral. (amarillo)
- Label de estado del servicio: En servicio / En espera / Finalizado / etc.
- Query agrega SUM por tipo de comentario sin subquery adicional

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:42:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0b39e3f0d0 fix(consecutivo): contador atómico con LAST_INSERT_ID — elimina race condition
Reemplaza SELECT MAX() (vulnerable a concurrencia) por UPDATE atómico sobre
turnero_consecutivos. Dos recepcionistas simultáneas nunca pueden recibir
el mismo número de orden. Sin UNIQUE constraint, sin riesgo en datos históricos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:41:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9f251d57be fix(bandeja): URL consentimiento → ver_formulario_enviado.php?token=...
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:31:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 368c4c8a5a feat(bandeja): vistos/por-revisar, filtro nombre+examen, marcar como visto
- Reemplaza activos/cerrados por pendientes (por revisar) / vistos
- Columna bandeja_visto_at en turnero_turnos; API marcar_visto.php
- Botón "Marcar como visto" → mueve paciente a sección inferior
- Filtro en tiempo real por nombre y por código de examen (GROUP_CONCAT)
- URL de consentimientos: relativa (consent.php?token=...)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:29:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6c54674795 fix(bandeja): corregir res.data → top-level keys (jsonOk no anida en data)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:21:01 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0c6e522106 feat(turnero): Bandeja del día — lista de pacientes con detalle y cierre
Vista split: lista ordenada por numero_orden (activos arriba, cerrados abajo),
stats bar con totales, panel de detalle con exámenes por categoría, muestras,
consentimientos, comentarios, PDF via window.print() y acción de cerrar turno.
Auto-refresh cada 30s. Visible para bacteriólogos y admins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:17:48 -05:00
Lizandro Guarnizo 3c3e531e48 feat(lugar): botón quitar consentimiento con motivo obligatorio y auditoría 2026-07-27 20:56:44 -05:00
LizandroandClaude Sonnet 4.6 828681ffd6 feat(turnero): cambio de estado y reapertura de turnos desde historial
Agrega botón de cambio de estado en cada fila del historial de turnos.
Admins (sin role_id) pueden reabrir turnos finalizados/ausentes/cancelados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 01:47:11 +00:00
Lizandro Guarnizo deb74a1f9e fix(login): bacteriologo redirige a v=dashboard para que App.php lo intercepte correctamente 2026-07-27 07:40:12 -05:00
Lizandro Guarnizo 75c7cb2762 feat(audit): registrar en lab_actividad_admin quien edita formularios de lugar 2026-07-27 07:27:56 -05:00
Lizandro Guarnizo 8dadf0ba7a fix(login): bacteriologo redirige a lugar en vez de recepcion al iniciar sesion 2026-07-27 07:08:15 -05:00
Lizandro Guarnizo 6192ef89a1 fix(kiosko): height calc(100vh/0.80) compensa zoom para llenar viewport 2026-07-27 06:54:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1660a5a426 fix(kiosko): zoom en html en vez de .kiosko-wrap para llenar viewport
zoom en el elemento hijo encogía 100vh dejando espacio vacío abajo.
En html el browser recalcula vh correctamente y el layout llena la pantalla.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 06:50:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ea956f2c6c fix(display_global): convertir nombre a Title Case antes del TTS
Los nombres en MAYÚSCULAS son deletreados por el motor de voz.
Convertir a Title Case para que se lean como palabras normales.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 06:44:36 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 40b8b54aee fix(display_global): no cortar TTS cuando llaman desde lugar simultáneo
Elimina speechSynthesis.cancel() para que los anuncios no se interrumpan.
El overlay espera a que el TTS termine (onend) antes de pasar al siguiente,
con un mínimo visual de 5 segundos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 06:41:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b4e45c1c22 feat(lugar): formularios en modal 10% más grandes en tablet
Pasa zoom=1.1 al iframe; ver_formulario_enviado.php aplica zoom al body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:58:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e93bb1ad41 fix: evitar que <?xml rompa PHP con short_open_tag
El parser de PHP interpreta <? en HTML mode cuando short_open_tag=On.
Se rompe la cadena para que no aparezca el literal <? en el archivo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:14:04 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5400fd9b32 fix: exportar Excel como SpreadsheetML (.xls) en vez de CSV
Abre directamente en Excel con columnas formateadas, sin asistente
de importación ni apertura en VS Code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:02:12 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 dc5fd6e272 feat(chat): separadores de fecha estilo WhatsApp
Muestra "Hoy", "Ayer" o la fecha entre grupos de mensajes de días distintos.
El timestamp del bubble ahora solo muestra la hora.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 09:58:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 42662c2ade fix(kiosko): reducir interfaz al 80% con zoom
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 13:22:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4cd307ba4f feat(lugar): alarma persistente para tomas progresivas
Sonido más fuerte (gain 1.0, 3 pulsos grave-agudo), se repite cada
30 s mientras el banner no se cierre con click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 12:45:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2ed9eff653 form_cliente: leer inputs de texto en evaluador de condiciones
Permite usar campos linked (texto) como controladores de secciones
condicionales; necesario para ocultar datos obstétricos según género.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 12:33:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4f58d6bf16 fix(kiosko): quitar ceros iniciales del barcode de cédula
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:57:19 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 acac557f90 fix(kiosko): botón Volver visible arriba en pantalla de cédula
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:47:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1291d0368a fix(kiosko): reducir numpad otro 15%
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:25:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 960867d785 fix(kiosko): bloquear campo al primer Tab del escáner + numpad más pequeño
El Tab es la señal de fin de cédula en el barcode. Al recibirlo se
activa _scannerLocked y se ignoran todos los caracteres siguientes
(apellidos, fecha, sangre) hasta que llegue Enter o se limpie el campo.
También se redujo el numpad ~10% para que entre en pantalla.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:22:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5c02a8ea99 fix(kiosko): numpad debajo del WhatsApp, escribe al campo activo
Restaura campo WhatsApp (inputmode=none), mueve el numpad debajo.
El numpad detecta cuál input tiene foco y escribe en ese campo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:19:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4e945e6437 fix(kiosko): eliminar campo WhatsApp del flujo, botón OK en numpad
El campo WhatsApp generaba fricción innecesaria. Ahora el numpad
tiene un botón "Obtener mi turno" de ancho completo como última fila.
WhatsApp queda como hidden vacío para no romper la API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:18:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 104ba07af7 feat(kiosko): numpad táctil para campo cédula
- inputmode=none en inp-cedula suprime el teclado Windows
- Grid 3×4 con dígitos 0-9, C (borrar todo) y ⌫ (borrar uno)
- pointerdown + preventDefault mantiene el foco en el input
- El escáner sigue funcionando (escribe por keydown, no por IME)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:12:31 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 43f14c2571 fix(kiosko): salida admin cierra Chrome con window.close()
Navegar al ERP dentro del kiosk no sirve porque --kiosk es un flag
del proceso. window.close() cierra Chrome y devuelve el escritorio
Windows táctil, desde donde pueden abrir el ERP normalmente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 11:03:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e84c16f875 feat(kiosko): salida admin con 7 toques en el logo
Toca el logo o el nombre del laboratorio 7 veces en 4 s para abrir
el overlay de salida: ir al ERP, recargar kiosko o cancelar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:59:30 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 aaeb3567c2 fix(kiosko): teclado táctil Windows reaparece al tocar el campo
- blur() en mostrarTicket, seleccionarOtraPrioridad y volverPrioridades
  para que ningún campo quede enfocado al cambiar de pantalla
- pointerup en cada input: si ya estaba enfocado, blur+focus(30ms)
  fuerza a Windows a reinvocar el teclado táctil
- El focus programático de seleccionarPrioridad se conserva (escáner)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:57:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4d75f82b30 fix(kiosko): cortar en primer no-dígito, no solo en espacio/tab
El Tab se bloquea pero las letras seguían escribiéndose en el campo.
Ahora cualquier carácter no numérico trunca el valor inmediatamente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:54:04 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f8903a4982 fix(kiosko): bloquear Tab del escáner para que no salte al campo WhatsApp
El lector de cédula envía los campos con Tab entre ellos. Se previene
el cambio de foco en keydown. Si algo llega igualmente a inp-cel
(letras) se limpia y regresa el foco a la cédula.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:50:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 24211de0e9 fix(kiosko): truncar código de barras al primer campo (solo cédula)
El escáner envía cedula+nombre+apellidos+… en una sola ráfaga.
Se corta en el primer espacio/tab para quedarse solo con los dígitos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 10:37:36 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5f9e7032a0 fix(chat): mostrar fecha en timestamp de mensajes de días anteriores
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 22:36:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a15dceae2a feat(turnero): devolver paciente a recepción desde toma de muestras
- cambiar_estado: permite en_espera_lugar→en_recepcion y en_servicio→en_recepcion
- Al volver de un lugar limpia lugar_destino_id, llamado_lugar_at, inicio_lugar_at,
  fin_recepcion_at y atendido_lugar_por para que quede reasignable
- lugar.php: botón "A recepción" visible en en_espera_lugar y en_servicio
- resetFicha oculta el botón nuevo al limpiar la ficha

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 22:22:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 728d78f606 fix(kiosko): corregir touch en Edge kiosk mode
- touch-action: manipulation directo en cada botón e input (no solo en body)
- -ms-touch-action para compatibilidad Edge legacy
- position: relative en .kiosko-wrap para contener screens absolutas
- inset: 0 en .screen.hidden para que no se salga del contenedor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 22:12:19 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 868263fe6f fix: no usar firma_svg global como fallback cuando hay datos por-campo
Formularios con 2 campos firma (consentimiento + disentimiento)
mostraban la firma del campo 2 también en el campo 1 porque
firma_svg se usaba como fallback del primer campo. Ahora si
datos_respuestas ya tiene algún _svg, se confía solo en los datos
por-campo y se ignora el fallback global.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 21:57:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 136b123cc0 fix: preview en tiempo real de firma Topaz en modal
Usar GetSigImageB64 en el poll para actualizar el canvas del overlay
cada vez que cambia el número de puntos. SigImage/0 (SigWebRefresh)
solo devuelve el trazo actual; SigImage/1 acumula toda la firma.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 21:45:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 719f27d863 fix: captura de trazos Topaz — canvas context + funciones correctas
- Agregar <canvas id="topaz-canvas"> al overlay de ambos archivos
- SetTabletState(1, canvasCtx, 50) con contexto 2D (sin esto los trazos no se dibujan)
- Guardar timer retornado y pasarlo a SetTabletState(0, tmr) al cerrar
- NumberOfTabletPoints() en vez de GetSigTotalPoints() (función inexistente en v1.0.4)
- GetSigImageB64(callback) async en vez de llamada síncrona sin valor de retorno

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 21:38:56 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 78b01033c5 fix: cargar SigWebTablet.js desde /assets/js/ en form_cliente
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 21:26:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 41e6c536c0 fix: cargar SigWebTablet.js desde servidor en ver_formulario_enviado
Mismo fix que recepcion.php: servir el JS desde assets/js/ en vez
de localhost:47289 que no sirve archivos estáticos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 21:25:20 -05:00
145 changed files with 13060 additions and 1478 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 Este documento quedó desactualizado y se conserva solo por historial de git.
> **Última actualización**: 27/03/2026
--- 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) Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
- [¿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.*
+60
View File
@@ -0,0 +1,60 @@
# Documentación del proyecto
La documentación vive **dentro del sistema**, en el módulo Soporte:
/erp.php?m=soporte&v=documentacion
Se escribe en Markdown, en `modules/soporte/docs/`, y se versiona con el código.
| Sección | Carpeta | Quién la ve |
|---|---|---|
| Manual de usuario | `docs/manual/` | Cualquier usuario autenticado |
| Documentación técnica | `docs/tecnica/` | Administradores |
| Arquitectura | `docs/arquitectura/` | Administradores |
| Operación y soporte | `docs/operacion/` | Administradores |
## Agregar o editar una página
Creá un `.md` en la carpeta de la sección. El nombre lleva un prefijo numérico
que solo sirve para ordenar:
modules/soporte/docs/tecnica/70-mi-tema.md
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
datos leídos del código y de la base:
| Marcador | Qué inserta |
|----------|-------------|
| `{{modulos}}` | Módulos, con sus vistas y endpoints |
| `{{endpoints}}` | Todos los endpoints por módulo |
| `{{tablas}}` | Tablas de la base, agrupadas por prefijo |
| `{{roles}}` | Roles, usuarios activos y sus permisos |
| `{{servicios}}` | Clases de `core/`, `services/` y `classes/` |
Así los inventarios no pueden quedar desactualizados. La descripción de cada
endpoint sale de su comentario de cabecera: si lo escribís bien, aparece bien.
## Documentos anteriores
`DOCUMENTACION_LAB.md` y `README_LAB.md` quedaron de una etapa previa y están
desactualizados. `WEBHOOK_ENDPOINTS.md` se migró a la sección técnica.
+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 Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
```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)
+57 -1
View File
@@ -21,6 +21,53 @@ requireAuthentication();
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0); $adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
$db = Database::getInstance(); $db = Database::getInstance();
/**
* Le pide el teléfono a quien lo tiene oculto en WhatsApp, una sola vez.
*
* Solo aplica a quien se identifica con un BSUID: de esa persona no tenemos
* número, y sin él el laboratorio no puede llamarla. Se le manda el botón que
* Meta dispone para esto y ella decide si lo comparte; si acepta, el webhook
* recibe el teléfono y lo vincula solo.
*
* No se insiste: pedir los datos una vez es razonable, repetirlo en cada
* trámite es acoso. Tampoco se interrumpe la creación de la ficha si el envío
* falla, porque la ficha es lo importante.
*
* @return string qué pasó, para que la interfaz lo pueda mostrar
*/
function pedirContactoSiHaceFalta(Database $db, int $userId): string {
$u = $db->fetch(
'SELECT phone_number, contacto_pedido_at FROM users WHERE id = ?',
[$userId]
);
if (!$u) return 'usuario_no_encontrado';
if (!esBsuid($u['phone_number'])) return 'no_hace_falta'; // ya tenemos su número
if (!empty($u['contacto_pedido_at'])) return 'ya_se_pidio';
try {
require_once __DIR__ . '/../../services/WhatsAppService.php';
$wa = new WhatsAppService();
$texto = getConfigFromDB(
'whatsapp_texto_pedir_contacto',
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?'
);
$r = $wa->pedirContacto($u['phone_number'], $texto);
if (!$r) {
error_log('[crear_desde_whatsapp] WhatsApp rechazó la solicitud de contacto del usuario ' . $userId);
return 'fallo_envio';
}
// Se marca solo si Meta aceptó: si falló, hay que poder reintentarlo
$db->update('users', ['contacto_pedido_at' => date('Y-m-d H:i:s')], 'id = ?', [$userId]);
return 'pedido';
} catch (Exception $e) {
error_log('[crear_desde_whatsapp] Error pidiendo el contacto: ' . $e->getMessage());
return 'fallo_envio';
}
}
// ── GET: solo_paciente ───────────────────────────────────────────────────── // ── GET: solo_paciente ─────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) { if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
$convId = (int)($_GET['conversation_id'] ?? 0); $convId = (int)($_GET['conversation_id'] ?? 0);
@@ -57,7 +104,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
$pacienteRepo = new Paciente(); $pacienteRepo = new Paciente();
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']); $pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
$paciente = $pacienteRepo->obtener($pacienteId); $paciente = $pacienteRepo->obtener($pacienteId);
echo json_encode(['success' => true, 'paciente' => $paciente]);
// Si la persona oculta su teléfono, la ficha queda sin número. Es el momento
// de pedírselo: se le manda el botón de WhatsApp una sola vez.
$contactoPedido = pedirContactoSiHaceFalta($db, (int) $conv['user_id']);
echo json_encode([
'success' => true,
'paciente' => $paciente,
'contacto_pedido' => $contactoPedido,
]);
exit; exit;
} }
+7 -3
View File
@@ -29,9 +29,11 @@ if (!isUserLoggedIn()) {
$body = json_decode(file_get_contents('php://input'), true); $body = json_decode(file_get_contents('php://input'), true);
if (!$body) $body = $_POST; if (!$body) $body = $_POST;
$envioId = (int)($body['envio_id'] ?? 0); $envioId = (int)($body['envio_id'] ?? 0);
$campoId = trim($body['campo_id'] ?? ''); $campoId = trim($body['campo_id'] ?? '');
$svg = $body['svg'] ?? ''; $svg = $body['svg'] ?? '';
$proNombre = trim($body['pro_nombre'] ?? '');
$proCedula = trim($body['pro_cedula'] ?? '');
if (!$envioId) err('envio_id requerido'); if (!$envioId) err('envio_id requerido');
if ($campoId === '') err('campo_id requerido'); if ($campoId === '') err('campo_id requerido');
@@ -72,6 +74,8 @@ if (!$campoDef || ($campoDef['tipo'] ?? '') !== 'firma_profesional') {
// ── Actualizar datos_cliente: guardar solo en el campo firmado ── // ── Actualizar datos_cliente: guardar solo en el campo firmado ──
$datos = json_decode($envio['datos_cliente'] ?? '{}', true) ?? []; $datos = json_decode($envio['datos_cliente'] ?? '{}', true) ?? [];
$datos[$campoId . '_svg'] = $svg; $datos[$campoId . '_svg'] = $svg;
if ($proNombre !== '') $datos['_pro_nombre'] = $proNombre;
if ($proCedula !== '') $datos['_pro_cedula'] = $proCedula;
$nuevosDatos = json_encode($datos, JSON_UNESCAPED_UNICODE); $nuevosDatos = json_encode($datos, JSON_UNESCAPED_UNICODE);
$nuevoEstado = $envio['estado'] === 'firmado' ? 'firmado' : 'firmado'; $nuevoEstado = $envio['estado'] === 'firmado' ? 'firmado' : 'firmado';
+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,
]);
+10 -4
View File
@@ -55,12 +55,18 @@ try {
if (!empty($datos['id'])) { if (!empty($datos['id'])) {
// Actualización general // Actualización general
$id = (int)$datos['id']; $id = (int)$datos['id'];
// Enfermero solo puede editar domicilios que él mismo creó // Enfermero puede editar domicilios que creó o que tiene asignados
if (userRole() === 'enfermero') { if (userRole() === 'enfermero') {
$dbCheck = Database::getInstance(); $dbCheck = Database::getInstance();
$chk = $dbCheck->fetchOne("SELECT creado_por FROM lab_domicilios WHERE id = ?", [$id]); $chk = $dbCheck->fetchOne(
if (!$chk || (int)($chk['creado_por'] ?? 0) !== adminId()) { "SELECT d.creado_por,
jsonError('Solo puedes editar domicilios que tú mismo agendaste.'); (SELECT COUNT(*) FROM lab_asignaciones a
WHERE a.domicilio_id = d.id AND a.enfermera_id = ?) AS asignado
FROM lab_domicilios d WHERE d.id = ?",
[enfermeraId(), $id]
);
if (!$chk || ((int)($chk['creado_por'] ?? 0) !== adminId() && !(int)$chk['asignado'])) {
jsonError('Solo puedes editar domicilios que agendaste o que tienes asignados.');
} }
} }
unset($datos['id'], $datos['solo_estado']); unset($datos['id'], $datos['solo_estado']);
+2 -2
View File
@@ -17,7 +17,7 @@ $payload = json_encode([
'generationConfig' => ['maxOutputTokens' => 10, 'temperature' => 0], 'generationConfig' => ['maxOutputTokens' => 10, 'temperature' => 0],
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
$ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$key}"); $ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key={$key}");
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true, CURLOPT_POST => true,
@@ -37,4 +37,4 @@ if ($code !== 200) {
jsonError($gemini['error']['message'] ?? "Error HTTP {$code}", 502); jsonError($gemini['error']['message'] ?? "Error HTTP {$code}", 502);
} }
jsonOk(['modelo' => 'gemini-2.0-flash', 'respuesta' => $gemini['candidates'][0]['content']['parts'][0]['text'] ?? '']); jsonOk(['modelo' => 'gemini-3.5-flash', 'respuesta' => $gemini['candidates'][0]['content']['parts'][0]['text'] ?? '']);
+112 -6
View File
@@ -113,24 +113,41 @@ class WhatsAppWebhook {
return; 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 = []; $contactNames = [];
if (isset($value['contacts']) && is_array($value['contacts'])) { if (isset($value['contacts']) && is_array($value['contacts'])) {
foreach ($value['contacts'] as $contact) { foreach ($value['contacts'] as $contact) {
$waId = $contact['wa_id'] ?? null;
$name = $contact['profile']['name'] ?? null; $name = $contact['profile']['name'] ?? null;
if ($waId && $name) { if (!$name) continue;
$contactNames[$waId] = $name; foreach ([$contact['wa_id'] ?? null, $contact['user_id'] ?? null] as $clave) {
if ($clave) $contactNames[$clave] = $name;
} }
} }
} }
foreach ($items as $message) { 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); $phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
$messageId = $message['id'] ?? ($message['message_id'] ?? null); $messageId = $message['id'] ?? ($message['message_id'] ?? null);
$timestamp = $message['timestamp'] ?? 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 // Si falta lo crítico, saltar
if (empty($phoneNumber) || empty($messageId)) { if (empty($phoneNumber) || empty($messageId)) {
continue; continue;
@@ -148,7 +165,7 @@ class WhatsAppWebhook {
// Obtener o crear usuario // Obtener o crear usuario
$user = $this->getUserByPhone($phoneNumber); $user = $this->getUserByPhone($phoneNumber);
$contactName = $contactNames[$phoneNumber] ?? null; $contactName = $contactNames[$phoneNumber] ?? ($bsuid ? ($contactNames[$bsuid] ?? null) : null);
if (!$user) { if (!$user) {
$userId = $this->createUser($phoneNumber); $userId = $this->createUser($phoneNumber);
$user = $this->getUserById($userId); $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 // Procesar diferentes tipos de mensaje
$messageText = ''; $messageText = '';
$messageType = 'text'; $messageType = 'text';
@@ -191,6 +222,28 @@ class WhatsAppWebhook {
'reaction_emoji' => $emoji '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'])) { } elseif (isset($message['interactive'])) {
// Interactive replies (list or button) - normalize to text so bot can process // Interactive replies (list or button) - normalize to text so bot can process
$messageType = 'text'; $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) { private function getUserByPhone($phoneNumber) {
return $this->db->fetch( return $this->db->fetch(
"SELECT * FROM users WHERE phone_number = :phone", "SELECT * FROM users WHERE phone_number = :phone",
+35 -3
View File
@@ -7,6 +7,15 @@
require_once __DIR__ . '/../../classes/Database.php'; require_once __DIR__ . '/../../classes/Database.php';
require_once __DIR__ . '/ActividadAdmin.php'; require_once __DIR__ . '/ActividadAdmin.php';
// esBsuid() vive en config.php; se garantiza aquí por si esta clase se incluye
// directamente, sin pasar por el arranque del ERP.
if (!function_exists('esBsuid')) {
$configPaciente = __DIR__ . '/../../config/config.php';
if (file_exists($configPaciente)) {
require_once $configPaciente;
}
}
class Paciente { class Paciente {
private Database $db; private Database $db;
@@ -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]); $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; return $ok > 0;
} }
@@ -244,10 +267,19 @@ class Paciente {
[$userId] [$userId]
); );
// Quien oculta su teléfono en WhatsApp se identifica con un BSUID, que ocupa
// el lugar del número en `users`. No es un teléfono: guardarlo aquí dejaría
// en la historia clínica un dato falso con apariencia de número real, porque
// normalizarTelefono() le quita el punto y las letras y lo deja en 16 dígitos.
// Mejor la ficha sin teléfono, que es la verdad: no lo tenemos.
$identificador = $user['phone_number'] ?? null;
$esIdentificadorSinTelefono = esBsuid($identificador);
return $this->crear([ return $this->crear([
'user_id' => $userId, 'user_id' => $userId,
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']), 'nombre_completo'=> $user['name']
'telefono' => $user['phone_number'] ?? null, ?? ($esIdentificadorSinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $identificador),
'telefono' => $esIdentificadorSinTelefono ? null : $identificador,
]); ]);
} }
+19
View File
@@ -62,6 +62,7 @@ define('SYSTEM_MODULES', [
// ── Sistema ────────────────────────────────────────────────────────────── // ── Sistema ──────────────────────────────────────────────────────────────
'usuarios' => 'Gestión de Usuarios', 'usuarios' => 'Gestión de Usuarios',
'enfermero_portal' => 'Portal Enfermero', 'enfermero_portal' => 'Portal Enfermero',
'soporte' => 'Soporte y Documentación',
// ── Oleada 1 — Turnero ─────────────────────────────────────────────────── // ── Oleada 1 — Turnero ───────────────────────────────────────────────────
'turnero' => 'Turnero', 'turnero' => 'Turnero',
// ── Oleada 2 — pendiente ───────────────────────────────────────────────── // ── Oleada 2 — pendiente ─────────────────────────────────────────────────
@@ -338,6 +339,24 @@ function deleteConfigFromDB($key) {
} }
} }
if (!function_exists('esBsuid')) {
/**
* ¿Este identificador es un BSUID de Meta y no un número de teléfono?
*
* Desde que WhatsApp permite ocultar el número, quien lo oculta llega
* identificado solo por su BSUID, con la forma "CO.1761088155094242".
* Ese valor ocupa el lugar del teléfono dentro del bot, así que hay que
* distinguirlo antes de tratarlo como si fuera un número real: guardarlo
* en un campo de teléfono deja un dato falso con toda la pinta de verdadero.
*
* @param mixed $valor
* @return bool
*/
function esBsuid($valor) {
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $valor);
}
}
/** /**
* Limpia el cache estático de configuraciones * Limpia el cache estático de configuraciones
*/ */
+19 -14
View File
@@ -162,25 +162,30 @@ class App
$base = '/erp.php?m=turnero&v=lugar&lugar_id='; $base = '/erp.php?m=turnero&v=lugar&lugar_id=';
try { try {
$pdo = Database::getInstance()->getConnection(); $pdo = Database::getInstance()->getConnection();
$ip = self::clientIp(); // 1. Por token de navegador
// ¿IP registrada? $tok = trim($_COOKIE['turnero_token'] ?? '');
$dev = $pdo->prepare( if ($tok) {
"SELECT lugar_id FROM turnero_dispositivos WHERE ip = ? AND activo = 1 LIMIT 1" $s = $pdo->prepare(
); "SELECT lugar_id FROM turnero_dispositivos WHERE token = ? AND activo = 1 LIMIT 1"
$dev->execute([$ip]); );
$row = $dev->fetch(PDO::FETCH_ASSOC); $s->execute([$tok]);
if ($row) { $row = $s->fetch(PDO::FETCH_ASSOC);
return $base . (int)$row['lugar_id']; if ($row) return $base . (int)$row['lugar_id'];
} }
// Primer lugar de toma de muestras // 2. Por IP
$ip = self::clientIp();
$s = $pdo->prepare(
"SELECT lugar_id FROM turnero_dispositivos WHERE ip = ? AND token IS NULL AND activo = 1 LIMIT 1"
);
$s->execute([$ip]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if ($row) return $base . (int)$row['lugar_id'];
// 3. Primer lugar de toma de muestras
$first = $pdo->query( $first = $pdo->query(
"SELECT id FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order LIMIT 1" "SELECT id FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order LIMIT 1"
)->fetch(PDO::FETCH_ASSOC); )->fetch(PDO::FETCH_ASSOC);
if ($first) { if ($first) return $base . (int)$first['id'];
return $base . (int)$first['id'];
}
} catch (\Throwable $_) {} } catch (\Throwable $_) {}
// Fallback: turnero sin vista específica
return '/erp.php?m=turnero'; return '/erp.php?m=turnero';
} }
+8
View File
@@ -23,6 +23,14 @@ class Router
private const PUBLIC_ROUTES = [ private const PUBLIC_ROUTES = [
'turnero/display', 'turnero/display',
'turnero/kiosko', 'turnero/kiosko',
// Tablet de firma del paciente: la manipula el público y nadie va a
// iniciar sesión en ella cada mañana. No queda abierta: se identifica
// por la cookie del dispositivo y sin ella no muestra dato alguno.
'turnero/firma',
// Prueba de voces: hay que abrirla EN el televisor para saber qué voces
// tiene ese equipo, y allí no hay sesión iniciada. No expone nada: solo
// lista las voces del navegador y lee una frase de ejemplo inventada.
'turnero/voces',
]; ];
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */ /** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
+66 -6
View File
@@ -16,11 +16,11 @@ $enfId = (int)($usuario['enfermera_id'] ?? 0);
$enfermeraNombre = $usuario['full_name'] ?? $usuario['username'] ?? 'El profesional de salud'; $enfermeraNombre = $usuario['full_name'] ?? $usuario['username'] ?? 'El profesional de salud';
// Si es admin puede simular ver la agenda de otra enfermera vía ?eid=X // Si es admin puede simular ver la agenda de otra enfermera vía ?eid=X
if ($rol === 'admin' && isset($_GET['eid'])) { if (in_array($rol, ['admin', 'superadmin']) && isset($_GET['eid'])) {
$enfId = (int)$_GET['eid']; $enfId = (int)$_GET['eid'];
} }
if ($rol !== 'admin' && $rol !== 'enfermero') { if (!in_array($rol, ['admin', 'superadmin', 'enfermero'])) {
header('Location: index.php'); exit; header('Location: index.php'); exit;
} }
@@ -761,7 +761,7 @@ const portal = {
? (()=>{ ? (()=>{
const tel = item.paciente_telefono.replace(/\D/g,''); const tel = item.paciente_telefono.replace(/\D/g,'');
const txt = encodeURIComponent( const txt = encodeURIComponent(
'Hola, soy ' + ENFERMERO_NOMBRE + ', el profesional de salud asignado a su atenci\u00f3n. '+ 'Hola, soy ' + ENFERMERO_NOMBRE + ' del Laboratorio Ximena Caicedo, el profesional de salud asignado a su atenci\u00f3n. '+
'Me comunico con ' + (item.paciente_nombre||'usted') + ' para confirmar la visita programada. \u00bfTiene alguna pregunta?' 'Me comunico con ' + (item.paciente_nombre||'usted') + ' para confirmar la visita programada. \u00bfTiene alguna pregunta?'
); );
return `<a class="btn btn-outline-success btn-accion" return `<a class="btn btn-outline-success btn-accion"
@@ -783,6 +783,11 @@ const portal = {
onclick="formVer.abrir(${item.paciente_id}, '${esc(item.paciente_nombre||'')}')"> onclick="formVer.abrir(${item.paciente_id}, '${esc(item.paciente_nombre||'')}')">
<i class="fas fa-folder-open me-1"></i>Ver llenados <i class="fas fa-folder-open me-1"></i>Ver llenados
</button>` : ''} </button>` : ''}
${!['completado','cancelado'].includes(est) ? `
<button class="btn btn-accion" style="background:#0d6efd;color:#fff"
onclick="agendaNueva.abrirEditar(${item.domicilio_id})">
<i class="fas fa-pen me-1"></i>Editar
</button>` : ''}
</div> </div>
</div> </div>
</div>`; </div>`;
@@ -1452,6 +1457,7 @@ const agendaNueva = {
_modal: null, _modal: null,
_buscarTimer: null, _buscarTimer: null,
_nuevoPac: false, _nuevoPac: false,
_editId: null,
init() { init() {
this._modal = new bootstrap.Modal('#modalNuevaAgenda'); this._modal = new bootstrap.Modal('#modalNuevaAgenda');
@@ -1500,12 +1506,63 @@ const agendaNueva = {
document.getElementById('na-np-email').value = ''; document.getElementById('na-np-email').value = '';
document.getElementById('na-crear-toggle-txt').textContent = 'Crear nuevo paciente'; document.getElementById('na-crear-toggle-txt').textContent = 'Crear nuevo paciente';
document.getElementById('na-paciente-buscar').disabled = false; document.getElementById('na-paciente-buscar').disabled = false;
this._editId = null;
const btn = document.getElementById('na-btn-guardar'); const btn = document.getElementById('na-btn-guardar');
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i>Agendar'; btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i>Agendar';
document.querySelector('#modalNuevaAgenda .modal-title').textContent = 'Nuevo domicilio';
this._modal.show(); this._modal.show();
}, },
async abrirEditar(domId) {
try {
const r = await fetch(`api/lab/get_domicilios.php?id=${domId}`);
const d = await r.json();
const dom = d.domicilio || d.data?.[0] || null;
if (!dom) { alert('No se pudo cargar el domicilio.'); return; }
this.abrir(); // resetea el form
this._editId = domId;
// Paciente
if (dom.paciente_id) {
document.getElementById('na-paciente-id').value = dom.paciente_id;
document.getElementById('na-paciente-buscar').value = dom.paciente_nombre || '';
document.getElementById('na-paciente-label').textContent = dom.paciente_nombre || '';
document.getElementById('na-paciente-elegido').classList.remove('d-none');
document.getElementById('na-sugerencias').classList.add('d-none');
}
// Campos
document.getElementById('na-direccion').value = dom.direccion || '';
document.getElementById('na-barrio').value = dom.barrio || '';
document.getElementById('na-ciudad').value = dom.ciudad || '';
document.getElementById('na-indicaciones').value = dom.indicaciones_dir || '';
document.getElementById('na-fecha').value = dom.fecha_programada || '';
document.getElementById('na-hora').value = (dom.hora_programada || '').slice(0,5);
document.getElementById('na-notas').value = dom.notas_admin || '';
document.getElementById('na-valor-dom').value = dom.valor_domicilio || '';
document.getElementById('na-valor-cop').value = dom.valor_copago || '';
// Tipo cliente
const tc = dom.tipo_cliente === 'seguro' ? 'na-tc-seguro' : 'na-tc-particular';
const tcEl = document.getElementById(tc);
if (tcEl) { tcEl.checked = true; this._toggleSeguro(); }
if (dom.seguro_nombre) {
const sn = document.getElementById('na-seguro-nombre');
if (sn) sn.value = dom.seguro_nombre;
}
// Botón y título
const btn = document.getElementById('na-btn-guardar');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar cambios';
document.querySelector('#modalNuevaAgenda .modal-title').textContent = 'Editar domicilio';
} catch(e) {
alert('Error al cargar domicilio: ' + e.message);
}
},
async _buscarPacientes(q) { async _buscarPacientes(q) {
if (q.length < 2) { if (q.length < 2) {
document.getElementById('na-sugerencias').classList.add('d-none'); document.getElementById('na-sugerencias').classList.add('d-none');
@@ -1783,8 +1840,8 @@ const agendaNueva = {
valor_domicilio: parseFloat(document.getElementById('na-valor-dom').value) || null, valor_domicilio: parseFloat(document.getElementById('na-valor-dom').value) || null,
valor_copago: parseFloat(document.getElementById('na-valor-cop').value) || null, valor_copago: parseFloat(document.getElementById('na-valor-cop').value) || null,
notas_admin: document.getElementById('na-notas').value.trim() || null, notas_admin: document.getElementById('na-notas').value.trim() || null,
estado: 'programado', ...(this._editId ? { id: this._editId } : { estado: 'programado' }),
...(ENFERMERA_ID ? { enfermera_id: ENFERMERA_ID } : {}), ...(ENFERMERA_ID && !this._editId ? { enfermera_id: ENFERMERA_ID } : {}),
}; };
try { try {
@@ -1814,10 +1871,13 @@ const agendaNueva = {
} }
this._modal.hide(); this._modal.hide();
this._editId = null;
portal.cargar(); portal.cargar();
} catch (e) { } catch (e) {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i>Agendar'; btn.innerHTML = this._editId
? '<i class="fas fa-save me-1"></i>Guardar cambios'
: '<i class="fas fa-calendar-check me-1"></i>Agendar';
this._mostrarError(e.message); this._mostrarError(e.message);
} }
}, },
+49 -40
View File
@@ -329,8 +329,9 @@ if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
</div> </div>
<div class="topaz-modal-body"> <div class="topaz-modal-body">
<div class="topaz-pad-area" id="topaz-pad-area"> <div class="topaz-pad-area" id="topaz-pad-area">
<i class="fas fa-signature fa-2x" style="color:#adb5bd" id="topaz-pad-icon"></i> <canvas id="topaz-canvas" width="500" height="150"
<div id="topaz-status-msg" style="font-size:.9rem;color:#64748b;font-weight:600"> style="border:1px solid #e2e8f0;border-radius:6px;background:#fff;max-width:100%;display:block;margin:0 auto"></canvas>
<div id="topaz-status-msg" style="font-size:.9rem;color:#64748b;font-weight:600;margin-top:8px">
Firme en el pad biométrico Firme en el pad biométrico
</div> </div>
<div class="topaz-pts-badge">Trazos: <span id="topaz-pts">0</span></div> <div class="topaz-pts-badge">Trazos: <span id="topaz-pts">0</span></div>
@@ -597,12 +598,12 @@ function renderCampos(esquema, prefilled) {
</div>` + wrapClose; </div>` + wrapClose;
} else if (c.tipo === 'radio') { } else if (c.tipo === 'radio') {
const opts = (c.options||[]).map(o => ` const opts = (c.options||[]).map(o => `
<div class="form-check"> <div class="form-check form-check-inline me-3">
<input class="form-check-input" type="radio" name="${c.id}" value="${esc(o)}" <input class="form-check-input" type="radio" name="${c.id}" value="${esc(o)}"
${o===val?'checked':''} ${isReadOnly?'disabled':''}> ${o===val?'checked':''} ${isReadOnly?'disabled':''}>
<label class="form-check-label">${esc(o)}</label> <label class="form-check-label">${esc(o)}</label>
</div>`).join(''); </div>`).join('');
html += wrapOpen + `<div class="mb-3">${lbl}${opts}${linkedNote}</div>` + wrapClose; html += wrapOpen + `<div class="mb-3">${lbl}<div class="d-flex flex-wrap gap-1">${opts}</div>${linkedNote}</div>` + wrapClose;
} else if (c.tipo === 'checkbox') { } else if (c.tipo === 'checkbox') {
const vals = Array.isArray(val) ? val : []; const vals = Array.isArray(val) ? val : [];
const opts = (c.options||[]).map(o => ` const opts = (c.options||[]).map(o => `
@@ -1233,7 +1234,8 @@ function iniciarCondiciones(esquema) {
} else { } else {
const radio = ctrl.querySelector(`input[type="radio"]:checked`); const radio = ctrl.querySelector(`input[type="radio"]:checked`);
const sel = ctrl.querySelector('select'); const sel = ctrl.querySelector('select');
const v = radio ? radio.value : (sel ? sel.value : ''); const inp = ctrl.querySelector('input[type="text"],input[type="hidden"]');
const v = radio ? radio.value : (sel ? sel.value : (inp ? inp.value : ''));
activo = valoresCond.includes(v); activo = valoresCond.includes(v);
} }
@@ -1270,17 +1272,8 @@ function iniciarCondiciones(esquema) {
// TOPAZ SIGWEB // TOPAZ SIGWEB
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
const topaz = (() => { const topaz = (() => {
const SIGWEB_URL = 'http://localhost:47289/SigWeb/SigWebTablet.js'; const SIGWEB_URL = '/assets/js/SigWebTablet.js';
let _loaded = false, _ctx = null, _fid = null, _poll = null; let _loaded = false, _fid = null, _poll = null, _tmr = null;
function _call(fn, ...args) {
// SigWeb v1: global functions with ctx as last param
// SigWeb v2: methods on ctx object
if (_ctx && typeof _ctx[fn] === 'function') return _ctx[fn](...args);
const g = window[fn];
if (typeof g === 'function') return _ctx ? g(...args, _ctx) : g(...args);
throw new Error('SigWeb: ' + fn + ' no encontrado');
}
function _loadScript() { function _loadScript() {
if (_loaded) return Promise.resolve(true); if (_loaded) return Promise.resolve(true);
@@ -1312,12 +1305,14 @@ const topaz = (() => {
} }
_fid = fid; _fid = fid;
try { try {
_ctx = typeof SigWebTablet !== 'undefined' ? new SigWebTablet() : null; const canvas = document.getElementById('topaz-canvas');
_call('SetImageXSize', 500); const canvasCtx = canvas.getContext('2d');
_call('SetImageYSize', 150); canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
_call('SetImagePenWidth', 3); SetImageXSize(500);
_call('SetTabletState', 1); SetImageYSize(150);
_call('ClearTablet'); SetImagePenWidth(3);
ClearTablet();
_tmr = SetTabletState(1, canvasCtx, 50);
} catch(e) { } catch(e) {
alert('Error al activar el pad: ' + e.message); alert('Error al activar el pad: ' + e.message);
return; return;
@@ -1327,13 +1322,30 @@ const topaz = (() => {
$('topaz-pts').textContent = '0'; $('topaz-pts').textContent = '0';
_setStatus('Firme en el pad biométrico', false); _setStatus('Firme en el pad biométrico', false);
let _lastPts = -1;
_poll = setInterval(() => { _poll = setInterval(() => {
try { try {
const pts = _call('GetSigTotalPoints'); const pts = NumberOfTabletPoints();
$('topaz-pts').textContent = pts; $('topaz-pts').textContent = pts;
const hasSig = pts > 0; const hasSig = pts > 0;
$('topaz-btn-aceptar').disabled = !hasSig; $('topaz-btn-aceptar').disabled = !hasSig;
if (hasSig) _setStatus('✅ Firma detectada — presione Aceptar', true); if (hasSig) {
_setStatus('✅ Firma detectada — presione Aceptar', true);
if (pts !== _lastPts) {
_lastPts = pts;
GetSigImageB64(function(b64) {
if (!b64) return;
const img = new Image();
img.onload = () => {
const cv = document.getElementById('topaz-canvas');
const cx = cv.getContext('2d');
cx.clearRect(0, 0, cv.width, cv.height);
cx.drawImage(img, 0, 0, cv.width, cv.height);
};
img.src = 'data:image/png;base64,' + b64;
});
}
}
} catch(e) { _stopPoll(); } } catch(e) { _stopPoll(); }
}, 400); }, 400);
} }
@@ -1342,8 +1354,8 @@ const topaz = (() => {
function _cerrarOverlay() { function _cerrarOverlay() {
_stopPoll(); _stopPoll();
try { if (_ctx) _call('SetTabletState', 0); } catch(e) {} try { SetTabletState(0, _tmr); } catch(e) {}
_ctx = null; _fid = null; _tmr = null; _fid = null;
$('topaz-overlay').style.display = 'none'; $('topaz-overlay').style.display = 'none';
} }
@@ -1357,18 +1369,13 @@ const topaz = (() => {
} }
function aceptar() { function aceptar() {
let b64; const fid = _fid;
try { b64 = _call('GetSigImageB64'); } catch(e) { _cerrarOverlay();
alert('Error al capturar la firma: ' + e.message); try {
_cerrarOverlay(); return; GetSigImageB64(function(b64) {
} if (!b64) { alert('No se capturó ninguna firma.'); return; }
if (!b64) { alert('No se capturó ninguna firma.'); return; } const img = new Image();
img.onload = () => {
const img = new Image();
img.onload = () => {
const fid = _fid;
_cerrarOverlay();
if (fid === '__global') { if (fid === '__global') {
const c = $('firma-canvas'); const c = $('firma-canvas');
const cx = c.getContext('2d'); const cx = c.getContext('2d');
@@ -1391,8 +1398,10 @@ const topaz = (() => {
const st = document.getElementById('fw-' + fid + '-status'); const st = document.getElementById('fw-' + fid + '-status');
if (st) { st.textContent = '✅ Firmado (tableta)'; st.className = 'small text-success align-self-center fw-semibold'; } if (st) { st.textContent = '✅ Firmado (tableta)'; st.className = 'small text-success align-self-center fw-semibold'; }
} }
}; };
img.src = 'data:image/png;base64,' + b64; img.src = 'data:image/png;base64,' + b64;
});
} catch(e) { alert('Error al capturar la firma: ' + e.message); }
} }
return { activar, cancelar, limpiarPad, aceptar }; return { activar, cancelar, limpiarPad, aceptar };
+8 -1
View File
@@ -19,9 +19,16 @@ if (isEnfermero()) {
} }
$_indexRole = $_SESSION['admin_user']['role'] ?? ''; $_indexRole = $_SESSION['admin_user']['role'] ?? '';
$_indexModules = $_SESSION['admin_user']['modules'] ?? []; $_indexModules = $_SESSION['admin_user']['modules'] ?? [];
if ($_indexRole === 'recepcionista' || (in_array('turnero', $_indexModules, true) && !in_array('whatsapp', $_indexModules, true))) { if ($_indexRole === 'recepcionista') {
header('Location: erp.php?m=turnero&v=recepcion'); exit; header('Location: erp.php?m=turnero&v=recepcion'); exit;
} }
if (in_array('turnero', $_indexModules, true) && !in_array('whatsapp', $_indexModules, true)) {
$_indexAdminRoles = ['superadmin', 'admin', 'supervisor', 'bacteriologo'];
$url = in_array($_indexRole, $_indexAdminRoles, true)
? 'erp.php?m=turnero&v=dashboard'
: 'erp.php?m=turnero&v=recepcion';
header('Location: ' . $url); exit;
}
if (in_array('lab_dashboard', $_indexModules, true) && !in_array('whatsapp', $_indexModules, true)) { if (in_array('lab_dashboard', $_indexModules, true) && !in_array('whatsapp', $_indexModules, true)) {
header('Location: lab_dashboard.php'); exit; header('Location: lab_dashboard.php'); exit;
} }
+66
View File
@@ -70,6 +70,8 @@ require_once __DIR__ . '/shared/components/sidebar.php';
<option value="enfermeras">Enfermeras</option> <option value="enfermeras">Enfermeras</option>
<option value="formularios">Formularios</option> <option value="formularios">Formularios</option>
<option value="asignaciones">Asignaciones</option> <option value="asignaciones">Asignaciones</option>
<option value="turnero">Turnero</option>
<option value="turnero_config">Turnero · configuración</option>
</select> </select>
</div> </div>
<div class="col-auto"> <div class="col-auto">
@@ -85,6 +87,11 @@ require_once __DIR__ . '/shared/components/sidebar.php';
<option value="enviar">enviar</option> <option value="enviar">enviar</option>
<option value="reprogramar">reprogramar</option> <option value="reprogramar">reprogramar</option>
<option value="cambiar_estado">cambiar_estado</option> <option value="cambiar_estado">cambiar_estado</option>
<option value="quitar_consentimiento">quitar_consentimiento</option>
<option value="resetear_consentimiento">resetear_consentimiento</option>
<option value="resetear_toma">resetear_toma</option>
<option value="cancelar_toma_pendiente">cancelar_toma_pendiente</option>
<option value="vincular_paciente">vincular_paciente</option>
</select> </select>
</div> </div>
<div class="col"> <div class="col">
@@ -104,6 +111,18 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</div> </div>
<!-- Tabla --> <!-- Tabla -->
<!-- Salud de la voz del televisor: alimentada por turnero_tv_log.
Responde de un vistazo «¿la pantalla está llamando bien?» sin
tener que consultar la base a mano. -->
<div class="card border-0 shadow-sm mb-3" id="card-voz-tv" style="display:none">
<div class="card-body py-3">
<h6 class="mb-2"><i class="fas fa-volume-up text-primary me-1"></i>
Voz del televisor — últimos 7 días</h6>
<div class="d-flex gap-4 flex-wrap mb-2" id="voz-tv-resumen"></div>
<div id="voz-tv-incidentes" class="small"></div>
</div>
</div>
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div id="act-spinner" class="text-center py-5 text-muted"> <div id="act-spinner" class="text-center py-5 text-muted">
@@ -270,6 +289,53 @@ function verDetalle(i) {
new bootstrap.Modal(document.getElementById('modalDetalle')).show(); new bootstrap.Modal(document.getElementById('modalDetalle')).show();
} }
// ── Salud de la voz del televisor ────────────────────────────
const VOZ_EVENTO_TXT = {
mudo: ['🔇 No sonó', 'text-danger', 'El llamado salió sin voz: ni el reintento habló'],
sin_start: ['⚠️ Arrancó con reintento','text-warning','La voz elegida no arrancó; habló la del navegador'],
error: ['⚠️ Error del motor', 'text-warning', 'El motor de voz falló a mitad; se reintentó'],
lag: ['⏸ Pantalla congelada', 'text-warning', 'La pestaña estuvo oculta o el equipo suspendido: en ese lapso no ve llamados. En el televisor debe estar siempre visible'],
};
async function cargarVozTV() {
try {
const r = await fetch('api/lab/get_tv_log.php');
const d = await r.json();
if (!d.ok) return;
// Sin datos todavía (recién desplegado): la tarjeta no aparece
if (!d.anuncios_7d && !d.incidentes.length) return;
document.getElementById('card-voz-tv').style.display = '';
const pct = d.anuncios_7d ? Math.round(d.completos_7d / d.anuncios_7d * 100) : 0;
document.getElementById('voz-tv-resumen').innerHTML = `
<div><span class="fw-bold fs-5">${d.anuncios_7d}</span>
<span class="text-muted small">llamados con voz</span></div>
<div><span class="fw-bold fs-5 ${pct >= 97 ? 'text-success' : 'text-warning'}">${pct}%</span>
<span class="text-muted small">hablados completos</span></div>
<div><span class="fw-bold fs-5 ${d.mudos_7d ? 'text-danger' : 'text-success'}">${d.mudos_7d}</span>
<span class="text-muted small">salieron mudos</span></div>
<div><span class="fw-bold fs-5 ${d.avisos_7d ? 'text-warning' : 'text-success'}">${d.avisos_7d}</span>
<span class="text-muted small">se recuperaron con reintento</span></div>`;
const inc = document.getElementById('voz-tv-incidentes');
if (!d.incidentes.length) {
inc.innerHTML = '<span class="text-success"><i class="fas fa-check-circle me-1"></i>Sin fallos registrados</span>';
return;
}
inc.innerHTML = '<div class="text-muted mb-1">Últimos fallos:</div>' +
d.incidentes.slice(0, 10).map(i => {
const [txt, cls, ayuda] = VOZ_EVENTO_TXT[i.evento] || [i.evento, '', ''];
return `<div class="py-1 border-bottom" title="${ayuda}">
<span class="text-muted">${(i.cuando || '').slice(0, 16)}</span>
· turno <b>${i.turno || '—'}</b>
· <span class="${cls}">${txt}</span>
${i.voz ? `<span class="text-muted"> (voz: ${i.voz})</span>` : ''}
</div>`;
}).join('');
} catch (_) {}
}
cargarVozTV();
async function exportarCSV() { async function exportarCSV() {
const params = new URLSearchParams({ const params = new URLSearchParams({
desde: document.getElementById('act-desde').value, desde: document.getElementById('act-desde').value,
+9 -3
View File
@@ -1614,10 +1614,16 @@ async function ffExportarExcel() {
...fieldCols.map(([l,d])=>{ const v=todos[lmap.get(l)||d]; return v==null?'':(Array.isArray(v)?v.join('; '):String(v)); }), ...fieldCols.map(([l,d])=>{ const v=todos[lmap.get(l)||d]; return v==null?'':(Array.isArray(v)?v.join('; '):String(v)); }),
]); ]);
}); });
const csv = '\uFEFF' + csvRows.map(r=>r.map(v=>'"'+String(v).replace(/"/g,'""')+'"').join(',')).join('\r\n'); const xlsEsc = v => String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const blob = new Blob([csv],{type:'text/csv;charset=utf-8;'}); const toCell = v => `<Cell><Data ss:Type="String">${xlsEsc(v)}</Data></Cell>`;
const xml = '<' + '?xml version="1.0" encoding="UTF-8"?>\n<' + '?mso-application progid="Excel.Sheet"?>\n'
+ `<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">`
+ `<Worksheet ss:Name="Domicilios"><Table>\n`
+ csvRows.map(r => `<Row>${r.map(toCell).join('')}</Row>`).join('\n')
+ `\n</Table></Worksheet></Workbook>`;
const blob = new Blob([xml], {type:'application/vnd.ms-excel;charset=utf-8'});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'),{href:url,download:'formularios_domicilios_'+new Date().toISOString().slice(0,10)+'.csv'}); const a = Object.assign(document.createElement('a'),{href:url,download:'formularios_domicilios_'+new Date().toISOString().slice(0,10)+'.xls'});
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
mostrarToast('✅ Excel descargado — ' + rows.length + ' registro(s)', 'success'); mostrarToast('✅ Excel descargado — ' + rows.length + ' registro(s)', 'success');
} }
+35 -3
View File
@@ -472,6 +472,28 @@ function fmtFecha(str) {
return str.slice(0, 10).split('-').reverse().join('/'); return str.slice(0, 10).split('-').reverse().join('/');
} }
// Quien oculta su número en WhatsApp llega identificado con un BSUID
// ("CO.1761088155094242"), que se guarda en phone_number pero no es un teléfono.
const esBsuid = v => /^[A-Z]{2}\.\d+$/.test(String(v || ''));
/**
* Celda de contacto. Si de esta persona no tenemos teléfono porque lo tiene
* oculto, se dice así en vez de mostrar el identificador crudo: recepción
* necesita entender por qué no puede llamarla, no ver un código.
*/
function celdaTelefono(p) {
if (esBsuid(p.phone_number)) {
return (p.telefono ? esc(p.telefono) : '<span class="text-muted">Sin teléfono</span>')
+ '<br><i class="fab fa-whatsapp text-success"></i> '
+ '<small class="text-muted" title="Tiene el número oculto en WhatsApp. '
+ 'Se le puede escribir por el chat, pero no llamar.">Solo por WhatsApp</small>';
}
return esc(p.telefono || '—')
+ (p.phone_number
? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>`
: '');
}
async function cargarLista(pag = 1) { async function cargarLista(pag = 1) {
paginaActual = pag; paginaActual = pag;
const busq = document.getElementById('buscador').value.trim(); const busq = document.getElementById('buscador').value.trim();
@@ -495,7 +517,7 @@ async function cargarLista(pag = 1) {
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''} ${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
</td> </td>
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td> <td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
<td class="small">${esc(p.telefono||'—')}${p.phone_number ? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>` : ''}</td> <td class="small">${celdaTelefono(p)}</td>
<td class="small text-muted">${esc(p.email||'—')}</td> <td class="small text-muted">${esc(p.email||'—')}</td>
<td class="small text-muted">${esc(p.ciudad||'—')}</td> <td class="small text-muted">${esc(p.ciudad||'—')}</td>
<td class="small text-muted">${esc(p.eps||'—')}</td> <td class="small text-muted">${esc(p.eps||'—')}</td>
@@ -546,8 +568,18 @@ async function verDetalle(id) {
document.getElementById('detail-body').innerHTML = ` document.getElementById('detail-body').innerHTML = `
<dl class="row small mb-3"> <dl class="row small mb-3">
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd> <dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd> <dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd> p.telefono ? esc(p.telefono)
: (esBsuid(p.phone_number)
? '<span class="text-muted">Sin teléfono — lo tiene oculto en WhatsApp</span>'
: '—')
}</dd>
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${
!p.phone_number ? '—'
: (esBsuid(p.phone_number)
? '<i class="fab fa-whatsapp text-success"></i> Se le puede escribir por el chat, pero no llamar'
: `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}`)
}</dd>
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd> <dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd> <dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''} ${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
+14 -10
View File
@@ -35,7 +35,7 @@ if ($tipo) {
ORDER BY o.created_at DESC", ORDER BY o.created_at DESC",
[$desde, $hasta] [$desde, $hasta]
); );
$filename = "ordenes_$desde\_$hasta.csv"; $filename = "ordenes_$desde\_$hasta.xls";
$headers = ['ID','Paciente','Documento','EPS','Estado','Médico','Fecha Orden','Exámenes','Ayuno','H.Ayuno','Creada','Revisada por','Autorizada por']; $headers = ['ID','Paciente','Documento','EPS','Estado','Médico','Fecha Orden','Exámenes','Ayuno','H.Ayuno','Creada','Revisada por','Autorizada por'];
break; break;
@@ -109,7 +109,7 @@ if ($tipo) {
$sqlParams $sqlParams
); );
$rangoLabel = !empty($_GET['fecha']) ? $_GET['fecha'] : "{$desde}_{$hasta}"; $rangoLabel = !empty($_GET['fecha']) ? $_GET['fecha'] : "{$desde}_{$hasta}";
$filename = "domicilios_{$rangoLabel}.csv"; $filename = "domicilios_{$rangoLabel}.xls";
$headers = [ $headers = [
'ID','Paciente','Documento','Teléfono','EPS', 'ID','Paciente','Documento','Teléfono','EPS',
'Fecha','Hora programada','Hora llegada','Hora salida','Duración', 'Fecha','Hora programada','Hora llegada','Hora salida','Duración',
@@ -133,7 +133,7 @@ if ($tipo) {
ORDER BY p.nombre_completo", ORDER BY p.nombre_completo",
[] []
); );
$filename = "pacientes_" . date('Y-m-d') . ".csv"; $filename = "pacientes_" . date('Y-m-d') . ".xls";
$headers = ['ID','Nombre','Tipo Doc.','Documento','Teléfono','Email','EPS','Ciudad','Barrio','Activo','Total Órdenes','Registrado']; $headers = ['ID','Nombre','Tipo Doc.','Documento','Teléfono','Email','EPS','Ciudad','Barrio','Activo','Total Órdenes','Registrado'];
break; break;
@@ -191,7 +191,7 @@ if ($tipo) {
ORDER BY d.fecha_programada ASC, d.id ASC", ORDER BY d.fecha_programada ASC, d.id ASC",
$pParams $pParams
); );
$filename = "reporte_pagos_{$rangoNombre}.csv"; $filename = "reporte_pagos_{$rangoNombre}.xls";
$headers = [ $headers = [
'ID','Fecha programada','Fecha pago','Paciente','Documento', 'ID','Fecha programada','Fecha pago','Paciente','Documento',
'Enfermero/a','Tipo cliente','Seguro','Estado domicilio', 'Enfermero/a','Tipo cliente','Seguro','Estado domicilio',
@@ -204,17 +204,21 @@ if ($tipo) {
exit('Tipo de exportación no válido'); exit('Tipo de exportación no válido');
} }
header('Content-Type: text/csv; charset=utf-8'); header('Content-Type: application/vnd.ms-excel; charset=utf-8');
header("Content-Disposition: attachment; filename=\"$filename\""); header("Content-Disposition: attachment; filename=\"$filename\"");
header('Pragma: no-cache'); header('Pragma: no-cache');
$f = fopen('php://output', 'w'); $esc = fn($v) => htmlspecialchars((string)$v, ENT_XML1, 'UTF-8');
fputs($f, "\xEF\xBB\xBF"); // BOM UTF-8 para Excel echo '<' . '?xml version="1.0" encoding="UTF-8"?' . '>' . "\n";
fputcsv($f, $headers); echo '<' . '?mso-application progid="Excel.Sheet"?' . '>' . "\n";
echo '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"' . "\n";
echo ' xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">' . "\n";
echo '<Worksheet ss:Name="Reporte"><Table>' . "\n";
echo '<Row>' . implode('', array_map(fn($h) => '<Cell><Data ss:Type="String">' . $esc($h) . '</Data></Cell>', $headers)) . '</Row>' . "\n";
foreach ($rows as $row) { foreach ($rows as $row) {
fputcsv($f, array_values($row)); echo '<Row>' . implode('', array_map(fn($v) => '<Cell><Data ss:Type="String">' . $esc($v) . '</Data></Cell>', array_values($row))) . '</Row>' . "\n";
} }
fclose($f); echo '</Table></Worksheet></Workbook>';
exit; exit;
} }
+31 -10
View File
@@ -41,16 +41,31 @@ if ($_POST && !$loginBlocked) {
$_SESSION['login_ip'] = $clientIp; $_SESSION['login_ip'] = $clientIp;
$_SESSION['login_time'] = time(); $_SESSION['login_time'] = time();
// Tablet fija: redirigir al lugar asignado por IP y bloquear el resto // Tablet fija: redirigir al lugar asignado por token o IP
try { try {
$_dispStmt = Database::getInstance()->getConnection()->prepare( $_pdo2 = Database::getInstance()->getConnection();
"SELECT td.lugar_id, td.nombre, tl.tipo $_disp = null;
FROM turnero_dispositivos td $_devToken = trim($_COOKIE['turnero_token'] ?? '');
JOIN turnero_lugares tl ON tl.id = td.lugar_id if ($_devToken) {
WHERE td.ip = ? AND td.activo = 1 LIMIT 1" $_s = $_pdo2->prepare(
); "SELECT td.lugar_id, td.nombre, tl.tipo
$_dispStmt->execute([$clientIp]); FROM turnero_dispositivos td
$_disp = $_dispStmt->fetch(PDO::FETCH_ASSOC); JOIN turnero_lugares tl ON tl.id = td.lugar_id
WHERE td.token = ? AND td.activo = 1 LIMIT 1"
);
$_s->execute([$_devToken]);
$_disp = $_s->fetch(PDO::FETCH_ASSOC) ?: null;
}
if (!$_disp) {
$_s = $_pdo2->prepare(
"SELECT td.lugar_id, td.nombre, tl.tipo
FROM turnero_dispositivos td
JOIN turnero_lugares tl ON tl.id = td.lugar_id
WHERE td.ip = ? AND td.activo = 1 LIMIT 1"
);
$_s->execute([$clientIp]);
$_disp = $_s->fetch(PDO::FETCH_ASSOC) ?: null;
}
if ($_disp) { if ($_disp) {
$_SESSION['turnero_dispositivo'] = $_disp; $_SESSION['turnero_dispositivo'] = $_disp;
$url = $_disp['tipo'] === 'recepcion' $url = $_disp['tipo'] === 'recepcion'
@@ -89,7 +104,13 @@ if ($_POST && !$loginBlocked) {
if ($roleSlug === 'enfermero') { if ($roleSlug === 'enfermero') {
header('Location: enfermero_portal.php'); header('Location: enfermero_portal.php');
} elseif (in_array('turnero', $modules, true)) { } elseif (in_array('turnero', $modules, true)) {
header('Location: erp.php?m=turnero&v=recepcion'); if ($roleSlug === 'bacteriologo') {
header('Location: erp.php?m=turnero&v=dashboard');
} elseif (in_array($roleSlug, ['superadmin', 'admin', 'supervisor'], true)) {
header('Location: erp.php?m=turnero&v=dashboard');
} else {
header('Location: erp.php?m=turnero&v=recepcion');
}
} elseif (in_array('lab_dashboard', $modules, true)) { } elseif (in_array('lab_dashboard', $modules, true)) {
header('Location: lab_dashboard.php'); header('Location: lab_dashboard.php');
} elseif (in_array('lab_formularios', $modules, true)) { } elseif (in_array('lab_formularios', $modules, true)) {
@@ -0,0 +1,44 @@
-- 20260803 — Cambios de esquema del turnero aplicados el 2026-08-03
-- Ya ejecutados en producción; este archivo los deja registrados para
-- reconstruir el esquema desde cero o replicarlo en otro entorno.
-- Todos usan IF NOT EXISTS para ser idempotentes.
-- 1. Cédula del profesional firmante (antes solo existía para enfermeros
-- vía lab_enfermeras; bacteriólogos y recepcionistas no tenían dónde).
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS cedula VARCHAR(30) NULL AFTER cargo;
-- Poblar desde el username cuando es numérico (así estaban registrados
-- bacteriólogos, recepcionistas y superadmin).
UPDATE admin_users SET cedula = username
WHERE cedula IS NULL AND username REGEXP '^[0-9]{5,15}$' AND is_active = 1;
-- 2. Quién generó cada consentimiento del turnero. Sin esto el pie
-- "Enviado por" mostraba al usuario que estuviera viendo el documento.
ALTER TABLE turnero_consentimientos
ADD COLUMN IF NOT EXISTS creado_por INT NULL AFTER estado;
-- 3. En qué turno se recibió una muestra que quedó pendiente de otra visita.
-- El turno original no se modifica; esto solo permite vincularlos.
ALTER TABLE turnero_muestras
ADD COLUMN IF NOT EXISTS recibida_en_turno_id INT UNSIGNED NULL AFTER recibida_at;
-- 4. Playlist de la Pantalla TV (reemplaza la clave única turnero_tv_video
-- de lab_config por una lista ordenable de videos e imágenes).
CREATE TABLE IF NOT EXISTS turnero_tv_media (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
tipo ENUM('video','imagen') NOT NULL,
url VARCHAR(500) NOT NULL,
orden INT NOT NULL DEFAULT 0,
duracion_segundos INT NOT NULL DEFAULT 8,
activo TINYINT(1) NOT NULL DEFAULT 1,
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_orden (activo, orden)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrar el video único que existía en lab_config como primer ítem.
INSERT INTO turnero_tv_media (tipo, url, orden, duracion_segundos, activo)
SELECT 'video', valor, 0, 8, 1 FROM lab_config
WHERE clave = 'turnero_tv_video' AND valor <> ''
AND NOT EXISTS (SELECT 1 FROM turnero_tv_media);
@@ -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 <?php
/**
* GET ?id=N — Detalle de un examen del catálogo con sus ítems.
*/
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
requireLogin(); requireLogin();
+3
View File
@@ -1,4 +1,7 @@
<?php <?php
/**
* GET ?exam_id=N — Tarifas de un examen por empresa o convenio.
*/
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
requireLogin(); requireLogin();
+3
View File
@@ -1,4 +1,7 @@
<?php <?php
/**
* GET ?q=&categoria=&page=&limit= — Catálogo de exámenes, paginado y filtrable.
*/
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
requireLogin(); requireLogin();
+3
View File
@@ -1,4 +1,7 @@
<?php <?php
/**
* POST — Crea o actualiza un examen del catálogo. Requiere administrador.
*/
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
requireAdmin(); requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405); if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
+3
View File
@@ -1,4 +1,7 @@
<?php <?php
/**
* POST — Crea o actualiza un ítem (analito) de un examen. Requiere administrador.
*/
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
requireAdmin(); requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405); if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
+3
View File
@@ -1,4 +1,7 @@
<?php <?php
/**
* POST — Crea o actualiza la tarifa de un examen. Requiere administrador.
*/
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
requireAdmin(); requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405); if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
+3
View File
@@ -1,4 +1,7 @@
<?php <?php
/**
* POST { id } — Elimina un médico del catálogo.
*/
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; } if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
+3
View File
@@ -1,4 +1,7 @@
<?php <?php
/**
* GET — Lista de médicos solicitantes.
*/
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; } if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
+3
View File
@@ -1,4 +1,7 @@
<?php <?php
/**
* POST — Crea o actualiza un médico del catálogo.
*/
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; } if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
+285
View File
@@ -0,0 +1,285 @@
<?php
/**
* modules/soporte/DocIndex.php
* Descubre los documentos en docs/, arma el árbol de navegación y resuelve el
* control de acceso por sección.
*
* 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
{
/** 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 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,
'resumen' => 'Cómo está armado el sistema y por qué.'],
'operacion' => ['titulo' => 'Operación y soporte', 'icono' => 'fas fa-life-ring', 'admin' => true,
'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 self::esAdmin();
}
/** Secciones visibles para el usuario actual. */
public static function seccionesVisibles(): array
{
return array_filter(
self::SECCIONES,
fn($s) => self::puedeVer($s),
ARRAY_FILTER_USE_KEY
);
}
/**
* Árbol completo: [seccion => ['titulo'=>..,'docs'=>[['slug','titulo','archivo'],...]]]
* Solo incluye lo que el usuario puede ver.
*/
public static function arbol(): array
{
$arbol = [];
foreach (self::seccionesVisibles() as $sec => $cfg) {
$ruta = self::dir() . '/' . $sec;
if (!is_dir($ruta)) continue;
$docs = [];
foreach (glob($ruta . '/*.md') ?: [] as $archivo) {
if (!self::puedeVerDoc($sec, $archivo)) continue;
$base = basename($archivo, '.md');
$slug = preg_replace('/^\d+-/', '', $base);
$docs[] = [
'slug' => $slug,
'titulo' => self::titulo($archivo, $slug),
'archivo' => $archivo,
'orden' => $base,
];
}
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;
}
/** Ruta del archivo pedido, o null si no existe o no hay acceso. */
public static function resolver(string $seccion, string $slug): ?string
{
if (!self::puedeVer($seccion)) return null;
// Evitar traversal: los slugs son [a-z0-9-]
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) continue;
return self::puedeVerDoc($seccion, $archivo) ? $archivo : null;
}
return null;
}
/** Primer encabezado `# ` del archivo; si no hay, el slug capitalizado. */
private static function titulo(string $archivo, string $slugFallback): string
{
$fh = @fopen($archivo, 'r');
if ($fh) {
$lineas = 0;
while (($l = fgets($fh)) !== false && $lineas++ < 30) {
if (preg_match('/^#\s+(.+)$/', trim($l), $m)) { fclose($fh); return trim($m[1]); }
}
fclose($fh);
}
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.
*/
public static function indiceBusqueda(): array
{
$out = [];
foreach (self::arbol() as $sec => $cfg) {
foreach ($cfg['docs'] as $doc) {
[, $texto] = self::leer($doc['archivo']);
// Aplanar: sin marcas, sin saltos, compacto
$texto = preg_replace('/```.*?```/s', ' ', $texto);
$texto = preg_replace('/[#>*_`|-]+/', ' ', $texto);
$texto = preg_replace('/\s+/', ' ', $texto);
$out[] = [
's' => $sec,
'u' => $doc['slug'],
't' => $doc['titulo'],
'c' => $cfg['titulo'],
'x' => mb_substr(trim($texto), 0, 4000),
];
}
}
return $out;
}
}
+220
View File
@@ -0,0 +1,220 @@
<?php
/**
* modules/soporte/Generadores.php
* Secciones de la documentación que se leen del código y de la base de datos
* en cada carga, en vez de escribirse a mano.
*
* El objetivo es que los inventarios (módulos, endpoints, tablas, roles) no
* puedan quedar desactualizados: si alguien agrega un módulo o una tabla,
* aparece aquí sin que nadie tenga que acordarse de editar un .md.
*
* Se invocan desde los documentos con marcadores en una línea propia:
* {{modulos}} {{endpoints}} {{tablas}} {{roles}} {{servicios}}
*/
final class Generadores
{
/** Reemplaza los marcadores {{...}} de un documento por su tabla generada. */
public static function expandir(string $md): string
{
return preg_replace_callback('/^\{\{(\w+)\}\}\s*$/m', function ($m) {
$metodo = 'gen' . ucfirst($m[1]);
if (!method_exists(self::class, $metodo)) return $m[0];
try {
return self::$metodo();
} catch (\Throwable $e) {
return '> No se pudo generar esta sección: ' . $e->getMessage();
}
}, $md);
}
private static function pdo(): PDO
{
return Database::getInstance()->getConnection();
}
private static function raiz(): string
{
return dirname(__DIR__, 2);
}
// ── Módulos registrados ───────────────────────────────────────────
private static function genModulos(): string
{
$dirs = glob(self::raiz() . '/modules/*', GLOB_ONLYDIR) ?: [];
$filas = [];
foreach ($dirs as $dir) {
$slug = basename($dir);
$meta = ['name' => $slug, 'description' => '', 'oleada' => ''];
$mf = $dir . '/module.php';
if (is_file($mf)) {
try {
$m = @include $mf;
if (is_array($m)) $meta = $m + $meta;
} catch (\Throwable $e) { /* un module.php con contexto no cargable no debe romper la doc */ }
}
$vistas = count(glob($dir . '/views/*.php') ?: []);
$apis = count(glob($dir . '/api/*.php') ?: []);
$enSys = defined('SYSTEM_MODULES') && array_key_exists($slug, SYSTEM_MODULES);
$filas[] = [
'`' . $slug . '`',
$meta['name'] ?? $slug,
$vistas ?: '—',
$apis ?: '—',
$enSys ? 'sí' : 'no',
$meta['description'] ?? '',
];
}
usort($filas, fn($a, $b) => strcmp($a[0], $b[0]));
return self::tabla(
['Slug', 'Nombre', 'Vistas', 'Endpoints', 'En SYSTEM_MODULES', 'Descripción'],
$filas
) . "\n\n_" . count($filas) . " módulos en `modules/`. Generado del filesystem._\n";
}
// ── Endpoints por módulo ──────────────────────────────────────────
private static function genEndpoints(): string
{
$out = '';
foreach (glob(self::raiz() . '/modules/*/api', GLOB_ONLYDIR) ?: [] as $dir) {
$slug = basename(dirname($dir));
$files = glob($dir . '/*.php') ?: [];
if (!$files) continue;
sort($files);
$filas = [];
foreach ($files as $f) {
$nombre = basename($f);
if (str_starts_with($nombre, '_')) continue; // helpers internos
$filas[] = ['`' . $nombre . '`', self::resumenPhpDoc($f)];
}
if (!$filas) continue;
$out .= "\n### " . $slug . "\n\n" . self::tabla(['Endpoint', 'Qué hace'], $filas) . "\n";
}
return $out ?: '> Sin endpoints.';
}
// ── Tablas de la base de datos ────────────────────────────────────
private static function genTablas(): string
{
$rows = self::pdo()->query(
"SELECT table_name, table_rows, table_comment
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'
ORDER BY table_name"
)->fetchAll(PDO::FETCH_ASSOC);
// Agrupar por prefijo para que se lea por dominio
$grupos = [];
foreach ($rows as $r) {
$t = $r['table_name'];
$pfx = str_contains($t, '_') ? explode('_', $t)[0] : 'otras';
$grupos[$pfx][] = $r;
}
ksort($grupos);
$out = '';
foreach ($grupos as $pfx => $tablas) {
$out .= "\n### " . $pfx . "\n\n";
$filas = [];
foreach ($tablas as $t) {
$cols = self::pdo()->prepare(
"SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ?"
);
$cols->execute([$t['table_name']]);
$filas[] = [
'`' . $t['table_name'] . '`',
(string)(int)$cols->fetchColumn(),
number_format((int)$t['table_rows'], 0, ',', '.'),
$t['table_comment'] ?: '',
];
}
$out .= self::tabla(['Tabla', 'Columnas', 'Filas aprox.', 'Comentario'], $filas) . "\n";
}
return $out . "\n_" . count($rows) . " tablas. Generado de `information_schema`; el conteo de filas es una estimación de InnoDB._\n";
}
// ── Roles y permisos efectivos ────────────────────────────────────
private static function genRoles(): string
{
$roles = self::pdo()->query(
"SELECT r.id, r.slug, r.name, r.description,
(SELECT COUNT(*) FROM admin_users u WHERE u.role_id = r.id AND u.is_active = 1) AS usuarios
FROM roles r ORDER BY r.slug"
)->fetchAll(PDO::FETCH_ASSOC);
$mods = self::pdo()->query(
"SELECT role_id, module_slug, permission FROM role_modules ORDER BY module_slug"
)->fetchAll(PDO::FETCH_ASSOC);
$porRol = [];
foreach ($mods as $m) {
$porRol[(int)$m['role_id']][] = $m['module_slug'] . ($m['permission'] === 'read' ? ' _(solo lectura)_' : '');
}
$filas = [];
foreach ($roles as $r) {
$lista = $porRol[(int)$r['id']] ?? [];
$filas[] = [
'`' . $r['slug'] . '`',
$r['name'] ?: '',
(string)(int)$r['usuarios'],
$lista ? implode(', ', $lista) : '—',
];
}
return self::tabla(['Rol', 'Nombre', 'Usuarios activos', 'Módulos'], $filas)
. "\n\n_Generado de `roles` y `role_modules`. El acceso efectivo se carga al iniciar sesión desde `role_id`._\n";
}
// ── Servicios y clases del núcleo ─────────────────────────────────
private static function genServicios(): string
{
$out = '';
foreach ([['core', 'Núcleo'], ['services', 'Servicios'], ['classes', 'Clases de dominio']] as [$dir, $tit]) {
$files = glob(self::raiz() . '/' . $dir . '/*.php') ?: [];
if (!$files) continue;
sort($files);
$filas = [];
foreach ($files as $f) {
$filas[] = ['`' . basename($f) . '`', self::resumenPhpDoc($f)];
}
$out .= "\n### " . $tit . " (`" . $dir . "/`)\n\n" . self::tabla(['Archivo', 'Responsabilidad'], $filas) . "\n";
}
return $out;
}
/** Primera línea con contenido del bloque docblock inicial de un archivo. */
private static function resumenPhpDoc(string $archivo): string
{
$fh = @fopen($archivo, 'r');
if (!$fh) return '';
$n = 0;
$ruta = null;
while (($l = fgets($fh)) !== false && $n++ < 25) {
$l = trim($l);
if (!str_starts_with($l, '*')) continue;
$l = trim(ltrim($l, '*/ '));
if ($l === '') continue;
// Saltar la línea que solo repite la ruta del archivo
if ($ruta === null && str_contains($l, '.php')) { $ruta = $l; continue; }
if (str_starts_with($l, '@')) break;
fclose($fh);
return $l;
}
fclose($fh);
return '';
}
/** Arma una tabla Markdown escapando los separadores del contenido. */
private static function tabla(array $encabezados, array $filas): string
{
$esc = fn($v) => str_replace('|', '\\|', (string)$v);
$md = '| ' . implode(' | ', array_map($esc, $encabezados)) . " |\n";
$md .= '|' . str_repeat('---|', count($encabezados)) . "\n";
foreach ($filas as $f) {
$md .= '| ' . implode(' | ', array_map($esc, $f)) . " |\n";
}
return $md;
}
}
+238
View File
@@ -0,0 +1,238 @@
<?php
/**
* modules/soporte/Markdown.php
* Renderizador Markdown → HTML para la documentación del proyecto.
*
* Cubre el subconjunto que usa la documentación: encabezados, listas
* (anidadas y numeradas), tablas, bloques de código, citas, reglas, enlaces,
* énfasis y código en línea. No pretende ser CommonMark completo — se prefirió
* un archivo propio y auditable a incorporar una dependencia externa.
*
* Todo el texto se escapa antes de aplicar formato, así que el contenido de
* los .md no puede inyectar HTML.
*/
final class Markdown
{
/** Convierte un documento Markdown completo a HTML. */
public static function render(string $texto): string
{
$lineas = preg_split('/\R/', $texto);
$html = '';
$n = count($lineas);
$i = 0;
while ($i < $n) {
$linea = $lineas[$i];
// ── Bloque de código cercado ──────────────────────────────
if (preg_match('/^```\s*([\w-]*)\s*$/', $linea, $m)) {
$lang = $m[1];
$buffer = [];
$i++;
while ($i < $n && !preg_match('/^```\s*$/', $lineas[$i])) {
$buffer[] = $lineas[$i];
$i++;
}
$i++; // cerrar
$clase = $lang ? ' class="lang-' . htmlspecialchars($lang, ENT_QUOTES) . '"' : '';
$html .= '<pre><code' . $clase . '>'
. htmlspecialchars(implode("\n", $buffer), ENT_QUOTES)
. '</code></pre>';
continue;
}
// ── Línea en blanco ───────────────────────────────────────
if (trim($linea) === '') { $i++; continue; }
// ── Regla horizontal ──────────────────────────────────────
if (preg_match('/^(-{3,}|\*{3,}|_{3,})\s*$/', $linea)) {
$html .= '<hr>';
$i++;
continue;
}
// ── Encabezado ────────────────────────────────────────────
if (preg_match('/^(#{1,6})\s+(.*)$/', $linea, $m)) {
$nivel = strlen($m[1]);
$texto2 = trim($m[2]);
$slug = self::slug($texto2);
$html .= "<h{$nivel} id=\"{$slug}\">" . self::inline($texto2) . "</h{$nivel}>";
$i++;
continue;
}
// ── Tabla ─────────────────────────────────────────────────
if (strpos($linea, '|') !== false
&& isset($lineas[$i + 1])
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lineas[$i + 1])) {
[$tabla, $i] = self::tabla($lineas, $i);
$html .= $tabla;
continue;
}
// ── Cita ──────────────────────────────────────────────────
if (preg_match('/^>\s?(.*)$/', $linea)) {
$buffer = [];
while ($i < $n && preg_match('/^>\s?(.*)$/', $lineas[$i], $m2)) {
$buffer[] = $m2[1];
$i++;
}
$html .= '<blockquote>' . self::render(implode("\n", $buffer)) . '</blockquote>';
continue;
}
// ── Lista (con o sin numerar, admite anidación) ───────────
if (preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $linea)) {
[$lista, $i] = self::lista($lineas, $i, 0);
$html .= $lista;
continue;
}
// ── Párrafo ───────────────────────────────────────────────
$buffer = [];
while ($i < $n
&& trim($lineas[$i]) !== ''
&& !preg_match('/^(#{1,6}\s|```|>|\s*([-*+]|\d+\.)\s|(-{3,}|\*{3,}|_{3,})\s*$)/', $lineas[$i])
&& !(strpos($lineas[$i], '|') !== false
&& isset($lineas[$i + 1])
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lineas[$i + 1]))) {
$buffer[] = $lineas[$i];
$i++;
}
if ($buffer) $html .= '<p>' . self::inline(implode(' ', $buffer)) . '</p>';
}
return $html;
}
/** Construye una lista, recursivamente para los niveles anidados. */
private static function lista(array $lineas, int $i, int $sangriaBase): array
{
$n = count($lineas);
preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $lineas[$i], $m0);
$ordenada = !in_array($m0[2], ['-', '*', '+'], true);
$tag = $ordenada ? 'ol' : 'ul';
$html = "<{$tag}>";
while ($i < $n) {
if (trim($lineas[$i]) === '') {
// Una línea vacía corta la lista salvo que siga otro ítem
if (isset($lineas[$i + 1]) && preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $lineas[$i + 1])) {
$i++;
continue;
}
break;
}
if (!preg_match('/^(\s*)([-*+]|\d+\.)\s+(.*)$/', $lineas[$i], $m)) break;
$sangria = strlen($m[1]);
if ($sangria < $sangriaBase) break;
if ($sangria > $sangriaBase) {
[$sub, $i] = self::lista($lineas, $i, $sangria);
// Colgar la sublista del último ítem abierto
$html = preg_replace('/<\/li>$/', '', $html) . $sub . '</li>';
continue;
}
$html .= '<li>' . self::inline($m[3]) . '</li>';
$i++;
}
return [$html . "</{$tag}>", $i];
}
/** Construye una tabla a partir de la fila de encabezado. */
private static function tabla(array $lineas, int $i): array
{
$n = count($lineas);
$celdas = fn(string $l) => array_map('trim', explode('|', trim($l, " \t|")));
$encabezado = $celdas($lineas[$i]);
$alineacion = array_map(function ($c) {
$c = trim($c);
if (str_starts_with($c, ':') && str_ends_with($c, ':')) return 'center';
if (str_ends_with($c, ':')) return 'right';
return 'left';
}, $celdas($lineas[$i + 1]));
$i += 2;
$html = '<div class="tabla-scroll"><table><thead><tr>';
foreach ($encabezado as $k => $c) {
$a = $alineacion[$k] ?? 'left';
$html .= '<th style="text-align:' . $a . '">' . self::inline($c) . '</th>';
}
$html .= '</tr></thead><tbody>';
while ($i < $n && trim($lineas[$i]) !== '' && strpos($lineas[$i], '|') !== false) {
$fila = $celdas($lineas[$i]);
$html .= '<tr>';
foreach ($encabezado as $k => $_) {
$a = $alineacion[$k] ?? 'left';
$html .= '<td style="text-align:' . $a . '">' . self::inline($fila[$k] ?? '') . '</td>';
}
$html .= '</tr>';
$i++;
}
return [$html . '</tbody></table></div>', $i];
}
/**
* Formato dentro de una línea. Se escapa primero y el código en línea se
* aparta con marcadores para que su contenido no reciba más formato.
*/
private static function inline(string $texto): string
{
$codigos = [];
$texto = preg_replace_callback('/`([^`]+)`/', function ($m) use (&$codigos) {
$codigos[] = '<code>' . htmlspecialchars($m[1], ENT_QUOTES) . '</code>';
return "\x00" . (count($codigos) - 1) . "\x00";
}, $texto);
$texto = htmlspecialchars($texto, ENT_QUOTES);
// Enlaces [texto](destino) — solo http(s), rutas internas y anclas
$texto = preg_replace_callback(
'/\[([^\]]+)\]\(([^)\s]+)\)/',
function ($m) {
$url = html_entity_decode($m[2], ENT_QUOTES);
if (!preg_match('#^(https?://|/|\?|\#)#', $url)) return $m[0];
$ext = str_starts_with($url, 'http') ? ' target="_blank" rel="noopener"' : '';
return '<a href="' . htmlspecialchars($url, ENT_QUOTES) . '"' . $ext . '>' . $m[1] . '</a>';
},
$texto
);
$texto = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $texto);
$texto = preg_replace('/(?<![\w*])\*([^*\n]+)\*(?![\w*])/', '<em>$1</em>', $texto);
$texto = preg_replace('/(?<![\w_])_([^_\n]+)_(?![\w_])/', '<em>$1</em>', $texto);
// Restaurar código en línea
return preg_replace_callback('/\x00(\d+)\x00/', fn($m) => $codigos[(int)$m[1]] ?? '', $texto);
}
/** Ancla estable para un encabezado. */
public static function slug(string $texto): string
{
$t = strtr(mb_strtolower(strip_tags($texto)), [
'á'=>'a','é'=>'e','í'=>'i','ó'=>'o','ú'=>'u','ñ'=>'n','ü'=>'u',
]);
$t = preg_replace('/[^a-z0-9]+/', '-', $t);
return trim($t, '-');
}
/** Extrae los encabezados h2/h3 para la tabla de contenidos lateral. */
public static function indice(string $texto): array
{
$out = [];
foreach (preg_split('/\R/', $texto) as $l) {
if (preg_match('/^(#{2,3})\s+(.*)$/', $l, $m)) {
$t = trim($m[2]);
$out[] = ['nivel' => strlen($m[1]), 'texto' => $t, 'slug' => self::slug($t)];
}
}
return $out;
}
}
+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);
}
@@ -0,0 +1,61 @@
# Visión general
Este sistema es el ERP del **Laboratorio Clínico Ximena Caicedo**. Nació como un bot de WhatsApp y creció hasta cubrir la operación diaria del laboratorio: turnos presenciales, toma de muestras, domicilios, órdenes médicas, formularios firmados digitalmente y facturación del día.
## Qué resuelve
| Área | Qué hace el sistema |
|---|---|
| Atención por WhatsApp | Bot que responde, agenda, envía consentimientos y encuestas |
| Turnero presencial | Kiosko, recepción, estaciones de toma de muestras, pantallas de TV |
| Domicilios | Agendamiento y asignación de enfermeros a visitas domiciliarias |
| Formularios | Consentimientos y fichas clínicas firmadas digitalmente |
| Laboratorio | Pacientes, órdenes médicas, exámenes, EPS, empresas, médicos |
## Las tres capas
El código está organizado en tres niveles, de lo más general a lo más específico:
```
erp.php punto de entrada único del ERP
└── core/App.php arranque, sesión, enrutamiento, control de acceso
└── modules/<slug>/views/<vista>.php la pantalla concreta
└── modules/<slug>/api/*.php endpoints que consume por fetch
```
Debajo de todo eso están los **servicios** (`services/`), que encapsulan lo que habla con el mundo exterior — sobre todo la API de WhatsApp — y las **clases de dominio** (`classes/lab/`), que concentran las reglas de negocio de pacientes, domicilios, órdenes y formularios.
## Convivencia con el sistema anterior
Hay dos generaciones de código funcionando a la vez, y es intencional:
- **Archivos sueltos en la raíz** (`lab_domicilios.php`, `index.php`, `ver_formulario_enviado.php`, …). Es el sistema original. Siguen siendo el código real de muchas pantallas.
- **Módulos en `modules/`**. Es la estructura nueva. Algunos módulos son pantallas completas (turnero, registro de exámenes); otros son apenas un puente que incluye el archivo viejo.
Un ejemplo de puente, `modules/lab_domicilios/views/index.php`:
```php
require_once APP_ROOT . '/lab_domicilios.php';
```
La migración es gradual y a propósito: mover una pantalla al nuevo esquema no obliga a mover las demás. Al leer el código, **el archivo de la raíz suele ser el que manda**; el módulo solo aporta el registro en el menú y el control de acceso.
## Stack
| Componente | Detalle |
|---|---|
| Lenguaje | PHP 7.4+ (en producción corre sobre versiones más recientes) |
| Base de datos | MariaDB 11.8 |
| Frontend | HTML server-side + JavaScript sin framework; Bootstrap 5 y Font Awesome |
| Mensajería | WhatsApp Cloud API (Meta) |
| IA | Google Gemini Flash — asistente LIA del dashboard del turnero |
| Dependencias | Predis, Monolog, Guzzle, phpdotenv (vía Composer) |
No hay build step ni framework de frontend: las vistas son PHP que emite HTML y el JavaScript va embebido en la misma vista. Es deliberado — mantiene el despliegue en un simple `git pull`.
## Por dónde seguir
- [Enrutamiento y módulos](?m=soporte&v=documentacion&s=arquitectura&d=enrutamiento) — cómo una URL llega a una pantalla
- [Roles y permisos](?m=soporte&v=documentacion&s=arquitectura&d=roles-y-permisos) — quién ve qué
- [Modelo de datos](?m=soporte&v=documentacion&s=arquitectura&d=modelo-de-datos) — las 91 tablas, agrupadas
- [Integración con WhatsApp](?m=soporte&v=documentacion&s=arquitectura&d=whatsapp) — el punto más delicado del sistema
@@ -0,0 +1,123 @@
# Enrutamiento y módulos
Cómo una URL termina ejecutando una pantalla concreta, y qué hace falta para agregar un módulo nuevo.
## El recorrido de una petición
```
GET /erp.php?m=turnero&v=historial
├── erp.php define APP_ROOT y llama App::run()
├── App::boot() carga config, abre sesión, fija zona horaria
├── Router decide módulo y vista
│ ├── 1º intenta la ruta limpia: /turnero/historial
│ └── 2º cae a los parámetros: ?m=turnero&v=historial
├── Router::resolveFile()
│ └── modules/turnero/views/historial.php ¿existe? si no → 404
├── Rbac::hasModule('turnero') ¿tiene acceso? si no → 403
└── include del archivo de la vista
```
El punto clave: **la ruta es literalmente la ubicación del archivo**. `?m=turnero&v=historial` carga `modules/turnero/views/historial.php`. No hay tabla de rutas ni configuración intermedia.
## Validación de la URL
`Router` acepta como módulo y vista solo `[a-zA-Z0-9_]`, máximo 64 caracteres. Cualquier cosa fuera de ese patrón se descarta silenciosamente y se reemplaza por el valor por defecto (`dashboard` / `index`). Eso cierra la puerta a recorrer directorios con `../`.
## Rutas públicas
Casi todo exige sesión. Las excepciones están fijas en `core/Router.php`:
| Ruta | Por qué es pública |
|---|---|
| `turnero/display` | Pantalla de TV en sala de espera; no hay quién inicie sesión |
| `turnero/kiosko` | El paciente saca su turno solo |
Cualquier otra combinación pasa por el control de acceso.
> Ojo: `isPublic()` solo omite la verificación **de módulo**. La sesión se maneja aparte, dentro de cada vista.
## Registrar un módulo nuevo
Hacen falta tres cosas. Si falta alguna, el módulo no aparece o da 403.
**1. La carpeta y al menos una vista**
```
modules/mimodulo/
module.php
views/index.php
api/ (opcional)
```
**2. El descriptor `module.php`** — devuelve un arreglo:
```php
<?php return [
'slug' => 'mimodulo',
'name' => 'Mi Módulo',
'icon' => 'fas fa-cube',
'category' => 'lab',
'route' => '/erp.php?m=mimodulo&v=index',
'is_active' => true,
'sort_order' => 50,
'description' => 'Para qué sirve',
'links' => [
['name' => 'Inicio', 'icon' => 'fas fa-home', 'route' => '/erp.php?m=mimodulo&v=index'],
],
];
```
`links` son las entradas que salen en el menú lateral. El descriptor se ejecuta como PHP, así que puede armar los enlaces según el rol de quien mira — el turnero lo hace: muestra escritorios distintos a recepcionistas y bacteriólogos.
**3. El registro en `SYSTEM_MODULES`** (`config/config.php`)
```php
define('SYSTEM_MODULES', [
...
'mimodulo' => 'Mi Módulo',
]);
```
Estar acá es lo que **activa la verificación de permisos**. Un módulo ausente de esta lista no se valida y queda accesible para cualquier sesión.
**4. Dar acceso a los roles** — sin esto, todos reciben 403:
```sql
INSERT INTO role_modules (role_id, module_slug, permission, can_view)
SELECT id, 'mimodulo', 'write', 1 FROM roles WHERE slug IN ('admin','superadmin');
```
> Los módulos de la sesión se cargan **al iniciar sesión**, desde `role_id`. Después de tocar `role_modules`, el usuario afectado tiene que volver a entrar para que el cambio surta efecto.
## Módulos actuales
{{modulos}}
## Vistas y layout
Una vista se escribe así:
```php
require_once APP_ROOT . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
Layout::open('Título de la pantalla', 'fas fa-icono');
// HTML, CSS y JS de la pantalla
Layout::close();
```
`Layout::open()` emite el `<head>`, la barra superior y el menú lateral — que construye leyendo los `module.php` de los módulos a los que el usuario tiene acceso. `Layout::close()` cierra el documento.
## Endpoints
Cada módulo puede tener su carpeta `api/`. Son archivos PHP sueltos que devuelven JSON y se consumen por `fetch` desde las vistas. No pasan por `Router`: se invocan por su ruta real (`modules/turnero/api/get_historial.php`).
Por convención, `api/_helpers.php` de cada módulo concentra lo común — conexión, lectura del cuerpo JSON, respuestas `jsonOk()` / `jsonError()` y la verificación de acceso.
Los archivos que empiezan con guión bajo son de uso interno y no se llaman directamente desde el navegador.
@@ -0,0 +1,113 @@
# Roles y permisos
Quién puede ver y hacer qué. Es el punto donde más seguido se cometen errores, así que conviene entenderlo completo.
## Las dos columnas de un usuario
En `admin_users` conviven dos campos que parecen redundantes y **no lo son**:
| Columna | Para qué se usa |
|---|---|
| `role` | Texto del rol (`admin`, `bacteriologo`, …). Lo consultan las vistas para decidir qué mostrar |
| `role_id` | Apunta a `roles.id`. Es de donde se **cargan los módulos** al iniciar sesión |
> **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, actualice las dos a la vez:
```sql
UPDATE admin_users
SET role = 'lab_recepcion',
role_id = (SELECT id FROM roles WHERE slug = 'lab_recepcion')
WHERE id = 12;
```
## Cómo se arma el acceso al iniciar sesión
`authenticateUser()` (`config/config.php`) valida la contraseña y arma la sesión:
```
admin_users.role_id
└── role_modules → lista de module_slug + permission
└── $_SESSION['admin_user']['modules'] (qué módulos ve)
$_SESSION['admin_user']['module_permissions'] (read o write en cada uno)
```
**Esto ocurre una sola vez, al entrar.** Cualquier cambio en `role_modules` no afecta a las sesiones abiertas: el usuario tiene que cerrar sesión y volver a entrar.
## Las dos preguntas del control de acceso
```php
hasModule('lab_domicilios') // ¿puede entrar al módulo?
hasModuleWrite('lab_domicilios') // ¿puede modificar, o solo mirar?
```
- `hasModule()` mira si el slug está en la lista de módulos de la sesión.
- `hasModuleWrite()` mira `module_permissions[slug] === 'write'`. Los administradores siempre pueden escribir.
Una vista típica lo usa así:
```php
$puedeEscribir = hasModuleWrite('lab_domicilios');
...
<?php if ($puedeEscribir): ?><button>Nuevo domicilio</button><?php endif; ?>
```
## La columna que manda es `permission`
`role_modules` tiene dos formas de expresar lo mismo, y solo una se usa:
| Columnas | ¿Se usan? |
|---|---|
| `permission` (`read` / `write`) | **Sí.** Es lo que lee `hasModuleWrite()` |
| `can_view`, `can_create`, `can_edit`, `can_delete`, `can_export` | No las lee el control de acceso |
> Poner `can_edit = 0` **no impide editar**. Para dejar un módulo en solo lectura hay que fijar `permission = 'read'`. Las columnas `can_*` quedaron de un diseño anterior; conviene mantenerlas coherentes por prolijidad, pero no protegen nada.
Solo lectura de verdad:
```sql
UPDATE role_modules SET permission = 'read'
WHERE role_id = 1030 AND module_slug IN ('lab_domicilios', 'lab_ordenes');
```
## Roles actuales
{{roles}}
## Sesiones sin `role_id`
Hay dos casos heredados que siguen contemplados en el código:
- **`modules` nulo y rol `admin`** → acceso total. Cubre usuarios anteriores al sistema de roles.
- **Rol `enfermero` sin `role_id`** → recibe `enfermero_portal` y `lab_formularios` de forma fija.
## Verificaciones adicionales
El control por módulo no siempre alcanza. Varias pantallas agregan sus propias reglas:
| Dónde | Regla |
|---|---|
| `enfermero_portal.php` | Solo `admin`, `superadmin` y `enfermero` |
| `api/lab/save_domicilio.php` | Un enfermero solo edita domicilios que creó **o** que tiene asignados |
| `api/lab/firmar_profesional.php` | Un enfermero solo firma envíos propios |
| `modules/turnero/api/_helpers.php` | `requireTurnero()` en todos los endpoints del turnero |
| `modules/turnero/module.php` | El menú cambia según rol y según la IP del equipo |
Al agregar un endpoint que modifica datos, **no alcanza con confiar en que la vista ocultó el botón**: el endpoint tiene que verificar por su cuenta.
## Diagnóstico rápido
Alguien reporta que no ve un módulo o que puede editar lo que no debería:
```sql
-- Qué rol tiene realmente y si las dos columnas coinciden
SELECT u.id, u.username, u.role, u.role_id, r.slug AS rol_real
FROM admin_users u LEFT JOIN roles r ON r.id = u.role_id
WHERE u.username = 'usuario';
-- Qué módulos le da ese rol
SELECT module_slug, permission FROM role_modules WHERE role_id = <role_id>;
```
Si los datos se ven bien y el usuario sigue sin acceso: **no ha vuelto a iniciar sesión**.
@@ -0,0 +1,85 @@
# Modelo de datos
Las tablas están agrupadas por prefijo, y el prefijo dice a qué dominio pertenecen.
| Prefijo | Dominio |
|---|---|
| `lab_` | Laboratorio: pacientes, domicilios, órdenes, formularios, configuración |
| `turnero_` | Turnos presenciales: sesiones, turnos, solicitudes, muestras, consentimientos |
| `exam_` | Catálogo de exámenes y sus consentimientos asociados |
| `terms_` | Términos y condiciones del bot y su historial de aceptaciones |
| `admin_`, `roles`, `role_modules` | Usuarios y permisos |
| resto | Conversaciones de WhatsApp, plantillas, logs, configuración del sistema |
## Los cuatro núcleos
### Turno presencial
Es la cadena más larga del sistema. Un paciente entra al laboratorio y genera esto:
```
turnero_sesiones una fila por día de operación
└── turnero_turnos el turno del paciente (código, estado, tiempos)
├── turnero_solicitudes qué se le va a hacer y cuánto se cobró
│ ├── turnero_examen_items exámenes pedidos
│ └── turnero_muestras muestras a recibir
├── turnero_consentimientos formularios a firmar
└── turnero_comentarios notas del personal
```
`turnero_turnos.estado` gobierna el flujo:
```
espera → en_recepcion → en_espera_lugar → en_servicio → finalizado
ausente / cancelado
```
Solo los turnos **finalizados** cuentan como facturación real; los que están en estados intermedios se reportan aparte como "en proceso". Ausentes y cancelados no cuentan.
### Domicilio
```
lab_domicilios
├── lab_asignaciones qué enfermero lo atiende
├── lab_domicilio_notas seguimiento
└── lab_domicilio_pagos cobros
```
### Formulario firmado
Un mismo formulario (`lab_formularios`) se firma por dos vías distintas, y cada una guarda en su propia tabla:
| Vía | Tabla | Token |
|---|---|---|
| Turnero | `turnero_consentimientos` | UUID (`?token=`) |
| Domicilios y envíos sueltos | `lab_form_envios` | 64 caracteres hex (`?t=`) |
Las dos las muestra `ver_formulario_enviado.php`, que distingue por el **formato del token**. Es la razón de que existan dos parámetros distintos para lo que parece lo mismo.
La definición del formulario vive en `lab_formularios.esquema`, un JSON con la lista de campos. Las respuestas quedan en `datos_respuestas` (turnero) o `datos_cliente` (envíos), también JSON.
### Conversación de WhatsApp
```
users / conversations el contacto y su hilo
├── messages cada mensaje
├── terms_acceptance aceptación de términos
└── message_templates plantillas aprobadas por Meta (caché local)
```
## Convenciones
- **Timestamps**: `creado_at` / `created_at` según la época en que se creó la tabla. No hay una sola convención.
- **Autor**: `creado_por` guarda `admin_users.id`. Varias tablas lo agregaron después, así que las filas viejas lo tienen en `NULL`.
- **Borrado**: casi todo es borrado físico. No hay *soft delete* generalizado.
- **JSON**: se usa bastante (`esquema`, `datos_respuestas`, `pagos_detalle`, `items_precio`). Guardado como `longtext`, no como tipo `JSON` nativo.
## Cambios de esquema
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 toca el esquema, deje el archivo.
## Inventario completo
{{tablas}}
@@ -0,0 +1,85 @@
# Integración con WhatsApp
Es la parte del sistema con más piezas fuera de nuestro control. Buena parte de la configuración vive **en Meta**, no en la base de datos, y eso explica varios comportamientos que de otro modo parecen inexplicables.
## Dos números, una misma cuenta
El laboratorio opera con dos líneas sobre la misma cuenta de WhatsApp Business (WABA):
| Canal | Configuración | Para qué |
|---|---|---|
| Principal | `whatsapp_phone_number_id` | Bot de atención general |
| Turnero | `whatsapp_phone_number_id_turnero` | Consentimientos, encuestas y avisos de turno |
Se elige al construir el servicio:
```php
$wa = new WhatsAppService(); // línea principal
$wa = new WhatsAppService('turnero'); // línea del turnero
```
> Si un mensaje sale por el número equivocado, casi siempre es porque se instanció sin el canal. Es el mismo WABA y el mismo token: **lo único que cambia es el `phone_number_id`**.
## Configuración
Todo en `system_config`:
| Clave | Qué es |
|---|---|
| `whatsapp_token` | Token de acceso a la API |
| `whatsapp_api_url` | URL base de la Cloud API |
| `whatsapp_business_account_id` | Identificador del WABA |
| `whatsapp_phone_number_id` | Número principal |
| `whatsapp_phone_number_id_turnero` | Número del turnero |
| `webhook_verify_token` | Verificación del webhook |
## Plantillas: la parte que no controlamos
Para escribir primero a alguien (fuera de la ventana de 24 horas) hay que usar una **plantilla aprobada por Meta**. `message_templates` guarda una copia local, pero **la copia no manda**: la versión real está en Meta.
Esto tiene una consecuencia importante y poco intuitiva:
> **Las URL de los botones viven en la plantilla, no en nuestro código.**
La plantilla `consentimiento_turno_v2` tiene un botón así:
```
https://erp.laboratorioximenacaicedo.com/form_cliente.php?t={{1}}
```
Nuestro código solo envía el token como `{{1}}`. Cambiar el código **no cambia** el enlace que recibe el paciente: hay que editar la plantilla en el WhatsApp Manager de Meta y esperar la reaprobación.
El único lugar donde sí armamos la URL completa es el **respaldo en texto plano**, que se usa cuando falla el envío por plantilla (`modules/turnero/api/send_consentimiento.php`).
## Por qué el enlace pasa por dos páginas
El botón apunta a `form_cliente.php?t=<UUID>`, pero el consentimiento del turnero lo muestra `ver_formulario_enviado.php?token=<UUID>`.
`form_cliente.php` detecta que el token tiene formato UUID —o sea, que viene del turnero— y redirige. Convive así porque la plantilla ya estaba aprobada apuntando a la página de envíos, y cambiarla obliga a otra ronda de aprobación en Meta.
## Términos y condiciones
Antes de conversar, el bot exige aceptar los términos. El usuario responde **ACEPTO** o **NO ACEPTO**.
La URL del documento está escrita en **dos lugares** y hay que cambiarlos juntos:
| Dónde | Rol |
|---|---|
| `terms_versions.documento_url` | El bot la adjunta al final del mensaje |
| `system_config.terms_message` | Va escrita dentro del texto de bienvenida |
Se vuelve a pedir la aceptación si: nunca aceptó, pasaron más de 6 meses, o hay una versión nueva con `forzar_reenvio`.
## El webhook
Meta envía los mensajes entrantes al webhook, que los registra y se los pasa a `BotService`. Ahí se decide si responde el bot automático o queda para un operador humano, según el estado de la conversación y el horario de atención (`BusinessHoursService`).
## Qué revisar cuando algo falla
| Síntoma | Dónde mirar primero |
|---|---|
| El mensaje sale por el número equivocado | Que se haya pasado `'turnero'` al constructor |
| Un enlace llega roto o apunta mal | La plantilla en Meta, no el código |
| No llega ninguna plantilla | Estado de aprobación en el WhatsApp Manager |
| Falla el envío pero llega un texto plano | Es el respaldo actuando: la plantilla falló |
| El bot no responde | `webhook_logs`, y el horario de atención |
@@ -0,0 +1,104 @@
# Decisiones y deuda técnica
Por qué el sistema es como es, y qué cosas conviene saber antes de tocarlo.
## Decisiones tomadas a propósito
### Migración gradual, sin corte
Conviven el sistema original (archivos en la raíz) y el nuevo (módulos). No hubo una reescritura de golpe.
**Por qué:** el laboratorio opera todos los días. Una reescritura completa implicaba congelar el desarrollo o mantener dos sistemas en paralelo.
**Costo:** hay que saber en cuál de los dos está el código de cada pantalla. Los `lab_*` suelen estar en la raíz; el turnero está en el módulo.
### Sin framework de frontend
Las vistas son PHP que emiten HTML, con JavaScript embebido en la misma vista.
**Por qué:** despliegue por `git pull`, sin build ni compilación. Un archivo se edita y ya está en producción.
**Costo:** hay código repetido entre vistas, y las vistas grandes (el turnero) pasan de las 2.000 líneas.
### Esquemas de formulario en JSON
Los formularios se definen en JSON dentro de `lab_formularios.esquema`, no en tablas normalizadas.
**Por qué:** las fichas clínicas cambian seguido y cada una tiene campos distintos. Normalizarlas obligaba a migrar el esquema con cada formulario nuevo.
**Costo:** no se puede consultar por SQL «todos los pacientes con fiebre». Las respuestas viven dentro de un JSON.
### La identidad de quien firma se resuelve en el servidor
Nunca se acepta del navegador quién firmó algo.
**Por qué:** es un dato con valor legal. Un cliente puede mentir; la sesión no.
### El turno original nunca se modifica
Cuando una muestra pendiente se completa en una visita posterior, el turno original **queda como estaba**. Solo se registra el vínculo.
**Por qué:** el turno cerrado es un registro histórico. Alterarlo retroactivamente falsea los tiempos de atención y la facturación de aquel día.
### Inventarios generados, no escritos
Las tablas de módulos, endpoints, tablas y roles de esta documentación se leen del código y la base en cada carga.
**Por qué:** una lista escrita a mano envejece sin que nadie se entere. Una generada no puede mentir.
## Deuda técnica conocida
### Columnas `can_*` que no hacen nada
`role_modules` tiene `can_view`, `can_create`, `can_edit`, `can_delete`, `can_export` — y **el control de acceso no las lee**. Solo usa `permission` (`read`/`write`).
**Riesgo:** poner `can_edit = 0` da falsa sensación de haber restringido algo. Ya causó confusión.
**Arreglo:** o se usan de verdad, o se eliminan. Mientras tanto, conviene mantenerlas coherentes con `permission`.
### `role` y `role_id` duplicados
Un usuario tiene el rol en dos columnas. La interfaz lee una, los permisos salen de la otra.
**Riesgo:** cambiar solo `role` deja al usuario con permisos que no corresponden.
**Arreglo:** derivar `role` de `role_id` en lugar de almacenarlo.
### Dos vías para el mismo formulario
`turnero_consentimientos` y `lab_form_envios` guardan lo mismo con estructuras distintas y tokens de formato distinto. `ver_formulario_enviado.php` tiene que manejar ambos, y `form_cliente.php` existe solo para redirigir entre ellos.
**Por qué sigue así:** unificarlas obliga a cambiar la plantilla aprobada en Meta y migrar los registros históricos.
### Convenciones de nombre mezcladas
Conviven `creado_at` y `created_at`, `creado_por` y `enviado_por`, español e inglés. Depende de la época de cada tabla.
### Vistas muy grandes
`ver_formulario_enviado.php` supera las 3.000 líneas y mezcla render, lógica de tomas prolongadas y JavaScript. Es el archivo más delicado de tocar del sistema.
### Datos históricos incompletos
Algunas columnas se agregaron después y las filas viejas quedaron en `NULL`, sin forma de recuperarlas:
| Columna | Desde | Antes |
|---|---|---|
| `turnero_consentimientos.creado_por` | 3 ago 2026 | `NULL` |
| Identidad por toma en F-LAB-28 | 3 ago 2026 | No se guardaba |
| `admin_users.cedula` | 3 ago 2026 | Solo enfermeros la tenían |
No hay traza de auditoría que permita reconstruirlos.
### El dominio se deduce de cada petición
`APP_URL` sale del `HTTP_HOST`. Si alguien entra por una IP o un dominio alterno, los enlaces que se generen llevarán esa dirección — y quedan guardados así en el WhatsApp del paciente.
**Arreglo:** fijar `APP_URL` explícitamente.
## Al hacer cambios
- **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.
@@ -0,0 +1,79 @@
# Primeros pasos
Lo mínimo para moverse por el sistema, sin importar el rol.
## Entrar
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 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 ve algo que deberías ver
Casi siempre es una de estas dos:
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 tiene acceso |
| Barra superior | Su usuario y el cierre de sesión |
| Centro | La pantalla activa |
## Los módulos principales
| Módulo | Para qué |
|---|---|
| **Turnero** | Turnos presenciales: recepción, toma de muestras, pantallas |
| **Domicilios** | Visitas domiciliarias y su asignación a enfermeros |
| **Pacientes** | Fichas clínicas e historial |
| **Órdenes médicas** | Órdenes recibidas |
| **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 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 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.
@@ -0,0 +1,98 @@
---
roles: recepcionista, lab_recepcion, supervisor
---
# Recepción
Guía de la pantalla de recepción del turnero: desde que llama al paciente hasta que pasa a toma de muestras.
## Su escritorio
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á 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 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.
Si el paciente no responde, **Marcar ausente**. Queda registrado como ausente y no cuenta en la facturación.
### 2. Verificar o crear el paciente
Busque por cédula o nombre.
- **Existe** → se cargan sus datos y su historial.
- **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
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, 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 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
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
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 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. Verifique el número de teléfono en su ficha.
2. Reenvialo desde la lista de consentimientos.
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
Mientras el turno no esté finalizado, un administrador puede reabrirlo desde el historial y cambiar su estado.
## Encuestas
Desde el historial se le puede enviar una encuesta de satisfacción al paciente por WhatsApp.
@@ -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.
@@ -0,0 +1,108 @@
---
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.
## Su estación
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 su bandeja. Al abrir uno ve su ficha completa: datos, exámenes solicitados, muestras a recibir y formularios pendientes.
## Recibir muestras
Cada muestra tiene tres estados posibles:
| Estado | Significado |
|---|---|
| **Recibida** | La tomaste o el paciente la entregó correctamente |
| **Pendiente** | No se pudo obtener; queda debiendo |
| **Rechazada** | Se obtuvo pero no sirve — hemólisis, volumen insuficiente, mal rotulada |
Al rechazar hay que indicar el motivo. Ese motivo queda registrado y se ve después en el historial.
### Muestras de visitas anteriores
```
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
```
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.
## Formularios
### Datos Toma de Muestras (F-LAB-08)
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 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 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. **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.
Si hay que cerrar el protocolo antes de terminar todas las tomas, se puede hacer indicando el motivo.
## Antes de finalizar
El sistema no le deja finalizar si quedan muestras sin decidir. Cada una tiene que estar recibida, pendiente o rechazada.
## Comentarios
Puede dejar notas en el turno. Quedan visibles para el resto del personal y en el historial.
## Preguntas frecuentes
**¿Puedo revertir una muestra que marqué mal?**
Sí. Con el botón de deshacer vuelve a pendiente.
**El formulario me muestra secciones de exámenes que el paciente no tiene.**
Avise a soporte. Debería mostrar únicamente las del examen marcado.
**¿Qué pasa si el paciente se va sin dar una muestra?**
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.
@@ -0,0 +1,70 @@
---
roles: enfermero, supervisor
---
# Enfermeros — domicilios
Guía del portal del enfermero: sus visitas domiciliarias, cómo agendarlas y qué hacer en cada una.
## Su portal
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.
## Agendar un domicilio
**Nuevo domicilio** abre el formulario:
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.
6. **Valores** — domicilio, copago.
## Editar
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 revisa y envía.
## Notas y archivos
Puede dejar dos tipos de nota en cada domicilio:
- **Nota de ficha** — estructurada, para datos clínicos.
- **Nota libre** — texto suelto.
Ambas admiten fotos y archivos adjuntos, útil para órdenes médicas en papel o resultados.
## Formularios
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 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á 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 usted, y solo si no está completado ni cancelado.
**El paciente cambió de dirección.**
Edite el domicilio. Si el cambio es permanente, actualice también la ficha del paciente.
**Necesito reprogramar.**
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.
@@ -0,0 +1,91 @@
---
roles: supervisor
---
# Administración
Tareas de administrador: usuarios, permisos, configuración y reportes.
## Usuarios y permisos
### Crear un usuario
La convención de la casa es usar el **número de cédula como nombre de usuario** para el personal asistencial.
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.
### Cambiar el rol de alguien
> 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 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.
### Dejar un módulo en solo lectura
Lo que decide si alguien puede modificar es el campo `permission` (`read` o `write`) de cada módulo del rol.
> Las columnas `can_editar`, `can_crear` y similares **no se usan** para el control de acceso. Ponerlas en cero no impide nada. Lo que manda es `permission`.
## Turnero
### Escritorios y estaciones
Cada puesto de recepción y cada estación de muestras es un *lugar*. Se les puede asignar:
- **Formularios obligatorios** — todo paciente que pase por ahí los debe firmar.
- **Equipos por IP o token** — así el operador ve solo su puesto y no puede confundirse.
### Formularios obligatorios
Se exigen por dos vías, y se acumulan:
| Vía | Ejemplo |
|---|---|
| Por examen | VIH exige su consentimiento específico |
| Por estación | Toda toma de muestras exige F-LAB-08 |
### Pantalla de TV
Admite una lista de videos e imágenes que se reproducen en bucle, uno detrás de otro. Se pueden reordenar arrastrando y a cada imagen se le fija cuántos segundos dura.
### Reabrir un turno
Desde el historial se puede cambiar el estado de un turno, incluso reabrir uno finalizado, ausente o cancelado.
## Facturación del día
El panel muestra tres cifras:
| Cifra | Qué incluye |
|---|---|
| **Facturado** | Turnos finalizados |
| **En proceso** | Turnos aún activos, ya cobrados pero sin cerrar |
| **Total estimado** | La suma de ambos |
Los turnos **ausentes y cancelados no se cuentan** en ninguna: no se van a cobrar.
## LIA
El asistente del dashboard del turnero responde preguntas sobre la operación del día: tiempos por profesional, facturación, exámenes más pedidos, buscar un paciente.
Tiene un presupuesto de consumo. Cuando se agota, se bloquea y hay que reponerlo. El consumo por pregunta es alto porque envía el contexto completo del día cada vez.
## Documentos
Los datos que salen en el encabezado de todos los documentos —nombre, dirección, ciudad, teléfono, logo, color— se editan desde **Configuración del laboratorio**, sin tocar código.
> La dirección física se cambia desde ahí. Pero **la URL de los botones que llegan por WhatsApp no**: esa vive en la plantilla aprobada por Meta y se cambia en el WhatsApp Manager, con reaprobación de por medio.
## Términos y condiciones
El bot exige aceptarlos antes de conversar. La URL del documento está en **dos lugares** que hay que cambiar juntos: la versión activa de términos y el texto del mensaje de bienvenida, que la repite dentro.
Se vuelve a pedir la aceptación cuando pasan 6 meses o cuando se publica una versión nueva marcada para reenvío.
## Cuando algo falla
El [runbook de incidentes](?m=soporte&v=documentacion&s=operacion&d=runbook) tiene los casos frecuentes con su diagnóstico y solución.
@@ -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.
@@ -0,0 +1,167 @@
# Runbook de incidentes
Qué hacer cuando algo falla. Ordenado por lo que reporta el usuario, no por la causa.
---
## «No veo un módulo que antes veía»
O el opuesto: «puedo editar algo que no debería».
**Casi siempre es una de dos cosas:** el usuario no volvió a iniciar sesión, o `role` y `role_id` quedaron desincronizados.
```sql
-- 1. ¿Las dos columnas coinciden?
SELECT u.id, u.username, u.role, u.role_id, r.slug AS rol_real
FROM admin_users u LEFT JOIN roles r ON r.id = u.role_id
WHERE u.username = 'usuario';
-- 2. ¿Qué le da ese rol?
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, actualice las dos:
```sql
UPDATE admin_users
SET role = 'lab_recepcion',
role_id = (SELECT id FROM roles WHERE slug = 'lab_recepcion')
WHERE id = <id>;
```
> Para dejar un módulo en solo lectura, lo que importa es `permission = 'read'`. Las columnas `can_edit`, `can_create` y demás **no** las lee el control de acceso.
---
## «Un enlace que enviamos por WhatsApp está roto»
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ó. Revise si el archivo existe en el dominio actual del sistema.
Las URL que enviamos viven en lugares distintos según el caso:
| Enlace | Dónde está definido |
|---|---|
| Botón de consentimiento | **En la plantilla de Meta**, no en el código |
| Documento de términos | `terms_versions.documento_url` **y** `system_config.terms_message` |
| Respaldo texto plano del consentimiento | Se arma con el dominio del servidor |
> Si el enlace roto es el botón de una plantilla, **cambiar el código no lo arregla**. Hay que editar la plantilla en el WhatsApp Manager de Meta y esperar la reaprobación.
Para el documento de términos, hay que cambiar **los dos** lugares a la vez:
```sql
UPDATE terms_versions
SET documento_url = REPLACE(documento_url, 'dominio.viejo', 'dominio.nuevo')
WHERE documento_url LIKE '%dominio.viejo%';
UPDATE system_config
SET config_value = REPLACE(config_value, 'dominio.viejo', 'dominio.nuevo')
WHERE config_value LIKE '%dominio.viejo%';
```
---
## «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`. Revise que el envío haya especificado el canal:
```php
$wa = new WhatsAppService('turnero'); // no new WhatsAppService()
```
---
## «LIA responde cortado»
El asistente del dashboard del turnero tiene tope de salida. Si la respuesta se corta a media frase, ahora avisa con *«respuesta cortada por longitud»*.
| Qué revisar | Dónde |
|---|---|
| Tope de tokens de salida | `modules/turnero/api/ai_chat.php`, `maxOutputTokens` |
| Presupuesto consumido | `lab_config.lia_tokens_usados` (tope: 1.000.000) |
| Clave configurada | `lab_config.gemini_api_key` |
Si el presupuesto se agotó, LIA se bloquea y pide contactar a soporte. Para reiniciar el contador:
```sql
UPDATE lab_config SET valor = '0' WHERE clave = 'lia_tokens_usados';
```
> El contexto del día se manda completo en **cada** pregunta, así que el gasto por consulta es alto aunque la respuesta sea corta.
---
## «El formulario de tomas prolongadas muestra secciones que no corresponden»
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. 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.
---
## «No aparece quién firmó una toma»
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, compruebe que el usuario tenga cédula:
```sql
SELECT id, username, full_name, cedula FROM admin_users WHERE id = <id>;
```
Los enfermeros la toman de `lab_enfermeras.numero_documento`; el resto de `admin_users.cedula`.
---
## «El bot no responde»
| Revisar | Cómo |
|---|---|
| ¿Llegan los mensajes? | Tabla `webhook_logs` |
| ¿Está en horario? | `BusinessHoursService` — fuera de horario responde distinto |
| ¿La conversación quedó con un operador? | Estado en `conversations`; el bot no interrumpe una atención humana |
| ¿Aceptó los términos? | `users.terms_accepted_at`; sin aceptar, el bot no avanza |
---
## «Un paciente quedó con una muestra pendiente»
Cuando el paciente vuelve, las muestras pendientes **y rechazadas** de visitas anteriores aparecen automáticamente en la estación de toma de muestras, con la etiqueta *visita anterior* y los exámenes de aquella orden.
Al recibirla queda registrado en qué turno se completó (`turnero_muestras.recibida_en_turno_id`), y ambos turnos quedan enlazados en el historial y en la bandeja. **El turno original no se modifica**: sigue finalizado como estaba.
---
## Consultas útiles
```sql
-- Facturación real de hoy (solo turnos finalizados)
SELECT ROUND(SUM(ts.total_cobrado)) AS facturado, COUNT(*) AS turnos
FROM turnero_turnos t
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
JOIN turnero_sesiones s ON s.id = t.sesion_id
WHERE s.fecha = CURDATE() AND t.estado = 'finalizado' AND ts.total_cobrado > 0;
-- Consentimientos sin firmar
SELECT tc.estado, COUNT(*) FROM turnero_consentimientos tc
JOIN turnero_turnos t ON t.id = tc.turno_id
JOIN turnero_sesiones s ON s.id = t.sesion_id
WHERE s.fecha = CURDATE() GROUP BY tc.estado;
-- Muestras pendientes acumuladas por paciente
SELECT p.nombre_completo, COUNT(*) AS pendientes
FROM turnero_muestras tm
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
JOIN lab_pacientes p ON p.id = ts.paciente_id
WHERE tm.estado IN ('pendiente','rechazada')
GROUP BY p.id ORDER BY pendientes DESC LIMIT 20;
```
@@ -0,0 +1,88 @@
# Configuraciones críticas
Dónde vive cada cosa que se configura. La pregunta que más tiempo hace perder es *«¿esto dónde se cambia?»*, sobre todo porque no todo está en la base de datos.
## Las tres tablas de configuración
| Tabla | Contenido | Se edita desde |
|---|---|---|
| `system_config` | Credenciales de WhatsApp, webhook, mensaje de términos | Base de datos |
| `lab_config` | Datos de la empresa, encabezados de documentos, clave y consumo de LIA | Configuración del laboratorio |
| `turnero_*` | Lugares, prioridades, dispositivos, playlist de TV | Configuración del turnero |
## Lo que NO está en la base de datos
Esto es lo que más confunde:
| Configuración | Dónde vive de verdad |
|---|---|
| URL del botón de consentimiento | **Plantilla en el WhatsApp Manager de Meta** |
| Texto y formato de las plantillas | **Meta** (`message_templates` es solo una copia) |
| Dominio del sistema | Se deduce del `HTTP_HOST` de cada petición |
> Cambiar el código **no** cambia la URL que reciben los pacientes en el botón de una plantilla. Eso se edita en Meta y requiere reaprobación.
## Datos de la empresa
En `lab_config`, salen impresos en el encabezado de todos los documentos:
| Clave | Ejemplo |
|---|---|
| `empresa_nombre` | XIMENA CAICEDO G. E.U |
| `empresa_subtitulo` | Laboratorio Hematológico |
| `empresa_direccion` | Calle 21 #0A-26, Barrio Blanco |
| `empresa_ciudad` | Cúcuta, Norte de Santander |
| `empresa_telefono` | +57 305 337 0116 |
| `empresa_email` | servicioalcliente@laboratorioximenacaicedo.com |
| `doc_logo_base64` | Logo embebido |
| `doc_color` | Color de encabezados |
Se editan desde **Configuración del laboratorio**, sin tocar código.
## Dominio del sistema
`APP_URL` y `BASE_URL` se calculan en cada petición a partir del host (`config/config.php`):
```php
$__host = $_SERVER['HTTP_HOST'] ?? 'localhost';
define('APP_URL', $__proto . '://' . $__host);
```
Detecta HTTPS detrás de proxy reverso mediante `X-Forwarded-Proto`.
> Consecuencia: si alguien entra por una IP o un dominio alternativo, **los enlaces que se generen en esa sesión llevarán esa dirección** — y quedan guardados así en el mensaje que recibe el paciente. Si eso importa, conviene fijar `APP_URL` explícitamente.
## Turnero
| Qué | Dónde |
|---|---|
| Escritorios y estaciones | `turnero_lugares` |
| Formularios obligatorios por estación | `turnero_lugar_consentimientos` |
| Formularios obligatorios por examen | `exam_tipo_consentimientos` |
| Equipos fijos por IP o token | `turnero_dispositivos` |
| Prioridades de la cola | `turnero_prioridades` |
| Playlist de la pantalla de TV | `turnero_tv_media` |
Las estaciones de toma de muestras exigen el formulario **Datos Toma de Muestras (F-LAB-08)**, incluso en visitas marcadas como *solo entrega de muestras*.
## LIA
| Clave | Qué es |
|---|---|
| `lab_config.gemini_api_key` | Clave de la API de Google Gemini |
| `lab_config.lia_tokens_usados` | Consumo acumulado (tope: 1.000.000) |
El tope está en el código como `LIA_TOKENS_MAX`.
## Términos y condiciones
En **dos** lugares que hay que mantener sincronizados:
- `terms_versions` — versión activa, URL del documento, mensajes de aceptación y rechazo
- `system_config.terms_message` — el texto de bienvenida, que **repite la URL** dentro
## Cambios de esquema
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql` e idempotentes (`IF NOT EXISTS`, guardas en `UPDATE`/`INSERT`).
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.
@@ -0,0 +1,79 @@
# Despliegue y mantenimiento
## Cómo se despliega
No hay build ni compilación. El código PHP se sirve directo:
```bash
git pull
```
Con eso los cambios están en producción. Es la contrapartida de no usar framework de frontend.
**Si el cambio incluye esquema de base de datos**, hay que correr la migración además del `git pull`.
## Repositorio
| | |
|---|---|
| Remoto | `gitea` |
| Rama | `main` |
Se trabaja directo sobre `main`.
## Migraciones
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql`.
**Deben ser idempotentes** — poder correrse más de una vez sin causar daño:
```sql
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS cedula VARCHAR(30) NULL AFTER cargo;
UPDATE admin_users SET cedula = username
WHERE cedula IS NULL AND username REGEXP '^[0-9]{5,15}$';
```
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, 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 toca el esquema, deje el archivo.
## Archivos subidos
| Carpeta | Contenido |
|---|---|
| `uploads/turnero/tv_media/` | Videos e imágenes de la pantalla de TV |
| `uploads/terms/` | Documentos de términos y condiciones |
Se crean solas al primer uso. **No están en el repositorio**: al mover el sistema de servidor hay que copiarlas aparte, o los enlaces quedan rotos.
## Verificaciones después de desplegar
```bash
# Sintaxis de los archivos tocados
php -l archivo.php
# ¿Responde un enlace público?
curl -s -o /dev/null -w "%{http_code}\n" "https://<dominio>/<ruta>"
```
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
Las credenciales se leen de variables de entorno (`.env`, vía phpdotenv) y de `system_config`. El dominio no se configura: se deduce del `HTTP_HOST` de cada petición.
## Mantenimiento periódico
| Cada | Revisar |
|---|---|
| Semana | Consumo de LIA (`lab_config.lia_tokens_usados`) contra el tope de 1.000.000 |
| Mes | Que los enlaces enviados por WhatsApp respondan — sobre todo el de términos |
| Mes | Consentimientos que quedaron sin firmar |
| Trimestre | Plantillas de Meta: que sigan aprobadas |
| Trimestre | Muestras pendientes acumuladas por paciente |
Las consultas para varias de estas revisiones están en el [runbook](?m=soporte&v=documentacion&s=operacion&d=runbook).
@@ -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.
@@ -0,0 +1,41 @@
# Índice de módulos
Inventario de los módulos del sistema, generado del filesystem en cada carga.
{{modulos}}
## Cómo leer esta tabla
**Vistas** son las pantallas (`modules/<slug>/views/*.php`). **Endpoints** son los archivos que devuelven JSON (`modules/<slug>/api/*.php`).
Un módulo con **1 vista y 0 endpoints** suele ser un puente al sistema anterior: la vista solo incluye el archivo de la raíz, donde está el código real.
```php
// modules/lab_domicilios/views/index.php
require_once APP_ROOT . '/lab_domicilios.php';
```
**En SYSTEM_MODULES** indica si el módulo pasa por el control de permisos. Los que dicen «no» quedan accesibles para cualquier sesión — es el caso de módulos auxiliares que se consumen desde otras pantallas.
## Dónde está el código de verdad
| Módulo | Código real |
|---|---|
| `turnero` | En el módulo. Es el más grande y el más nuevo |
| `registro_exams` | En el módulo |
| `lab_examenes`, `medicos` | En el módulo |
| `lab_domicilios`, `lab_pacientes`, `lab_ordenes`, y demás `lab_*` | Archivo de la raíz; el módulo es un puente |
| `whatsapp` | `index.php` y `services/BotService.php` |
| `enfermero_portal` | `enfermero_portal.php` |
## Servicios y clases compartidas
{{servicios}}
## Detalle por módulo
- [Turnero](?m=soporte&v=documentacion&s=tecnica&d=turnero)
- [WhatsApp y bot](?m=soporte&v=documentacion&s=tecnica&d=whatsapp-bot)
- [Formularios](?m=soporte&v=documentacion&s=tecnica&d=formularios)
- [Domicilios](?m=soporte&v=documentacion&s=tecnica&d=domicilios)
- [Todos los endpoints](?m=soporte&v=documentacion&s=tecnica&d=endpoints)
+104
View File
@@ -0,0 +1,104 @@
# Módulo Turnero
El módulo más grande del sistema: 12 vistas y unos 70 endpoints. Gestiona la atención presencial completa.
## Vistas
| Vista | Para quién | Qué hace |
|---|---|---|
| `dashboard` | Admin, supervisor | Métricas del día, facturación, asistente LIA |
| `historial` | Admin, supervisor | Turnos de varios días, filtros, exportación |
| `bandeja` | Bacteriólogo, admin | Turnos del día con su detalle completo |
| `recepcion` | Recepcionista | Atención en el mostrador |
| `lugar` | Bacteriólogo | Estación de toma de muestras |
| `kiosko` | Público | El paciente saca su turno |
| `display_global` | Público | Pantalla de TV de la sala |
| `verificar_paciente` | Recepción | Consulta rápida de una ficha |
| `chat` | Recepción | Conversación de WhatsApp del turnero |
| `configuracion` | Admin | Lugares, dispositivos, plantillas, pantalla de TV |
`kiosko` y `display_global` son las **únicas rutas públicas** del sistema (`core/Router.php`): no hay quién inicie sesión en un televisor ni en el tótem de la entrada.
## Menú dinámico
`modules/turnero/module.php` no devuelve una lista fija: la arma según **el rol y la IP del equipo**.
```
recepcionista → chat, verificar paciente, TV, y su escritorio
bacteriólogo → bandeja y su estación
admin → todo
```
Si el equipo está en `turnero_dispositivos` (por IP o por cookie `turnero_token`), el usuario ve **solo su puesto**. Si no, los ve todos. Evita que alguien atienda desde el escritorio equivocado.
## Modelo de datos
```
turnero_sesiones un día de operación
└── turnero_turnos código, estado, tiempos, prioridad
├── turnero_solicitudes qué se hace y cuánto se cobró
│ ├── turnero_examen_items
│ └── turnero_muestras
├── turnero_consentimientos
└── turnero_comentarios
```
### Estados
```
espera → en_recepcion → en_espera_lugar → en_servicio → finalizado
ausente / cancelado
```
Solo `finalizado` cuenta como facturación real.
## Consentimientos
Se crean automáticamente desde **dos fuentes** que se acumulan:
| Fuente | Tabla |
|---|---|
| Por examen | `exam_tipo_consentimientos` |
| Por estación destino | `turnero_lugar_consentimientos` |
`get_consentimientos.php` los autocrea si faltan y calcula el estado de cada uno.
> En visitas de *solo entrega de muestras* no aplican los formularios por examen (no hay exámenes), pero **sí los de estación**. Por eso F-LAB-08 se exige igual.
## Tomas prolongadas
El formulario **F-LAB-28** cubre exámenes seriados. La lógica está repartida entre `ver_formulario_enviado.php` (render) y `modules/turnero/api/guardar_toma.php` (guardado).
- El esquema trae secciones para todos los exámenes posibles; se muestran solo las del examen marcado, mediante la `condicion` de cada separador.
- `_tomas_config` en `datos_respuestas` guarda qué ciclos se configuraron para ese paciente.
- Cada firma se guarda en su propio campo (`_tm00_f`, `_tm30_f`, …) junto con la hora y **la identidad de quien firmó**, resuelta en el servidor desde la sesión.
- Al firmar, el endpoint calcula cuándo toca la siguiente toma y actualiza `siguiente_toma_at`.
> La identidad se resuelve **en el servidor**, no se acepta del cliente: cada toma puede firmarla un profesional distinto y esa es la única fuente confiable. Las firmas anteriores al 3 de agosto de 2026 no tienen ese dato y no es recuperable.
## Muestras entre visitas
Una muestra que queda `pendiente` o `rechazada` reaparece cuando el paciente vuelve, con la bandera `es_pendiente_anterior` y los exámenes de la orden original.
Al recibirla, `turnero_muestras.recibida_en_turno_id` registra en qué turno se completó. Historial y bandeja usan ese dato para enlazar ambos turnos en los dos sentidos.
**El turno original no se modifica**: sigue finalizado. Solo se agrega la trazabilidad.
## LIA
`api/ai_chat.php` — asistente sobre Gemini Flash.
| Aspecto | Detalle |
|---|---|
| Contexto | Se arma en cada llamada con hasta 60 turnos del día y sus detalles |
| Historial | Los últimos intercambios se envían para que entienda preguntas de seguimiento |
| Tope de salida | `maxOutputTokens`; si Gemini corta, se avisa con `finishReason` |
| Presupuesto | `lab_config.lia_tokens_usados` contra `LIA_TOKENS_MAX` |
Se contabiliza `totalTokenCount`, que **incluye el contexto de entrada**. Como el contexto va completo en cada pregunta, el gasto por consulta es alto aunque la respuesta sea breve.
## Endpoints propios
Los del turnero usan `api/_helpers.php`, que provee `db()`, `inputJson()`, `jsonOk()`, `jsonError()`, `adminId()`, `requireTurnero()` y `notificarSSE()`.
`notificarSSE()` avisa a las pantallas conectadas para que se refresquen sin recargar.
@@ -0,0 +1,104 @@
# Formularios y firma digital
Cómo se definen los formularios, cómo se envían y cómo se firman. Es transversal: lo usan el turnero, los domicilios y los envíos sueltos.
## Definición
Un formulario es una fila en `lab_formularios`. Su estructura está en la columna `esquema`, un JSON con la lista de campos:
```json
[
{"id": "_sep1", "tipo": "separador", "label": "Datos del paciente"},
{"id": "_nom", "tipo": "linked", "linked_key": "nombre_completo", "label": "Nombre"},
{"id": "_sint", "tipo": "checkbox", "label": "Síntomas", "options": ["Fiebre", "Tos"]},
{"id": "_fir", "tipo": "firma_profesional", "label": "Firma del profesional"}
]
```
### Tipos de campo
| Tipo | Qué es |
|---|---|
| `separador` | Encabezado de sección; admite `condicion` |
| `parrafo` | Texto fijo (consentimientos, notas legales) |
| `texto`, `textarea`, `numero` | Entrada libre |
| `fecha`, `fecha_hoy`, `hora` | Fechas y horas |
| `radio`, `checkbox`, `select` | Opciones |
| `linked` | Se autocompleta con un dato del paciente vía `linked_key` |
| `firma` | Firma del paciente |
| `firma_profesional` | Firma del profesional |
### Secciones condicionales
Un separador puede depender de otro campo:
```json
{"id": "_sep_ins", "tipo": "separador", "label": "Insulina · Minuto 0",
"condicion": {"campo_id": "_examen", "valores": ["Insulina"]}}
```
La sección y **todos sus campos** se ocultan si la condición no se cumple. Los campos heredan el estado mediante el atributo `data-sep-id`.
## Las dos vías de envío
Un mismo formulario se firma por dos caminos, con tablas y tokens distintos:
| Vía | Tabla | Token | Respuestas |
|---|---|---|---|
| Turnero | `turnero_consentimientos` | UUID → `?token=` | `datos_respuestas` |
| Domicilios y envíos | `lab_form_envios` | 64 hex → `?t=` | `datos_cliente` |
Las dos las muestra `ver_formulario_enviado.php`, que distingue **por el formato del token**. De ahí que existan dos parámetros para lo que parece lo mismo.
`form_cliente.php` detecta tokens con formato UUID y redirige a `ver_formulario_enviado.php` — necesario porque la plantilla de WhatsApp aprobada en Meta apunta a la primera página.
## Parámetros del visor
| Parámetro | Efecto |
|---|---|
| `token` | Consentimiento del turnero (UUID) |
| `t` | Envío de formulario (64 hex) |
| `id` | Acceso interno con sesión |
| `embed=1` | Modo embebido; **activa el filtrado de secciones** |
| `compact=1` | Grilla de campos y tarjetas de toma |
| `zoom` | Escala |
| `autoprint=1` | Abre el diálogo de impresión |
> `embed=1` no es cosmético: sin él no se aplica el filtrado de secciones de tomas prolongadas y el documento muestra secciones que no corresponden. Bandeja e historial lo pasan siempre.
## Firma
### Del paciente
Se dibuja en un canvas y se guarda como imagen en `datos_cliente['<campo>_svg']`. También se admite pad biométrico Topaz.
### Del profesional
Se dibuja igual, pero además queda **quién firmó**. Hay tres endpoints según el contexto:
| Endpoint | Contexto |
|---|---|
| `modules/turnero/api/guardar_toma.php` | Tomas prolongadas — una firma por toma |
| `modules/turnero/api/firmar_profesional_consentimiento.php` | Consentimientos del turnero |
| `api/lab/firmar_profesional.php` | Envíos de formularios |
La identidad se guarda como `_pro_nombre` y `_pro_cedula`; en tomas prolongadas, además por campo (`_tm30_f_pro_nombre`), porque cada toma puede firmarla alguien distinto.
De dónde sale la identidad:
```
admin_users.cedula → personal en general
lab_enfermeras.numero_documento → enfermeros (vía admin_users.enfermera_id)
```
> Al mostrar un documento firmado **no se usa un valor por defecto**: si no quedó guardado quién firmó, se muestra vacío. Antes se caía al usuario de la sesión actual, lo que atribuía la firma a quien simplemente estaba mirando el documento.
## Precarga desde una visita anterior
`modules/turnero/api/get_formulario_anterior.php` devuelve las respuestas del último formulario firmado del mismo paciente, para no reescribir la historia clínica en cada visita.
Excluye deliberadamente firmas e identidad del profesional anterior: cada visita se firma de nuevo, con la fecha de hoy y quien atienda.
## Diseñador
`lab_formulario_builder.php` permite armar el esquema desde la interfaz, sin escribir JSON a mano.
@@ -0,0 +1,96 @@
# WhatsApp y bot
El sistema nació como bot de WhatsApp y esa integración sigue siendo central.
## Servicios
{{servicios}}
## `WhatsAppService`
Envuelve la Cloud API de Meta. Se elige la línea al construirlo:
```php
$wa = new WhatsAppService(); // principal
$wa = new WhatsAppService('turnero'); // turnero
```
Ambas líneas comparten cuenta (WABA) y token; **lo único que cambia es el `phone_number_id`**. Si un mensaje sale por la línea equivocada, casi siempre falta el argumento.
Métodos principales:
```php
$wa->sendTextMessage($telefono, $texto, $meta);
$wa->sendTemplateMessage($telefono, $plantilla, $idioma, [], [], $componentes, $meta);
```
`$meta` acompaña el registro del mensaje: `['canal' => 'turnero', 'operator_id' => adminId()]`.
## Plantillas
Fuera de la ventana de 24 horas hay que usar plantilla aprobada. `message_templates` guarda una copia local con sus `components`, pero **la copia no manda**: la versión real vive en Meta.
Consecuencia importante:
> Las URL de los botones están **en la plantilla**, no en el código. Nuestro código solo envía los parámetros (`{{1}}`). Cambiar el código no altera el enlace que recibe el paciente.
Ejemplo — botón de `consentimiento_turno_v2`:
```
https://erp.laboratorioximenacaicedo.com/form_cliente.php?t={{1}}
```
Y así se arman los componentes al enviar:
```php
$rawComps = [
['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $codigo]]],
['type' => 'button', 'sub_type' => 'url', 'index' => '0',
'parameters' => [['type' => 'text', 'text' => $token]]],
];
```
### Respaldo
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:
1. ¿Aceptó los términos? Si no, se los pide y no avanza.
2. ¿Está en horario? (`BusinessHoursService`)
3. ¿La conversación la tomó un operador humano? El bot no interrumpe.
4. Si no, responde según el estado de la conversación (`ConversationStateService`) y el menú (`MenuService`).
## Términos y condiciones
| Tabla | Contenido |
|---|---|
| `terms_versions` | Versión activa, URL del documento, mensajes |
| `terms_acceptance` | Historial de aceptaciones |
| `system_config.terms_message` | Texto de bienvenida, **repite la URL dentro** |
Se vuelve a pedir la aceptación si nunca aceptó, si pasaron más de 6 meses, o si hay versión nueva con `forzar_reenvio`.
> La URL del documento está en **dos** lugares. Cambiar solo uno deja al otro sirviendo un enlace viejo.
## Configuración
Todo en `system_config`: `whatsapp_token`, `whatsapp_api_url`, `whatsapp_business_account_id`, `whatsapp_phone_number_id`, `whatsapp_phone_number_id_turnero`, `webhook_verify_token`.
## Diagnóstico
| Síntoma | Dónde mirar |
|---|---|
| Sale por el número equivocado | Falta `'turnero'` en el constructor |
| Enlace roto en un botón | La plantilla en Meta |
| No llega ninguna plantilla | Estado de aprobación en WhatsApp Manager |
| Llega texto plano en vez de plantilla | El respaldo actuó: la plantilla falló |
| El bot no responde | `webhook_logs`, horario, estado de la conversación |
@@ -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,65 @@
# Domicilios
Visitas domiciliarias: agendamiento, asignación de enfermeros y seguimiento.
## Dónde está el código
Es de la generación anterior. El módulo es un puente:
```php
// modules/lab_domicilios/views/index.php
require_once APP_ROOT . '/lab_domicilios.php';
```
| Archivo | Rol |
|---|---|
| `lab_domicilios.php` | Pantalla administrativa |
| `enfermero_portal.php` | Portal del enfermero, pensado para celular |
| `classes/lab/Domicilio.php` | Reglas de negocio |
| `classes/lab/Asignacion.php` | Vínculo enfermerodomicilio |
| `api/lab/*.php` | Endpoints |
## Datos
```
lab_domicilios
├── lab_asignaciones qué enfermero atiende
├── lab_domicilio_notas seguimiento, admite adjuntos
└── lab_domicilio_pagos cobros
```
Estados: `programado``en_curso``completado`, o `cancelado`.
## Permisos
Se combinan dos niveles.
**Nivel módulo**`hasModuleWrite('lab_domicilios')` decide si aparecen los botones de crear y editar. Un rol con `permission = 'read'` ve la pantalla sin poder modificar.
**Nivel registro** — un enfermero solo puede editar domicilios **que creó o que tiene asignados**. Se verifica en el servidor (`api/lab/save_domicilio.php`):
```sql
SELECT d.creado_por,
(SELECT COUNT(*) FROM lab_asignaciones a
WHERE a.domicilio_id = d.id AND a.enfermera_id = ?) AS asignado
FROM lab_domicilios d WHERE d.id = ?
```
> No alcanza con ocultar el botón en la vista: el endpoint verifica por su cuenta. Cualquier endpoint que modifique datos debe hacer lo mismo.
## Portal del enfermero
`enfermero_portal.php` admite `admin`, `superadmin` y `enfermero`. Los administradores pueden ver el portal de un enfermero concreto pasando `?eid=<id>`, útil para dar soporte.
Incluye:
- Agenda propia con creación y edición
- Notas de ficha y notas libres, con fotos y archivos
- Envío de formularios al paciente, o copia del enlace
- Enlace a WhatsApp con el mensaje ya redactado, presentando al profesional como parte del Laboratorio Ximena Caicedo
## Formularios
Los domicilios usan la vía `lab_form_envios` (token de 64 hex, parámetro `?t=`), a diferencia del turnero que usa UUID. Ver [Formularios](?m=soporte&v=documentacion&s=tecnica&d=formularios).
Cuando el enfermero firma, su nombre y cédula salen de `lab_enfermeras` a través de `admin_users.enfermera_id`.
@@ -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).
@@ -1,4 +1,7 @@
# Webhook WhatsApp — Endpoints y Características # Webhook de WhatsApp
_Migrado de `WEBHOOK_ENDPOINTS.md` (raíz del repositorio), donde vivía suelto._
## Endpoint principal ## Endpoint principal
@@ -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á.
@@ -0,0 +1,41 @@
# Endpoints
Inventario de los endpoints de todos los módulos, generado del filesystem en cada carga. La descripción sale del comentario de cabecera de cada archivo.
## Convenciones
Los endpoints son archivos PHP sueltos que devuelven JSON. **No pasan por el enrutador**: se invocan por su ruta real.
```
modules/turnero/api/get_historial.php
api/lab/save_domicilio.php
```
Cada módulo tiene su `api/_helpers.php` con lo común. Los archivos que empiezan con guión bajo son internos y no se listan acá.
### Helpers típicos
| Función | Qué hace |
|---|---|
| `db()` | Conexión PDO |
| `inputJson()` | Cuerpo de la petición como arreglo |
| `jsonOk($datos)` | Respuesta correcta |
| `jsonError($msg, $codigo)` | Error con código HTTP |
| `adminId()` | Id del usuario de la sesión |
| `requireMethod('POST')` | Corta si el método no coincide |
| `requireTurnero()` | Corta si no tiene acceso al turnero |
### Reglas al agregar uno
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. **Deje un comentario de cabecera** describiendo qué hace y qué recibe: es lo que aparece en la tabla de abajo.
## Inventario
{{endpoints}}
## Endpoints fuera de módulos
`api/lab/` agrupa los del laboratorio de la generación anterior — domicilios, pacientes, formularios, configuración. Siguen las mismas convenciones y usan `api/lab/_helpers.php`.
+26
View File
@@ -0,0 +1,26 @@
<?php
/**
* Descriptor del módulo Soporte — documentación del proyecto.
* El manual de usuario es visible para cualquier usuario autenticado; el resto
* de secciones (técnica, arquitectura, operación) solo para administradores.
*/
// 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'],
];
return [
'slug' => 'soporte',
'name' => 'Soporte',
'icon' => 'fas fa-life-ring',
'category' => 'sistema',
'route' => '/erp.php?m=soporte&v=documentacion',
'is_active' => true,
'sort_order' => 90,
'oleada' => 1,
'description' => 'Documentación del proyecto: manual de usuario, técnica, arquitectura y operación',
'links' => $_sopLinks,
];
+512
View File
@@ -0,0 +1,512 @@
<?php
/**
* modules/soporte/views/documentacion.php
* Visor de la documentación del proyecto.
*
* ?s=<seccion>&d=<documento> documento puntual
* ?s=<seccion> portada de la sección
* (sin parámetros) portada general
*/
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();
$seccion = preg_replace('/[^a-z0-9-]/', '', $_GET['s'] ?? '');
$docSlug = preg_replace('/[^a-z0-9-]/', '', $_GET['d'] ?? '');
$archivo = ($seccion && $docSlug) ? DocIndex::resolver($seccion, $docSlug) : null;
$titulo = 'Documentación';
$cuerpo = '';
$toc = [];
if ($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) {
if ($d['slug'] === $docSlug) { $titulo = $d['titulo']; break; }
}
} elseif ($seccion && isset($arbol[$seccion])) {
$titulo = $arbol[$seccion]['titulo'];
}
Layout::open('Soporte · Documentación', 'fas fa-life-ring');
?>
<style>
.doc-wrap { display:flex; gap:0; align-items:flex-start; min-height:calc(100vh - 60px); background:#fff; }
/* ── Índice lateral ── */
.doc-nav { width:280px; flex-shrink:0; border-right:1px solid #e2e8f0; background:#f8fafc;
align-self:stretch; padding:18px 0 60px; position:sticky; top:0; max-height:100vh; overflow-y:auto; }
.doc-nav .buscador { padding:0 16px 14px; }
.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;
text-decoration:none; border-left:2px solid transparent; }
.doc-nav a:hover { background:#eef2f7; color:#1e293b; }
.doc-nav a.activo { background:#e0edff; color:#1d4ed8; font-weight:600; border-left-color:#2563eb; }
/* ── Resultados de búsqueda ── */
#resultados { padding:0 16px; }
#resultados .r { display:block; padding:8px 10px; border-radius:8px; text-decoration:none;
margin-bottom:4px; background:#fff; border:1px solid #e2e8f0; }
#resultados .r:hover { border-color:#2563eb; }
#resultados .r-t { font-size:.83rem; font-weight:600; color:#1e293b; }
#resultados .r-c { font-size:.68rem; color:#64748b; text-transform:uppercase; letter-spacing:.05em; }
#resultados .r-x { font-size:.75rem; color:#475569; margin-top:2px; }
#resultados .vacio { font-size:.8rem; color:#94a3b8; padding:8px 4px; }
/* ── Contenido ── */
.doc-main { flex:1; min-width:0; display:flex; gap:0; }
.doc-body { flex:1; min-width:0; padding:26px 40px 80px; max-width:900px; }
.doc-ruta { font-size:.73rem; color:#94a3b8; margin-bottom:14px; }
.doc-ruta a { color:#64748b; text-decoration:none; }
.doc-ruta a:hover { text-decoration:underline; }
.doc-body h1 { font-size:1.65rem; font-weight:800; color:#0f172a; margin:0 0 18px;
padding-bottom:12px; border-bottom:1px solid #e2e8f0; }
.doc-body h2 { font-size:1.2rem; font-weight:700; color:#1e293b; margin:32px 0 12px;
padding-top:6px; scroll-margin-top:20px; }
.doc-body h3 { font-size:1rem; font-weight:700; color:#334155; margin:24px 0 9px; scroll-margin-top:20px; }
.doc-body h4 { font-size:.9rem; font-weight:700; color:#475569; margin:18px 0 7px; }
.doc-body p { font-size:.9rem; line-height:1.7; color:#334155; margin:0 0 13px; }
.doc-body ul, .doc-body ol { font-size:.9rem; line-height:1.7; color:#334155; margin:0 0 13px; padding-left:24px; }
.doc-body li { margin-bottom:4px; }
.doc-body li > ul, .doc-body li > ol { margin:4px 0 2px; }
.doc-body a { color:#2563eb; }
.doc-body strong { color:#0f172a; }
.doc-body code { background:#f1f5f9; color:#be185d; padding:1px 5px; border-radius:4px;
font-size:.83em; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
.doc-body pre { background:#0f172a; color:#e2e8f0; padding:14px 16px; border-radius:10px;
overflow-x:auto; margin:0 0 15px; }
.doc-body pre code { background:none; color:inherit; padding:0; font-size:.8rem; line-height:1.6; }
.doc-body blockquote { border-left:3px solid #f59e0b; background:#fffbeb; margin:0 0 15px;
padding:10px 16px; border-radius:0 8px 8px 0; }
.doc-body blockquote p { margin:0; color:#78350f; font-size:.86rem; }
.doc-body hr { border:none; border-top:1px solid #e2e8f0; margin:26px 0; }
.tabla-scroll { overflow-x:auto; margin:0 0 16px; }
.doc-body table { width:100%; border-collapse:collapse; font-size:.82rem; }
.doc-body thead th { background:#f8fafc; text-align:left; font-weight:700; color:#475569;
padding:8px 11px; border-bottom:2px solid #e2e8f0; white-space:nowrap; }
.doc-body tbody td { padding:7px 11px; border-bottom:1px solid #f1f5f9; color:#334155; vertical-align:top; }
.doc-body tbody tr:hover { background:#f8fafc; }
/* ── Tabla de contenidos ── */
.doc-toc { width:210px; flex-shrink:0; padding:32px 20px 60px; position:sticky; top:0;
max-height:100vh; overflow-y:auto; }
.doc-toc-t { font-size:.66rem; font-weight:800; text-transform:uppercase; letter-spacing:.08em;
color:#94a3b8; margin-bottom:9px; }
.doc-toc a { display:block; font-size:.76rem; color:#64748b; text-decoration:none;
padding:3px 0 3px 9px; border-left:2px solid #e2e8f0; line-height:1.4; }
.doc-toc a:hover { color:#2563eb; border-left-color:#93c5fd; }
.doc-toc a.n3 { padding-left:20px; font-size:.73rem; }
/* ── Portadas ── */
.doc-hero { margin-bottom:26px; }
.doc-hero h1 { font-size:1.7rem; font-weight:800; color:#0f172a; margin:0 0 8px; }
.doc-hero p { font-size:.92rem; color:#64748b; margin:0; }
.tarjetas { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; }
.tarjeta { display:block; padding:18px; border:1px solid #e2e8f0; border-radius:12px;
text-decoration:none; background:#fff; transition:border-color .15s, transform .15s; }
.tarjeta:hover { border-color:#2563eb; transform:translateY(-2px); }
.tarjeta i { font-size:1.3rem; color:#2563eb; }
.tarjeta .t { font-size:1rem; font-weight:700; color:#1e293b; margin:9px 0 5px; }
.tarjeta .d { font-size:.82rem; color:#64748b; line-height:1.5; }
.tarjeta .n { font-size:.72rem; color:#94a3b8; margin-top:9px; }
.lista-docs { list-style:none; padding:0; margin:0; }
.lista-docs li { border-bottom:1px solid #f1f5f9; }
.lista-docs a { display:block; padding:12px 4px; text-decoration:none; color:#1e293b; font-size:.92rem; }
.lista-docs a:hover { color:#2563eb; }
@media (max-width:1100px) { .doc-toc { display:none; } }
@media (max-width:820px) {
.doc-nav { position:static; width:100%; max-height:none; border-right:none;
border-bottom:1px solid #e2e8f0; }
.doc-wrap { flex-direction:column; }
.doc-body { padding:20px 18px 60px; }
}
@media print {
.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">
<!-- ── Índice lateral ── -->
<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">
<?php foreach ($arbol as $sec => $cfg): ?>
<div class="doc-nav-sec">
<i class="<?= htmlspecialchars($cfg['icono']) ?>"></i>
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>"
style="padding:0;border:none;color:inherit;font:inherit;letter-spacing:inherit">
<?= htmlspecialchars($cfg['titulo']) ?>
</a>
</div>
<?php foreach ($cfg['docs'] as $d): ?>
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>&d=<?= urlencode($d['slug']) ?>"
class="<?= ($sec === $seccion && $d['slug'] === $docSlug) ? 'activo' : '' ?>">
<?= htmlspecialchars($d['titulo']) ?>
</a>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</nav>
<div class="doc-main">
<div class="doc-body">
<?php if ($archivo): ?>
<div class="doc-ruta">
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Documentación</a>
&nbsp;&nbsp;
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($seccion) ?>">
<?= htmlspecialchars($arbol[$seccion]['titulo'] ?? $seccion) ?>
</a>
&nbsp;&nbsp;<?= htmlspecialchars($titulo) ?>
</div>
<?= $cuerpo ?>
<?php elseif ($seccion && isset($arbol[$seccion])): ?>
<div class="doc-ruta">
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Documentación</a>
&nbsp;&nbsp;<?= htmlspecialchars($arbol[$seccion]['titulo']) ?>
</div>
<div class="doc-hero">
<h1><?= htmlspecialchars($arbol[$seccion]['titulo']) ?></h1>
<p><?= htmlspecialchars($arbol[$seccion]['resumen']) ?></p>
</div>
<?php if ($arbol[$seccion]['docs']): ?>
<ul class="lista-docs">
<?php foreach ($arbol[$seccion]['docs'] as $d): ?>
<li><a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($seccion) ?>&d=<?= urlencode($d['slug']) ?>">
<?= htmlspecialchars($d['titulo']) ?>
</a></li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="text-muted">Esta sección aún no tiene documentos.</p>
<?php endif; ?>
<?php else: ?>
<div class="doc-hero">
<h1>Documentación del sistema</h1>
<p>Todo sobre cómo funciona este ERP: cómo se usa, cómo está construido y qué hacer cuando algo falla.</p>
</div>
<div class="tarjetas">
<?php foreach ($arbol as $sec => $cfg): ?>
<a class="tarjeta" href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>">
<i class="<?= htmlspecialchars($cfg['icono']) ?>"></i>
<div class="t"><?= htmlspecialchars($cfg['titulo']) ?></div>
<div class="d"><?= htmlspecialchars($cfg['resumen']) ?></div>
<div class="n"><?= count($cfg['docs']) ?> documento<?= count($cfg['docs']) === 1 ? '' : 's' ?></div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if ($toc): ?>
<aside class="doc-toc">
<div class="doc-toc-t">En esta página</div>
<?php foreach ($toc as $t): ?>
<a href="#<?= htmlspecialchars($t['slug']) ?>" class="<?= $t['nivel'] === 3 ? 'n3' : '' ?>">
<?= htmlspecialchars($t['texto']) ?>
</a>
<?php endforeach; ?>
</aside>
<?php endif; ?>
</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) ?>;
const BASEQ = '<?= BASE_URL ?>erp.php?m=soporte&v=documentacion';
const norm = s => (s || '').toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '');
function buscar(termino) {
const q = norm(termino).trim();
const cajaR = document.getElementById('resultados');
const cajaA = document.getElementById('arbol');
if (q.length < 2) { cajaR.style.display = 'none'; cajaA.style.display = ''; return; }
const palabras = q.split(/\s+/);
const hits = [];
DOCS.forEach(d => {
const heno = norm(d.t + ' ' + d.c + ' ' + d.x);
if (!palabras.every(p => heno.includes(p))) return;
// El título pesa más que el cuerpo
const enTitulo = palabras.every(p => norm(d.t).includes(p));
const pos = heno.indexOf(palabras[0]);
hits.push({ d, score: (enTitulo ? 0 : 1000) + pos, pos });
});
hits.sort((a, b) => a.score - b.score);
cajaA.style.display = 'none';
cajaR.style.display = '';
if (!hits.length) {
cajaR.innerHTML = '<div class="vacio">Sin resultados para «' + esc(termino) + '»</div>';
return;
}
cajaR.innerHTML = hits.slice(0, 20).map(h => {
const ini = Math.max(0, h.pos - 40);
const frag = h.d.x.substr(ini, 120).trim();
return `<a class="r" href="${BASEQ}&s=${encodeURIComponent(h.d.s)}&d=${encodeURIComponent(h.d.u)}">
<div class="r-c">${esc(h.d.c)}</div>
<div class="r-t">${esc(h.d.t)}</div>
<div class="r-x">${ini > 0 ? '…' : ''}${esc(frag)}…</div>
</a>`;
}).join('');
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(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', () => {
clearTimeout(_t);
_t = setTimeout(() => buscar(inputQ.value), 120);
});
// Atajo: "/" enfoca el buscador
document.addEventListener('keydown', e => {
if (e.key === '/' && document.activeElement !== inputQ) { e.preventDefault(); inputQ.focus(); }
if (e.key === 'Escape' && document.activeElement === inputQ) { inputQ.value = ''; buscar(''); inputQ.blur(); }
});
</script>
<?php Layout::close(); ?>
+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;
}
}
+75 -13
View File
@@ -1,7 +1,7 @@
<?php <?php
/** /**
* modules/turnero/api/ai_chat.php * modules/turnero/api/ai_chat.php
* POST { pregunta: string } * POST { pregunta: string, historial?: [{rol:'user'|'model', texto:string}, ...] }
* Llama a Gemini Flash con contexto completo del turnero del día. * Llama a Gemini Flash con contexto completo del turnero del día.
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
@@ -14,12 +14,30 @@ $pregunta = trim($body['pregunta'] ?? '');
if ($pregunta === '') jsonError('Pregunta vacía', 400); if ($pregunta === '') jsonError('Pregunta vacía', 400);
if (mb_strlen($pregunta) > 800) jsonError('Pregunta demasiado larga', 400); if (mb_strlen($pregunta) > 800) jsonError('Pregunta demasiado larga', 400);
// Historial de la conversación, para que LIA entienda preguntas de seguimiento
// ("¿y de esos cuántos fueron en la mañana?"). Se limita a los últimos turnos
// para no inflar el consumo de tokens en cada llamada.
$historial = [];
foreach (array_slice(is_array($body['historial'] ?? null) ? $body['historial'] : [], -8) as $h) {
$rol = ($h['rol'] ?? '') === 'model' ? 'model' : 'user';
$texto = trim((string)($h['texto'] ?? ''));
if ($texto === '') continue;
$historial[] = ['role' => $rol, 'parts' => [['text' => mb_substr($texto, 0, 2000)]]];
}
define('LIA_TOKENS_MAX', 1_000_000);
$pdo = db(); $pdo = db();
// ── Clave Gemini ────────────────────────────────────────────── // ── Clave Gemini ──────────────────────────────────────────────
$row = $pdo->query("SELECT valor FROM lab_config WHERE clave = 'gemini_api_key' LIMIT 1")->fetch(); $cfg = $pdo->query("SELECT clave, valor FROM lab_config WHERE clave IN ('gemini_api_key','lia_tokens_usados')")->fetchAll(PDO::FETCH_KEY_PAIR);
$apiKey = trim($row['valor'] ?? ''); $apiKey = trim($cfg['gemini_api_key'] ?? '');
$tokensUsados = (int)($cfg['lia_tokens_usados'] ?? 0);
if (!$apiKey) jsonError('Token de IA no configurado. Ve a Configuración del laboratorio.', 503); if (!$apiKey) jsonError('Token de IA no configurado. Ve a Configuración del laboratorio.', 503);
if ($tokensUsados >= LIA_TOKENS_MAX) {
jsonError('⚠️ Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.', 402);
}
// ── Contexto del día ────────────────────────────────────────── // ── Contexto del día ──────────────────────────────────────────
$hoy = date('Y-m-d'); $hoy = date('Y-m-d');
@@ -164,29 +182,51 @@ 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 ──────────────────────────────────────────────────── // ── Prompt ────────────────────────────────────────────────────
$systemPrompt = <<<PROMPT $systemPrompt = <<<PROMPT
Eres el asistente inteligente del sistema de turnero del Laboratorio Clínico. Eres LIA, la asistente inteligente del sistema de turnero del Laboratorio Clínico.
Responde de forma concisa y útil en español. 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. 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.
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente. 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.
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: CONTEXTO DEL DÍA:
{$ctx} {$ctx}{$bloqueDocs}
PROMPT; PROMPT;
// ── Llamada a Gemini Flash ──────────────────────────────────── // ── Llamada a Gemini Flash ────────────────────────────────────
$payload = json_encode([ $payload = json_encode([
'system_instruction' => ['parts' => [['text' => $systemPrompt]]], 'system_instruction' => ['parts' => [['text' => $systemPrompt]]],
'contents' => [ 'contents' => array_merge($historial, [
['role' => 'user', 'parts' => [['text' => $pregunta]]] ['role' => 'user', 'parts' => [['text' => $pregunta]]]
], ]),
'generationConfig' => [ 'generationConfig' => [
'temperature' => 0.3, 'temperature' => 0.3,
'maxOutputTokens' => 600, // 600 cortaba a media frase las respuestas largas (listados de turnos,
// comparativas por bacteriólogo). Gemini Flash admite hasta 8192.
'maxOutputTokens' => 2048,
], ],
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
$ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"); $ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key={$apiKey}");
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true, CURLOPT_POST => true,
@@ -207,5 +247,27 @@ if ($code !== 200 || empty($gemini['candidates'][0]['content']['parts'][0]['text
jsonError("Error IA: {$detail}", 502); jsonError("Error IA: {$detail}", 502);
} }
$respuesta = $gemini['candidates'][0]['content']['parts'][0]['text']; $respuesta = $gemini['candidates'][0]['content']['parts'][0]['text'];
jsonOk(['respuesta' => $respuesta]);
// Gemini corta la respuesta y avisa con finishReason; sin esto el usuario recibe
// un texto truncado a media frase sin saber que faltó contenido.
$finishReason = $gemini['candidates'][0]['finishReason'] ?? null;
$truncada = ($finishReason === 'MAX_TOKENS');
if ($truncada) {
$respuesta .= "\n\n_(respuesta cortada por longitud — pide el detalle por partes)_";
}
$usage = $gemini['usageMetadata'] ?? [];
$tokensEsta = (int)($usage['totalTokenCount'] ?? (int)((mb_strlen($pregunta) + mb_strlen($respuesta)) / 4));
$tokensUsados += $tokensEsta;
$pdo->prepare("INSERT INTO lab_config (clave, valor) VALUES ('lia_tokens_usados', ?)
ON DUPLICATE KEY UPDATE valor = ?")->execute([$tokensUsados, $tokensUsados]);
jsonOk([
'respuesta' => $respuesta,
'truncada' => $truncada,
'tokens_usados' => $tokensUsados,
'tokens_restantes' => max(0, LIA_TOKENS_MAX - $tokensUsados),
'tokens_max' => LIA_TOKENS_MAX,
]);
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* POST /modules/turnero/api/anexar_formulario.php
* Crea un consentimiento adicional para un turno activo.
* Body JSON: { turno_id: int, formulario_id: int }
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$body = inputJson();
$turnoId = (int)($body['turno_id'] ?? 0);
$formularioId = (int)($body['formulario_id'] ?? 0);
if (!$turnoId) jsonError('turno_id requerido.');
if (!$formularioId) jsonError('formulario_id requerido.');
$pdo = db();
// Verificar que el turno existe y está activo
$turno = $pdo->prepare("SELECT id, sesion_id, estado FROM turnero_turnos WHERE id = ? LIMIT 1");
$turno->execute([$turnoId]);
$t = $turno->fetch(PDO::FETCH_ASSOC);
if (!$t) jsonError('Turno no encontrado.', 404);
if (!in_array($t['estado'], ['en_espera_lugar', 'en_servicio'])) jsonError('El turno no está activo.');
// Verificar que el formulario existe
$form = $pdo->prepare("SELECT id, nombre FROM lab_formularios WHERE id = ? AND is_active = 1 LIMIT 1");
$form->execute([$formularioId]);
if (!$form->fetch()) jsonError('Formulario no encontrado o inactivo.', 404);
// Crear el consentimiento con token UUID (mismo formato que create_solicitud)
$ins = $pdo->prepare(
"INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado, datos_respuestas, creado_por)
VALUES (?, ?, UUID(), 'enviado', '{}', ?)"
);
$ins->execute([$turnoId, $formularioId, adminId()]);
$newId = (int)$pdo->lastInsertId();
$token = $pdo->query("SELECT token FROM turnero_consentimientos WHERE id = $newId")->fetchColumn();
notificarSSE((int)$t['sesion_id']);
jsonOk(['id' => $newId, 'token' => $token], 'Formulario agregado.');
+3
View File
@@ -1,4 +1,7 @@
<?php <?php
/**
* GET ?q= — Busca diagnósticos CIE-10 por código o descripción.
*/
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
requireMethod('GET'); requireMethod('GET');
requireTurnero(); requireTurnero();
+85 -5
View File
@@ -22,6 +22,7 @@
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST'); requireMethod('POST');
requireTurnero(); requireTurnero();
@@ -39,8 +40,8 @@ if ($nuevoEstado === '') jsonError('nuevo_estado es requerido.');
const TRANSICIONES = [ const TRANSICIONES = [
'espera' => ['en_recepcion', 'ausente', 'cancelado'], 'espera' => ['en_recepcion', 'ausente', 'cancelado'],
'en_recepcion' => ['en_espera_lugar', 'espera', 'ausente', 'cancelado'], 'en_recepcion' => ['en_espera_lugar', 'espera', 'ausente', 'cancelado'],
'en_espera_lugar' => ['en_servicio', 'ausente', 'cancelado'], 'en_espera_lugar' => ['en_servicio', 'en_recepcion', 'ausente', 'cancelado'],
'en_servicio' => ['finalizado', 'ausente', 'cancelado', 'en_espera_lugar'], 'en_servicio' => ['finalizado', 'ausente', 'cancelado', 'en_espera_lugar', 'en_recepcion'],
]; ];
$pdo = db(); $pdo = db();
@@ -65,10 +66,60 @@ try {
$estadoActual = $turno['estado']; $estadoActual = $turno['estado'];
// Estados finales no modificables // Estados finales — solo admin (sin role_id) puede reabrir
if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) { if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) {
$pdo->rollBack(); $roleId = $_SESSION['admin_user']['role_id'] ?? null;
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422); if (!empty($roleId)) {
$pdo->rollBack();
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
}
$permitidosAdmin = ['en_servicio', 'en_espera_lugar', 'en_recepcion', 'espera'];
if (!in_array($nuevoEstado, $permitidosAdmin, true)) {
$pdo->rollBack();
jsonError("Estado destino inválido para reapertura. Opciones: " . implode(', ', $permitidosAdmin), 422);
}
$sets = ['estado = ?'];
$binds = [$nuevoEstado];
if ($nuevoEstado === 'en_servicio') {
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = COALESCE(inicio_lugar_at, NOW())';
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
$binds[] = adminId();
} elseif ($nuevoEstado === 'en_espera_lugar') {
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = NULL';
$sets[] = 'llamado_lugar_at = NULL';
} elseif ($nuevoEstado === 'espera') {
$sets[] = 'llamado_recepcion_at = NULL';
$sets[] = 'inicio_recepcion_at = NULL';
$sets[] = 'fin_recepcion_at = NULL';
$sets[] = 'llamado_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = NULL';
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'atendido_recepcion_por = NULL';
$sets[] = 'atendido_lugar_por = NULL';
$sets[] = 'recepcion_desk_id = NULL';
}
$sql = 'UPDATE turnero_turnos SET ' . implode(', ', $sets) . ' WHERE id = ?';
$binds[] = $turnoId;
$pdo->prepare($sql)->execute($binds);
$pdo->commit();
$stmt = $pdo->prepare(
'SELECT t.*, p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.id = ?'
);
$stmt->execute([$turnoId]);
$turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC);
notificarSSE((int) $turnoActualizado['sesion_id']);
jsonOk(['turno' => $turnoActualizado], "Turno reabierto: estado actualizado a '{$nuevoEstado}'");
} }
// Verificar que la transición sea válida // Verificar que la transición sea válida
@@ -116,6 +167,14 @@ try {
$sets[] = 'recepcion_desk_id = ?'; $sets[] = 'recepcion_desk_id = ?';
$binds[] = $deskId; $binds[] = $deskId;
} }
// Viene de un lugar: limpiar campos del lugar para que quede reasignable
if (in_array($estadoActual, ['en_espera_lugar', 'en_servicio'], true)) {
$sets[] = 'lugar_destino_id = NULL';
$sets[] = 'llamado_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = NULL';
$sets[] = 'fin_recepcion_at = NULL';
$sets[] = 'atendido_lugar_por = NULL';
}
break; break;
case 'en_espera_lugar': case 'en_espera_lugar':
@@ -175,6 +234,27 @@ try {
notificarSSE((int) $turnoActualizado['sesion_id']); notificarSSE((int) $turnoActualizado['sesion_id']);
// Queda constancia de quién cambió el estado a mano. Sin esto, en el
// historial aparecía un turno cancelado o ausente sin ninguna huella
// de quién lo hizo ni desde qué estado.
try {
(new ActividadAdmin())->registrar(
adminId(),
'turnero',
'cambiar_estado',
$turnoId,
[
'turno_id' => $turnoId,
'turno_codigo' => $turnoActualizado['codigo'] ?? null,
'estado_previo' => $estadoActual,
'estado_nuevo' => $nuevoEstado,
]
);
} catch (\Throwable $e) {
// La auditoría nunca debe tumbar la operación
error_log('[cambiar_estado] auditoría: ' . $e->getMessage());
}
jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'"); jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'");
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -5,6 +5,7 @@
* Body: { consentimiento_id } * Body: { consentimiento_id }
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
requireMethod('POST'); requireMethod('POST');
requireTurnero(); requireTurnero();
@@ -30,4 +31,35 @@ $stmt->execute([$turnoId, $consentId]);
if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404); if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404);
// Cancelar una toma pendiente cierra un protocolo que quedó a medias en otra
// visita: conviene saber quién lo dio por terminado.
try {
$inf = $pdo->prepare(
"SELECT tc.turno_id, t.codigo, f.nombre AS formulario
FROM turnero_consentimientos tc
JOIN turnero_turnos t ON t.id = tc.turno_id
JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.id = ? LIMIT 1"
);
$inf->execute([$consentId]);
$d = $inf->fetch(PDO::FETCH_ASSOC) ?: [];
(new ActividadAdmin())->registrar(
adminId(),
'turnero',
'cancelar_toma_pendiente',
$consentId,
[
// Se registra bajo el turno donde se hizo la cancelación, que es
// donde alguien la va a buscar en el historial.
'turno_id' => $turnoId,
'turno_origen_id' => isset($d['turno_id']) ? (int)$d['turno_id'] : null,
'turno_origen' => $d['codigo'] ?? null,
'formulario_nombre' => $d['formulario'] ?? null,
]
);
} catch (\Throwable $e) {
error_log('[cancelar_toma_pendiente] auditoría: ' . $e->getMessage());
}
jsonOk(['cancelado' => true]); jsonOk(['cancelado' => true]);
+24
View File
@@ -0,0 +1,24 @@
<?php
/**
* GET check_paciente.php?doc=NUMERO
* Endpoint público (mismo nivel que el kiosko). Solo devuelve fecha_nacimiento para validar edad.
*/
require_once __DIR__ . '/../../../config/config.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { echo json_encode(['ok' => false]); exit; }
$doc = preg_replace('/\D/', '', trim($_GET['doc'] ?? ''));
if (strlen($doc) < 5 || strlen($doc) > 15) { echo json_encode(['ok' => false]); exit; }
try {
$stmt = Database::getInstance()->getConnection()
->prepare("SELECT fecha_nacimiento FROM lab_pacientes WHERE numero_documento = ? LIMIT 1");
$stmt->execute([$doc]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || !$row['fecha_nacimiento']) { echo json_encode(['ok' => false]); exit; }
echo json_encode(['ok' => true, 'fecha_nacimiento' => $row['fecha_nacimiento']]);
} catch (\Throwable $e) {
echo json_encode(['ok' => false]);
}
+17 -2
View File
@@ -19,6 +19,7 @@ $otroHora = trim($body['otro_hora'] ?? '');
if (!$token) jsonError('token requerido.'); if (!$token) jsonError('token requerido.');
if (!is_array($cfg) || empty($cfg)) jsonError('tomas_config requerido.'); if (!is_array($cfg) || empty($cfg)) jsonError('tomas_config requerido.');
if ($examType === '') jsonError('exam_type requerido.');
foreach ($cfg as $grupo => $ids) { foreach ($cfg as $grupo => $ids) {
if (!is_array($ids) || empty($ids)) jsonError("El grupo '$grupo' no tiene tomas seleccionadas."); if (!is_array($ids) || empty($ids)) jsonError("El grupo '$grupo' no tiene tomas seleccionadas.");
@@ -41,8 +42,22 @@ if ($tc['datos_respuestas']) {
if (is_array($d)) $dr = $d; if (is_array($d)) $dr = $d;
} }
$dr['_tomas_config'] = $cfg; // Store per-exam nested: { examName: { groupKey: [firmaIds] } }
if ($examType !== '') $dr['_c8j2g16'] = $examType; $existing = isset($dr['_tomas_config']) && is_array($dr['_tomas_config']) ? $dr['_tomas_config'] : [];
// Reset old flat format (values were arrays of strings, not arrays of arrays)
if (!empty($existing)) {
$__first = reset($existing);
if (is_array($__first) && !empty($__first) && is_string(reset($__first))) $existing = [];
}
$existing[$examType] = $cfg;
$dr['_tomas_config'] = $existing;
// Guardar selección completa de exámenes (array) para que el reload restaure todos
$examTypes = isset($body['exam_types']) && is_array($body['exam_types']) ? array_values(array_filter($body['exam_types'])) : [];
if ($examTypes) {
$dr['_c8j2g16'] = count($examTypes) === 1 ? $examTypes[0] : $examTypes;
} elseif ($examType !== '' && !array_key_exists('_c8j2g16', $dr)) {
$dr['_c8j2g16'] = $examType;
}
if ($otroHora !== '') $dr['_otro_hora'] = $otroHora; if ($otroHora !== '') $dr['_otro_hora'] = $otroHora;
$pdo->prepare("UPDATE turnero_consentimientos SET datos_respuestas = ? WHERE id = ?") $pdo->prepare("UPDATE turnero_consentimientos SET datos_respuestas = ? WHERE id = ?")
+3 -3
View File
@@ -68,10 +68,10 @@ if ($existente) {
); );
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado) "INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado, creado_por)
VALUES (?, ?, ?, 'pendiente')" VALUES (?, ?, ?, 'pendiente', ?)"
); );
$stmt->execute([$turnoId, $formularioId, $token]); $stmt->execute([$turnoId, $formularioId, $token, adminId()]);
} }
$url = BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($token); $url = BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($token);
+3 -3
View File
@@ -187,15 +187,15 @@ try {
$consentimientosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC); $consentimientosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC);
$stmtCreateConsent = $pdo->prepare( $stmtCreateConsent = $pdo->prepare(
"INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado) "INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado, creado_por)
VALUES (?, ?, UUID(), 'pendiente')" VALUES (?, ?, UUID(), 'pendiente', ?)"
); );
$stmtRead = $pdo->prepare( $stmtRead = $pdo->prepare(
"SELECT id, token, estado FROM turnero_consentimientos "SELECT id, token, estado FROM turnero_consentimientos
WHERE turno_id = ? AND formulario_id = ?" WHERE turno_id = ? AND formulario_id = ?"
); );
foreach ($consentimientosRequeridos as &$c) { foreach ($consentimientosRequeridos as &$c) {
$stmtCreateConsent->execute([$turnoId, $c['formulario_id']]); $stmtCreateConsent->execute([$turnoId, $c['formulario_id'], adminId()]);
$stmtRead->execute([$turnoId, $c['formulario_id']]); $stmtRead->execute([$turnoId, $c['formulario_id']]);
$row = $stmtRead->fetch(PDO::FETCH_ASSOC); $row = $stmtRead->fetch(PDO::FETCH_ASSOC);
$c['id'] = $row['id'] ?? null; $c['id'] = $row['id'] ?? null;
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* modules/turnero/api/delete_tv_media.php
* Elimina un ítem de la playlist de la Pantalla TV (archivo + fila).
* POST JSON: { id: int }
*/
require_once __DIR__ . '/../../../config/config.php';
header('Content-Type: application/json; charset=utf-8');
if (!isUserLoggedIn()) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'No autenticado']);
exit;
}
$pdo = Database::getInstance()->getConnection();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$id = (int)($body['id'] ?? 0);
if (!$id) {
echo json_encode(['ok' => false, 'error' => 'id requerido']);
exit;
}
$stmt = $pdo->prepare("SELECT url FROM turnero_tv_media WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
echo json_encode(['ok' => false, 'error' => 'No encontrado']);
exit;
}
$uploadDir = dirname(__DIR__, 3) . '/uploads/turnero/tv_media/';
$filename = basename(parse_url($row['url'], PHP_URL_PATH));
$filepath = $uploadDir . $filename;
if (is_file($filepath)) {
@unlink($filepath);
}
$pdo->prepare("DELETE FROM turnero_tv_media WHERE id = ?")->execute([$id]);
echo json_encode(['ok' => true]);
+44
View File
@@ -0,0 +1,44 @@
<?php
/**
* POST /modules/turnero/api/enviar_encuesta.php
* Envía la plantilla "encuesta" al paciente del turno vía WhatsApp turnero.
* Body JSON: { turno_id: int }
*/
require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../services/WhatsAppService.php';
requireMethod('POST');
requireTurnero();
$body = inputJson();
$turnoId = (int)($body['turno_id'] ?? 0);
if (!$turnoId) jsonError('turno_id requerido.');
$pdo = db();
$row = $pdo->prepare(
"SELECT t.paciente_cel, t.paciente_nombre,
COALESCE(lp.telefono, t.paciente_cel) AS celular
FROM turnero_turnos t
LEFT JOIN lab_pacientes lp ON lp.id = t.paciente_id
WHERE t.id = ? LIMIT 1"
);
$row->execute([$turnoId]);
$pac = $row->fetch(PDO::FETCH_ASSOC);
if (!$pac) jsonError('Turno no encontrado.', 404);
$phone = preg_replace('/[^0-9]/', '', $pac['celular'] ?? '');
if (!$phone) jsonError('El paciente no tiene número de celular registrado.');
$langRow = $pdo->prepare("SELECT language_code FROM message_templates WHERE template_name = 'encuesta_turnero' LIMIT 1");
$langRow->execute();
$lang = $langRow->fetchColumn() ?: 'es';
try {
$wa = new WhatsAppService('turnero');
$meta = ['canal' => 'turnero', 'operator_id' => adminId()];
$wa->sendTemplateMessage($phone, 'encuesta_turnero', $lang, [], [], null, $meta);
jsonOk(['mensaje' => 'Encuesta enviada a ' . ($pac['paciente_nombre'] ?? $phone)]);
} catch (\Throwable $e) {
jsonError('Error al enviar: ' . $e->getMessage());
}
@@ -0,0 +1,89 @@
<?php
/**
* POST enviar_informe_dia.php
* Envía por WhatsApp un resumen del día al número indicado.
* Body JSON: { phone: string }
*/
require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/../../../services/WhatsAppService.php';
requireMethod('POST');
requireTurnero();
$body = inputJson();
$phone = preg_replace('/[^0-9]/', '', $body['phone'] ?? '');
if (strlen($phone) < 7) jsonError('Número de teléfono inválido.');
$pdo = db();
$hoy = date('Y-m-d');
$ses = $pdo->prepare("SELECT id, inicio_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1");
$ses->execute([$hoy]);
$sesion = $ses->fetch(PDO::FETCH_ASSOC);
if (!$sesion) jsonError('No hay sesión activa hoy.');
$sesId = (int)$sesion['id'];
// Resumen de estados
$stmt = $pdo->prepare("SELECT estado, COUNT(*) AS n FROM turnero_turnos WHERE sesion_id = ? GROUP BY estado");
$stmt->execute([$sesId]);
$estados = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) $estados[$r['estado']] = (int)$r['n'];
$total = array_sum($estados);
$atendidos = ($estados['finalizado'] ?? 0);
$enEspera = ($estados['espera'] ?? 0) + ($estados['en_recepcion'] ?? 0) + ($estados['en_espera_lugar'] ?? 0) + ($estados['en_servicio'] ?? 0);
$ausentes = ($estados['ausente'] ?? 0);
// Tiempos promedio
$tRow = $pdo->prepare(
"SELECT
ROUND(AVG(TIMESTAMPDIFF(SECOND, creado_at, COALESCE(inicio_recepcion_at, NOW())) / 60), 0) AS espera,
ROUND(AVG(TIMESTAMPDIFF(SECOND, inicio_lugar_at, COALESCE(fin_lugar_at, NOW())) / 60), 0) AS servicio,
ROUND(AVG(TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) / 60), 0) AS total
FROM turnero_turnos WHERE sesion_id = ?"
);
$tRow->execute([$sesId]);
$t = $tRow->fetch(PDO::FETCH_ASSOC);
// Facturación
$fRows = $pdo->prepare(
"SELECT ts.metodo_pago, SUM(ts.total_cobrado) AS total, COUNT(*) AS n
FROM turnero_solicitudes ts
JOIN turnero_turnos tt ON tt.id = ts.turno_id
WHERE tt.sesion_id = ? AND ts.total_cobrado > 0
GROUP BY ts.metodo_pago"
);
$fRows->execute([$sesId]);
$fact = $fRows->fetchAll(PDO::FETCH_ASSOC);
$totalFact = array_sum(array_column($fact, 'total'));
// Armar mensaje
$fecha = date('d/m/Y');
$hora = date('H:i');
$msg = "📊 *Informe del día - Lab. Ximena Caicedo*\n";
$msg .= "📅 {$fecha} · {$hora}\n\n";
$msg .= "👥 *Turnos:*\n";
$msg .= "• Total: {$total}\n";
$msg .= "• Finalizados: {$atendidos}\n";
if ($enEspera) $msg .= "• En atención/espera: {$enEspera}\n";
if ($ausentes) $msg .= "• Ausentes: {$ausentes}\n";
$msg .= "\n";
if ($fact) {
$msg .= "💰 *Facturación:*\n";
foreach ($fact as $f) {
$msg .= "{$f['metodo_pago']}: $" . number_format((float)$f['total'], 0, ',', '.') . " ({$f['n']} pac.)\n";
}
$msg .= "• *Total: $" . number_format($totalFact, 0, ',', '.') . "*\n\n";
}
$msg .= "⏱ *Tiempos promedio:*\n";
$msg .= "• Espera hasta recepción: " . ($t['espera'] ?? '—') . " min\n";
$msg .= "• Servicio en muestras: " . ($t['servicio'] ?? '—') . " min\n";
$msg .= "• Total puerta a puerta: " . ($t['total'] ?? '—') . " min\n";
try {
$wa = new WhatsAppService('turnero');
$wa->sendMessage($phone, $msg);
jsonOk(['mensaje' => "Informe enviado a {$phone}"]);
} catch (\Throwable $e) {
jsonError('Error al enviar: ' . $e->getMessage());
}
+108
View File
@@ -0,0 +1,108 @@
<?php
/**
* GET export_excel.php
* Exporta el resumen del día como .xls (HTML table, abre en Excel).
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('GET');
$pdo = db();
$hoy = trim($_GET['fecha'] ?? date('Y-m-d'));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $hoy)) $hoy = date('Y-m-d');
$ses = $pdo->prepare("SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1");
$ses->execute([$hoy]);
$sesion = $ses->fetch(PDO::FETCH_ASSOC);
$turnos = [];
if ($sesion) {
$sesId = (int)$sesion['id'];
$stmt = $pdo->prepare(
"SELECT t.codigo, p.codigo AS prioridad, t.estado,
COALESCE(sp.nombre_completo, t.paciente_nombre) AS paciente,
sp.tipo_documento AS tipo_doc, sp.numero_documento AS num_doc,
t.paciente_cel AS celular,
l.nombre AS lugar,
ur.full_name AS recepcionista,
ul.full_name AS bacteriologo,
ts.total_cobrado AS cobrado, ts.metodo_pago,
GROUP_CONCAT(DISTINCT et.nombre ORDER BY et.nombre SEPARATOR ', ') AS examenes,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, t.fin_recepcion_at)) / 60, 1) AS espera_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60, 1) AS servicio_min,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, t.fin_lugar_at) / 60, 1) AS total_min,
t.creado_at
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_solicitudes ts ON ts.turno_id = t.id
LEFT JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
LEFT JOIN exam_tipos et ON et.id = tei.exam_tipo_id
LEFT JOIN lab_pacientes sp ON sp.id = ts.paciente_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.sesion_id = ?
GROUP BY t.id ORDER BY t.numero ASC"
);
$stmt->execute([$sesId]);
$turnos = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
$fecha = date('d/m/Y', strtotime($hoy));
$archivo = 'informe-turnero-' . $hoy . '.xls';
header('Content-Type: application/vnd.ms-excel; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $archivo . '"');
header('Cache-Control: no-cache');
$estados = [
'finalizado'=>'Finalizado','ausente'=>'Ausente','cancelado'=>'Cancelado',
'en_servicio'=>'En servicio','en_espera_lugar'=>'Esp. lugar',
'en_recepcion'=>'Recepción','espera'=>'Espera',
];
echo '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">';
echo '<head><meta charset="UTF-8">
<style>
th { background:#1565c0; color:#fff; font-weight:bold; }
td,th { border:1px solid #ccc; padding:4px 8px; font-size:11px; }
.f { color:green; font-weight:bold; }
.a { color:red; }
</style></head><body>';
echo "<h2>Informe Turnero - {$fecha}</h2>";
echo '<table>';
echo '<tr>
<th>Código</th><th>Prioridad</th><th>Estado</th>
<th>Paciente</th><th>Tipo Doc</th><th>Documento</th><th>Celular</th>
<th>Lugar</th><th>Recepcionista</th><th>Bacteriólogo</th>
<th>Exámenes</th><th>Método Pago</th><th>Cobrado</th>
<th>Espera (min)</th><th>Servicio (min)</th><th>Total (min)</th>
<th>Hora entrada</th>
</tr>';
foreach ($turnos as $t) {
$cls = $t['estado'] === 'finalizado' ? ' class="f"' : ($t['estado'] === 'ausente' ? ' class="a"' : '');
$hora = $t['creado_at'] ? date('H:i', strtotime($t['creado_at'])) : '';
$cobrado = $t['cobrado'] ? number_format((float)$t['cobrado'], 0, ',', '.') : '';
$estadoLbl = $estados[$t['estado']] ?? $t['estado'];
echo "<tr{$cls}>
<td>{$t['codigo']}</td>
<td>{$t['prioridad']}</td>
<td>{$estadoLbl}</td>
<td>" . htmlspecialchars($t['paciente'] ?? '', ENT_QUOTES) . "</td>
<td>{$t['tipo_doc']}</td>
<td>{$t['num_doc']}</td>
<td>{$t['celular']}</td>
<td>" . htmlspecialchars($t['lugar'] ?? '', ENT_QUOTES) . "</td>
<td>" . htmlspecialchars($t['recepcionista'] ?? '', ENT_QUOTES) . "</td>
<td>" . htmlspecialchars($t['bacteriologo'] ?? '', ENT_QUOTES) . "</td>
<td>" . htmlspecialchars($t['examenes'] ?? '', ENT_QUOTES) . "</td>
<td>{$t['metodo_pago']}</td>
<td>{$cobrado}</td>
<td>{$t['espera_min']}</td>
<td>{$t['servicio_min']}</td>
<td>{$t['total_min']}</td>
<td>{$hora}</td>
</tr>\n";
}
echo '</table></body></html>';
@@ -17,8 +17,10 @@ $turnoId = (int)($body['turno_id'] ?? 0);
$formularioId = (int)($body['formulario_id'] ?? 0); $formularioId = (int)($body['formulario_id'] ?? 0);
$svg = $body['svg'] ?? ''; $svg = $body['svg'] ?? '';
$soloPro = !empty($body['solo_profesional']); $soloPro = !empty($body['solo_profesional']);
$datosResp = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas'])) // Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula)
? json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE) : null; $datosResp = null;
$datosArr = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
? $body['datos_respuestas'] : [];
if (!$turnoId) jsonError('turno_id requerido.'); if (!$turnoId) jsonError('turno_id requerido.');
if (!$formularioId) jsonError('formulario_id requerido.'); if (!$formularioId) jsonError('formulario_id requerido.');
@@ -29,6 +31,32 @@ if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
$pdo = db(); $pdo = db();
// Quién firma se resuelve aquí y no se toma del navegador. El cliente manda el
// nombre que cargó al abrir la página, y si el personal cambia de turno sin
// recargar, la firma quedaba a nombre de quien abrió el formulario en vez de
// quien la está haciendo. La sesión del servidor es la única fuente confiable.
// (guardar_toma.php ya lo hacía así para cada toma; esto alinea la firma final.)
$__uid = adminId();
if ($__uid) {
$stmtPro = $pdo->prepare(
"SELECT au.full_name, au.cedula AS au_cedula,
le.nombre_completo AS pro_nombre, le.numero_documento AS pro_documento
FROM admin_users au
LEFT JOIN lab_enfermeras le ON le.id = au.enfermera_id
WHERE au.id = ? LIMIT 1"
);
$stmtPro->execute([$__uid]);
$pro = $stmtPro->fetch(PDO::FETCH_ASSOC) ?: [];
$proNombre = ($pro['pro_nombre'] ?? null) ?: ($pro['full_name'] ?? null);
$proCedula = ($pro['au_cedula'] ?? null) ?: ($pro['pro_documento'] ?? null);
if ($proNombre) $datosArr['_pro_nombre'] = $proNombre;
if ($proCedula) $datosArr['_pro_cedula'] = $proCedula;
$datosArr['_pro_id'] = $__uid;
}
if (!empty($datosArr)) {
$datosResp = json_encode($datosArr, JSON_UNESCAPED_UNICODE);
}
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"SELECT tc.id, tc.estado, t.sesion_id "SELECT tc.id, tc.estado, t.sesion_id
FROM turnero_consentimientos tc FROM turnero_consentimientos tc
@@ -52,9 +80,12 @@ if ($soloPro) {
} else { } else {
$pdo->prepare( $pdo->prepare(
"UPDATE turnero_consentimientos "UPDATE turnero_consentimientos
SET firma_profesional_svg = ?, firmado_profesional_at = NOW() SET firma_profesional_svg = ?, firmado_profesional_at = NOW(),
datos_respuestas = CASE WHEN ? IS NOT NULL
THEN JSON_MERGE_PATCH(COALESCE(datos_respuestas,'{}'), ?)
ELSE datos_respuestas END
WHERE turno_id = ? AND formulario_id = ?" WHERE turno_id = ? AND formulario_id = ?"
)->execute([$svg, $turnoId, $formularioId]); )->execute([$svg, $datosResp, $datosResp, $turnoId, $formularioId]);
} }
notificarSSE((int)$tc['sesion_id']); notificarSSE((int)$tc['sesion_id']);
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* modules/turnero/api/get_bandeja.php
* GET → lista + stats del día (sesion activa)
* GET ?turno_id=X → detalle completo de un turno
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
$pdo = db();
$sesionId = (int)($pdo->query(
"SELECT id FROM turnero_sesiones ORDER BY id DESC LIMIT 1"
)->fetchColumn() ?: 0);
// ── DETALLE ───────────────────────────────────────────────────
if (isset($_GET['turno_id'])) {
$tid = (int)$_GET['turno_id'];
$turno = $pdo->prepare("
SELECT t.id, t.codigo, t.estado, t.paciente_nombre, t.inicio_lugar_at, t.fin_lugar_at,
t.bandeja_visto_at,
ts.id AS sol_id, ts.numero_orden,
p.tipo_documento, p.numero_documento, p.fecha_nacimiento, p.telefono,
l.nombre AS lugar_nombre
FROM turnero_turnos t
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
LEFT JOIN lab_pacientes p ON p.id = ts.paciente_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
WHERE t.id = ? LIMIT 1
");
$turno->execute([$tid]);
$data = $turno->fetch(PDO::FETCH_ASSOC);
if (!$data) jsonError('Turno no encontrado', 404);
$solId = (int)$data['sol_id'];
// Exámenes agrupados por categoría
$stmtEx = $pdo->prepare("
SELECT et.codigo, et.nombre, COALESCE(NULLIF(et.categoria,''),'Otros') AS categoria
FROM turnero_examen_items tei
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
WHERE tei.solicitud_id = ?
ORDER BY categoria, et.nombre
");
$stmtEx->execute([$solId]);
$examenesPorCat = [];
foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $e) {
$examenesPorCat[$e['categoria']][] = $e['codigo'];
}
// Muestras
$stmtM = $pdo->prepare("
SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at
FROM turnero_muestras tm
WHERE tm.solicitud_id = ?
ORDER BY tm.id
");
$stmtM->execute([$solId]);
$muestras = $stmtM->fetchAll(PDO::FETCH_ASSOC);
// Consentimientos
$stmtC = $pdo->prepare("
SELECT tc.id, tc.estado, tc.token, tc.firmado_at,
lf.nombre AS formulario_nombre
FROM turnero_consentimientos tc
JOIN lab_formularios lf ON lf.id = tc.formulario_id
WHERE tc.turno_id = ?
ORDER BY tc.id
");
$stmtC->execute([$tid]);
$consentimientos = $stmtC->fetchAll(PDO::FETCH_ASSOC);
// Comentarios (todos los tipos)
$stmtCom = $pdo->prepare("
SELECT tipo, usuario_nombre, comentario, creado_at
FROM turnero_comentarios
WHERE turno_id = ?
ORDER BY creado_at ASC
");
$stmtCom->execute([$tid]);
$comentarios = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
// Turnos vinculados por muestras pendientes completadas en otra visita (ambos sentidos)
$relacionados = [];
$stmtRelFwd = $pdo->prepare("
SELECT t2.id AS rel_id, t2.codigo AS rel_codigo, s2.fecha AS rel_fecha
FROM turnero_muestras tm
JOIN turnero_turnos t2 ON t2.id = tm.recibida_en_turno_id
JOIN turnero_sesiones s2 ON s2.id = t2.sesion_id
WHERE tm.solicitud_id = ? AND tm.recibida_en_turno_id IS NOT NULL
");
$stmtRelFwd->execute([$solId]);
foreach ($stmtRelFwd->fetchAll(PDO::FETCH_ASSOC) as $r) {
$relacionados[] = ['turno_id' => (int)$r['rel_id'], 'codigo' => $r['rel_codigo'], 'fecha' => $r['rel_fecha'], 'direccion' => 'seguimiento'];
}
$stmtRelBack = $pdo->prepare("
SELECT t1.id AS rel_id, t1.codigo AS rel_codigo, s1.fecha AS rel_fecha
FROM turnero_muestras tm
JOIN turnero_solicitudes ts1 ON ts1.id = tm.solicitud_id
JOIN turnero_turnos t1 ON t1.id = ts1.turno_id
JOIN turnero_sesiones s1 ON s1.id = t1.sesion_id
WHERE tm.recibida_en_turno_id = ?
");
$stmtRelBack->execute([$tid]);
foreach ($stmtRelBack->fetchAll(PDO::FETCH_ASSOC) as $r) {
$relacionados[] = ['turno_id' => (int)$r['rel_id'], 'codigo' => $r['rel_codigo'], 'fecha' => $r['rel_fecha'], 'direccion' => 'origen'];
}
jsonOk([
'turno' => $data,
'examenes_por_cat'=> $examenesPorCat,
'muestras' => $muestras,
'consentimientos' => $consentimientos,
'comentarios' => $comentarios,
'relacionados' => $relacionados,
]);
}
// ── LISTA ─────────────────────────────────────────────────────
$desde = $_GET['desde'] ?? null;
$hasta = $_GET['hasta'] ?? null;
if ($desde && $hasta) {
$whereClause = "DATE(t.inicio_lugar_at) BETWEEN ? AND ?";
$params = [$desde, $hasta];
} else {
$whereClause = "t.sesion_id = ?";
$params = [$sesionId];
}
$stmt = $pdo->prepare("
SELECT t.id, t.codigo, t.paciente_nombre, t.estado,
t.inicio_lugar_at, t.fin_lugar_at, t.bandeja_visto_at, t.muestra_espera_at,
ts.numero_orden,
l.nombre AS lugar_nombre,
GROUP_CONCAT(DISTINCT et.codigo ORDER BY et.codigo SEPARATOR ' ') AS examenes_txt,
SUM(tc.tipo = 'recepcion') AS com_recepcion,
SUM(tc.tipo = 'muestras') AS com_muestras,
SUM(tc.tipo = 'general') AS com_general,
(SELECT COUNT(*) FROM turnero_muestras tm2 WHERE tm2.solicitud_id = ts.id AND tm2.estado = 'rechazada') AS muestras_pendientes
FROM turnero_turnos t
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
LEFT JOIN exam_tipos et ON et.id = tei.exam_tipo_id
LEFT JOIN turnero_comentarios tc ON tc.turno_id = t.id
WHERE $whereClause AND t.inicio_lugar_at IS NOT NULL
GROUP BY t.id
ORDER BY ts.numero_orden ASC
");
$stmt->execute($params);
$todos = $stmt->fetchAll(PDO::FETCH_ASSOC);
$pendientes = array_values(array_filter($todos, fn($t) => $t['bandeja_visto_at'] === null));
$vistos = array_values(array_filter($todos, fn($t) => $t['bandeja_visto_at'] !== null));
$muestrasPendientes = count(array_filter($todos, fn($t) => (int)$t['muestras_pendientes'] > 0));
$prolongadasPendientes = count(array_filter($todos, fn($t) => !empty($t['muestra_espera_at']) && empty($t['fin_lugar_at'])));
jsonOk([
'pendientes' => $pendientes,
'vistos' => $vistos,
'stats' => [
'total' => count($todos),
'pendientes' => count($pendientes),
'vistos' => count($vistos),
'muestras_pendientes' => $muestrasPendientes,
'prolongadas_pendientes'=> $prolongadasPendientes,
],
]);
+33 -10
View File
@@ -83,7 +83,8 @@ if ($area === 'recepcion') {
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
WHERE t.sesion_id = ? WHERE t.sesion_id = ?
AND t.estado = 'en_espera_lugar' AND t.estado = 'en_espera_lugar'
AND t.lugar_destino_id IN ($inLugares)) AND t.lugar_destino_id IN ($inLugares)
AND t.muestra_espera_at IS NULL)
UNION ALL UNION ALL
(SELECT $cols (SELECT $cols
FROM turnero_turnos t FROM turnero_turnos t
@@ -91,7 +92,8 @@ if ($area === 'recepcion') {
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
WHERE t.sesion_id = ? WHERE t.sesion_id = ?
AND t.estado = 'en_servicio' AND t.estado = 'en_servicio'
AND t.lugar_destino_id = ?) AND t.lugar_destino_id = ?
AND t.muestra_espera_at IS NULL)
) AS cola_union ) AS cola_union
ORDER BY solo_muestras DESC, orden_peso ASC, creado_at ASC" ORDER BY solo_muestras DESC, orden_peso ASC, creado_at ASC"
); );
@@ -126,7 +128,7 @@ if (!empty($cola)) {
$campoLlamado = $area === 'recepcion' ? 'llamado_recepcion_at' : 'llamado_lugar_at'; $campoLlamado = $area === 'recepcion' ? 'llamado_recepcion_at' : 'llamado_lugar_at';
$estadoActivo = $area === 'recepcion' ? "'en_recepcion'" : "'en_servicio'"; $estadoActivo = $area === 'recepcion' ? "'en_recepcion'" : "'en_servicio'";
// Activo: para lugar filtrar SOLO por esta estación (ya fue asignada al llamar) // Activo: para lugar filtrar SOLO por esta estación (ya fue asignada al llamar)
$filtroActivo = $area === 'lugar' ? 'AND t.lugar_destino_id = ?' : ''; $filtroActivo = $area === 'lugar' ? 'AND t.lugar_destino_id = ? AND t.muestra_espera_at IS NULL' : '';
$bindActivo = $area === 'lugar' ? [$sesionId, $lugarId] : [$sesionId]; $bindActivo = $area === 'lugar' ? [$sesionId, $lugarId] : [$sesionId];
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
@@ -173,12 +175,33 @@ $stmt = $pdo->prepare(
$stmt->execute([$sesionId]); $stmt->execute([$sesionId]);
$stats = $stmt->fetch(PDO::FETCH_ASSOC); $stats = $stmt->fetch(PDO::FETCH_ASSOC);
// ── Pacientes en espera entre muestras (toma progresiva) ─────
$enEsperaMuestra = [];
if ($area === 'lugar') {
$stmtEsp = $pdo->prepare("
SELECT t.id, t.paciente_nombre, ts.numero_orden, t.muestra_espera_at,
(SELECT tc.siguiente_toma_at
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
WHERE tc.turno_id = t.id AND tc.estado = 'en_progreso'
ORDER BY tc.siguiente_toma_at ASC LIMIT 1) AS siguiente_toma_at
FROM turnero_turnos t
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
WHERE t.sesion_id = ? AND t.lugar_destino_id IN ($inLugares)
AND t.muestra_espera_at IS NOT NULL AND t.fin_lugar_at IS NULL
ORDER BY t.muestra_espera_at ASC
");
$stmtEsp->execute([$sesionId]);
$enEsperaMuestra = $stmtEsp->fetchAll(PDO::FETCH_ASSOC);
}
jsonOk([ jsonOk([
'sesion_id' => $sesionId, 'sesion_id' => $sesionId,
'area' => $area, 'area' => $area,
'lugar_id' => $lugarId, 'lugar_id' => $lugarId,
'activo' => $activo, 'activo' => $activo,
'cola' => $cola, 'cola' => $cola,
'stats' => $stats, 'en_espera_muestra' => $enEsperaMuestra,
'timestamp' => date('c'), 'stats' => $stats,
'timestamp' => date('c'),
]); ]);
+17 -22
View File
@@ -1,14 +1,15 @@
<?php <?php
/** /**
* GET modules/turnero/api/get_consecutivo.php * GET modules/turnero/api/get_consecutivo.php
* Devuelve el siguiente número de orden del día. * Devuelve el siguiente número de orden del día — atómico, sin race condition.
* *
* Parámetros: * Parámetros:
* tipo string 'F' = Físico/turnero (default) | 'D' = Domicilio * tipo string 'F' = Físico/turnero (default) | 'D' = Domicilio
* *
* Formato resultante: * Formato: F-YYYYMMDD-NNN | D-YYYYMMDD-NNN
* F-YYYYMMDD-NNN para tipo=F *
* D-YYYYMMDD-NNN para tipo=D * Usa turnero_consecutivos con UPDATE ... LAST_INSERT_ID() para garantizar
* que dos peticiones simultáneas nunca retornen el mismo número.
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
requireTurnero(); requireTurnero();
@@ -18,23 +19,17 @@ $tipo = strtoupper(trim($_GET['tipo'] ?? 'F'));
if (!in_array($tipo, ['F', 'D'], true)) $tipo = 'F'; if (!in_array($tipo, ['F', 'D'], true)) $tipo = 'F';
$pdo = db(); $pdo = db();
$prefix = $tipo . '-' . date('Ymd') . '-'; $prefijo = $tipo . '-' . date('Ymd'); // e.g. 'F-20260727'
$prefix = $prefijo . '-';
if ($tipo === 'D') { // Asegurar que exista la fila del día (INSERT IGNORE no toca si ya existe)
$stmt = $pdo->prepare( $pdo->prepare("INSERT IGNORE INTO turnero_consecutivos (prefijo, ultimo) VALUES (?, 0)")
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden, '-', -1) AS UNSIGNED)) AS ultimo ->execute([$prefijo]);
FROM lab_domicilios
WHERE numero_orden LIKE ?"
);
} else {
$stmt = $pdo->prepare(
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden, '-', -1) AS UNSIGNED)) AS ultimo
FROM turnero_solicitudes
WHERE numero_orden LIKE ?"
);
}
$stmt->execute([$prefix . '%']);
$ultimo = (int)($stmt->fetch(PDO::FETCH_ASSOC)['ultimo'] ?? 0);
$siguiente = $prefix . str_pad($ultimo + 1, 3, '0', STR_PAD_LEFT); // Incremento atómico: UPDATE toma lock de fila → dos hilos nunca leen el mismo valor
jsonOk(['consecutivo' => $siguiente]); $pdo->prepare("UPDATE turnero_consecutivos SET ultimo = LAST_INSERT_ID(ultimo + 1) WHERE prefijo = ?")
->execute([$prefijo]);
$siguiente = (int)$pdo->query("SELECT LAST_INSERT_ID()")->fetchColumn();
jsonOk(['consecutivo' => $prefix . str_pad($siguiente, 3, '0', STR_PAD_LEFT)]);
+2 -2
View File
@@ -46,7 +46,7 @@ $token = sprintf(
mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff) mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff)
); );
$pdo->prepare( $pdo->prepare(
"INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado) VALUES (?, ?, ?, 'pendiente')" "INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado, creado_por) VALUES (?, ?, ?, 'pendiente', ?)"
)->execute([$turnoId, $formularioId, $token]); )->execute([$turnoId, $formularioId, $token, adminId()]);
jsonOk(['token' => $token, 'nuevo' => true]); jsonOk(['token' => $token, 'nuevo' => true]);
+95 -48
View File
@@ -56,7 +56,8 @@ $stmt = $pdo->prepare(
tc.datos_respuestas, tc.datos_respuestas,
f.nombre AS formulario_nombre, f.nombre AS formulario_nombre,
f.esquema AS formulario_esquema, f.esquema AS formulario_esquema,
f.es_toma_progresiva AS formulario_es_toma_prog f.es_toma_progresiva AS formulario_es_toma_prog,
f.solo_profesional AS formulario_solo_profesional
FROM turnero_consentimientos tc FROM turnero_consentimientos tc
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.turno_id = ? WHERE tc.turno_id = ?
@@ -80,15 +81,19 @@ foreach ($consentimientos as &$c) {
// Toma progresiva: override explícito en DB gana; si es NULL, usar heurística (>1 campo firma_profesional) // Toma progresiva: override explícito en DB gana; si es NULL, usar heurística (>1 campo firma_profesional)
$overrideProg = $c['formulario_es_toma_prog'] ?? null; $overrideProg = $c['formulario_es_toma_prog'] ?? null;
$esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1; $esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1;
$tomasTotal = 0; $dr = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
$tomasFirm = 0; $tomasTotal = 0;
$tomasFirm = 0;
$tomasDetalle = []; // reiniciar cada vuelta: si no, un formulario sin tomas hereda las del anterior
if ($esTomaProg) { if ($esTomaProg) {
$dr = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
// Mapear cada firma_profesional a los valores de examen de su sección condicional // Mapear cada firma_profesional a los valores de examen de su sección condicional
// (y a su etiqueta + campo hora, para poder mostrar el detalle por toma)
$firmaCondMap = []; $firmaCondMap = [];
$curCondCampo = null; $curCondCampo = null;
$curCondVals = []; $curCondVals = [];
$curLabel = null;
$curHoraId = null;
foreach ($campos as $campo) { foreach ($campos as $campo) {
$ft = $campo['tipo'] ?? ''; $ft = $campo['tipo'] ?? '';
if ($ft === 'separador') { if ($ft === 'separador') {
@@ -97,8 +102,15 @@ foreach ($consentimientos as &$c) {
$curCondVals = $cond $curCondVals = $cond
? ($cond['valores'] ?? ($cond['valor'] ? [$cond['valor']] : [])) ? ($cond['valores'] ?? ($cond['valor'] ? [$cond['valor']] : []))
: []; : [];
$curLabel = $campo['label'] ?? '';
$curHoraId = null;
} elseif ($ft === 'hora') {
$curHoraId = $campo['id'] ?? null;
} elseif ($ft === 'firma_profesional' && !empty($campo['id'])) { } elseif ($ft === 'firma_profesional' && !empty($campo['id'])) {
$firmaCondMap[$campo['id']] = ['campo' => $curCondCampo, 'valores' => $curCondVals]; $firmaCondMap[$campo['id']] = [
'campo' => $curCondCampo, 'valores' => $curCondVals,
'label' => $curLabel, 'hora_id' => $curHoraId,
];
} }
} }
@@ -119,27 +131,48 @@ foreach ($consentimientos as &$c) {
// Si hay config de tomas guardada, restringir al subconjunto seleccionado // Si hay config de tomas guardada, restringir al subconjunto seleccionado
$tomasConfig = is_array($dr['_tomas_config'] ?? null) ? $dr['_tomas_config'] : null; $tomasConfig = is_array($dr['_tomas_config'] ?? null) ? $dr['_tomas_config'] : null;
if ($tomasConfig !== null) { if ($tomasConfig !== null) {
$allowedFids = array_merge(...array_values($tomasConfig)); $allowedFids = [];
foreach ($tomasConfig as $__v) {
if (!is_array($__v)) continue;
$__inner = reset($__v);
// nested: exam => { group => [fids] }; flat: group => [fids]
if (is_array($__inner)) { foreach ($__v as $__fids) foreach ($__fids as $__f) $allowedFids[] = $__f; }
else { foreach ($__v as $__f) $allowedFids[] = $__f; }
}
$allowedFids = array_unique($allowedFids);
$relevantIds = array_values(array_filter($relevantIds, fn($id) => in_array($id, $allowedFids, true))); $relevantIds = array_values(array_filter($relevantIds, fn($id) => in_array($id, $allowedFids, true)));
} }
$tomasTotal = count($relevantIds); $tomasTotal = count($relevantIds);
$tomasDetalle = [];
foreach ($relevantIds as $fid) { foreach ($relevantIds as $fid) {
if ((!empty($dr[$fid]) && strlen($dr[$fid]) > 10) $firmado = (!empty($dr[$fid]) && strlen($dr[$fid]) > 10)
|| (!empty($dr[$fid.'_svg']) && strlen($dr[$fid.'_svg']) > 10)) $tomasFirm++; || (!empty($dr[$fid.'_svg']) && strlen($dr[$fid.'_svg']) > 10);
if ($firmado) $tomasFirm++;
$info = $firmaCondMap[$fid] ?? [];
$horaId = $info['hora_id'] ?? null;
$tomasDetalle[] = [
'label' => $info['label'] ?? '',
'hora' => $horaId ? ($dr[$horaId] ?? null) : null,
'firmado' => $firmado,
'firmante' => $firmado ? ($dr[$fid . '_pro_nombre'] ?? $dr['_pro_nombre'] ?? null) : null,
];
} }
} }
$soloPro = !empty($c['formulario_solo_profesional']);
$c['requiere_firma_profesional'] = $tieneFirmaPro; $c['requiere_firma_profesional'] = $tieneFirmaPro;
$c['requiere_firma_paciente'] = !($tieneFirmaPro && !$tieneFirmaPac); $c['requiere_firma_paciente'] = $soloPro ? false : !($tieneFirmaPro && !$tieneFirmaPac);
$c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true) $c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true)
? ($lugarDestinoId ?: -1) : null; ? ($lugarDestinoId ?: -1) : null;
$c['es_toma_progresiva'] = $esTomaProg; $c['es_toma_progresiva'] = $esTomaProg;
$c['tomas_total'] = $tomasTotal; $c['tomas_total'] = $tomasTotal;
$c['tomas_firmadas'] = $tomasFirm; $c['tomas_firmadas'] = $tomasFirm;
$c['tomas_detalle'] = $tomasDetalle ?? [];
$c['cierre_anticipado'] = $dr['_cierre_anticipado'] ?? null;
// Exponer campos firma_profesional (ids) para el frontend // Exponer campos firma_profesional (ids) para el frontend
$c['campos_firma_pro'] = array_column($camposFirmaPro, 'id'); $c['campos_firma_pro'] = array_column($camposFirmaPro, 'id');
unset($c['formulario_esquema'], $c['formulario_es_toma_prog'], $c['datos_respuestas']); unset($c['formulario_esquema'], $c['formulario_es_toma_prog'], $c['formulario_solo_profesional'], $c['datos_respuestas']);
} }
unset($c); unset($c);
@@ -193,34 +226,9 @@ if ($incluirSolicitud) {
$stmt->execute([$solicitud['id']]); $stmt->execute([$solicitud['id']]);
$examenes = $stmt->fetchAll(PDO::FETCH_ASSOC); $examenes = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Visita de solo entrega de muestras: no requiere consentimientos ni formularios // Nota: en visitas de "solo entrega de muestras" no hay exámenes, así que la
if (!empty($solicitud['solo_muestras'])) { // Fuente 1 (por examen) no aplica, pero la Fuente 2 (por lugar, ej. F-LAB-08
$respuesta['consentimientos'] = []; // Datos Toma de Muestras) sigue siendo obligatoria — se procesa más abajo igual.
$respuesta['solicitud'] = $solicitud;
$respuesta['paciente'] = $paciente;
$respuesta['examenes'] = $examenes;
// Muestras pendientes de visitas anteriores
$muestras = [];
try {
$stmtPrev = $pdo->prepare(
"SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at,
COALESCE(lt.nombre, tm.tipo_muestra) AS label,
1 AS es_pendiente_anterior,
ts.numero_orden AS solicitud_orden_anterior
FROM turnero_muestras tm
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra
WHERE ts.paciente_id = ?
AND tm.solicitud_id != ?
AND tm.estado = 'pendiente'
ORDER BY tm.id ASC"
);
$stmtPrev->execute([$solicitud['paciente_id'], $solicitud['id']]);
$muestras = $stmtPrev->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable $_) {}
$respuesta['muestras'] = $muestras;
jsonOk($respuesta);
}
// Auto-crear consentimientos requeridos desde DOS fuentes: // Auto-crear consentimientos requeridos desde DOS fuentes:
// 1. Por examen → exam_tipo_consentimientos // 1. Por examen → exam_tipo_consentimientos
@@ -271,8 +279,8 @@ if ($incluirSolicitud) {
$existFormIds = array_map('intval', array_column($consentimientos, 'formulario_id')); $existFormIds = array_map('intval', array_column($consentimientos, 'formulario_id'));
$stmtIns = $pdo->prepare( $stmtIns = $pdo->prepare(
"INSERT IGNORE INTO turnero_consentimientos "INSERT IGNORE INTO turnero_consentimientos
(turno_id, formulario_id, token, estado) (turno_id, formulario_id, token, estado, creado_por)
VALUES (?, ?, ?, 'pendiente')" VALUES (?, ?, ?, 'pendiente', ?)"
); );
$creados = 0; $creados = 0;
foreach ($requeridos as $r) { foreach ($requeridos as $r) {
@@ -285,7 +293,7 @@ if ($incluirSolicitud) {
mt_rand(0,0x3fff)|0x8000, mt_rand(0,0x3fff)|0x8000,
mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff) mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff)
); );
$stmtIns->execute([$turnoId, (int)$r['formulario_id'], $token]); $stmtIns->execute([$turnoId, (int)$r['formulario_id'], $token, adminId()]);
$creados++; $creados++;
} }
} }
@@ -302,7 +310,8 @@ if ($incluirSolicitud) {
tc.datos_respuestas, tc.datos_respuestas,
f.nombre AS formulario_nombre, f.nombre AS formulario_nombre,
f.esquema AS formulario_esquema, f.esquema AS formulario_esquema,
f.es_toma_progresiva AS formulario_es_toma_prog f.es_toma_progresiva AS formulario_es_toma_prog,
f.solo_profesional AS formulario_solo_profesional
FROM turnero_consentimientos tc FROM turnero_consentimientos tc
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
WHERE tc.turno_id = ? WHERE tc.turno_id = ?
@@ -322,10 +331,10 @@ if ($incluirSolicitud) {
$tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma')); $tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma'));
$overrideProg = $c['formulario_es_toma_prog'] ?? null; $overrideProg = $c['formulario_es_toma_prog'] ?? null;
$esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1; $esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1;
$dr2 = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
$tomasTotal2 = 0; $tomasTotal2 = 0;
$tomasFirm = 0; $tomasFirm = 0;
if ($esTomaProg) { if ($esTomaProg) {
$dr2 = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
$firmaCondMap2 = []; $firmaCondMap2 = [];
$curC2 = null; $curV2 = []; $curC2 = null; $curV2 = [];
foreach ($campos as $campo2) { foreach ($campos as $campo2) {
@@ -350,7 +359,14 @@ if ($incluirSolicitud) {
} }
$cfg2 = is_array($dr2['_tomas_config'] ?? null) ? $dr2['_tomas_config'] : null; $cfg2 = is_array($dr2['_tomas_config'] ?? null) ? $dr2['_tomas_config'] : null;
if ($cfg2 !== null) { if ($cfg2 !== null) {
$allowed2 = array_merge(...array_values($cfg2)); $allowed2 = [];
foreach ($cfg2 as $__v2) {
if (!is_array($__v2)) continue;
$__i2 = reset($__v2);
if (is_array($__i2)) { foreach ($__v2 as $__fids2) foreach ($__fids2 as $__f2) $allowed2[] = $__f2; }
else { foreach ($__v2 as $__f2) $allowed2[] = $__f2; }
}
$allowed2 = array_unique($allowed2);
$relevantIds2 = array_values(array_filter($relevantIds2, fn($id) => in_array($id, $allowed2, true))); $relevantIds2 = array_values(array_filter($relevantIds2, fn($id) => in_array($id, $allowed2, true)));
} }
$tomasTotal2 = count($relevantIds2); $tomasTotal2 = count($relevantIds2);
@@ -358,15 +374,17 @@ if ($incluirSolicitud) {
if (!empty($dr2[$fid2]) && strlen($dr2[$fid2]) > 10) $tomasFirm++; if (!empty($dr2[$fid2]) && strlen($dr2[$fid2]) > 10) $tomasFirm++;
} }
} }
$soloPro2 = !empty($c['formulario_solo_profesional']);
$c['requiere_firma_profesional'] = $tieneFirmaPro; $c['requiere_firma_profesional'] = $tieneFirmaPro;
$c['requiere_firma_paciente'] = !($tieneFirmaPro && !$tieneFirmaPac); $c['requiere_firma_paciente'] = $soloPro2 ? false : !($tieneFirmaPro && !$tieneFirmaPac);
$c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true) $c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true)
? $lugarDestinoId : null; ? $lugarDestinoId : null;
$c['es_toma_progresiva'] = $esTomaProg; $c['es_toma_progresiva'] = $esTomaProg;
$c['tomas_total'] = $tomasTotal2; $c['tomas_total'] = $tomasTotal2;
$c['tomas_firmadas'] = $tomasFirm; $c['tomas_firmadas'] = $tomasFirm;
$c['cierre_anticipado'] = $dr2['_cierre_anticipado'] ?? null;
$c['campos_firma_pro'] = array_column($camposFirmaPro, 'id'); $c['campos_firma_pro'] = array_column($camposFirmaPro, 'id');
unset($c['formulario_esquema'], $c['formulario_es_toma_prog'], $c['datos_respuestas']); unset($c['formulario_esquema'], $c['formulario_es_toma_prog'], $c['formulario_solo_profesional'], $c['datos_respuestas']);
} }
unset($c); unset($c);
$respuesta['consentimientos'] = $consentimientos; $respuesta['consentimientos'] = $consentimientos;
@@ -396,13 +414,14 @@ if ($incluirSolicitud) {
"SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at, "SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at,
COALESCE(lt.nombre, tm.tipo_muestra) AS label, COALESCE(lt.nombre, tm.tipo_muestra) AS label,
1 AS es_pendiente_anterior, 1 AS es_pendiente_anterior,
ts.id AS solicitud_id_anterior,
ts.numero_orden AS solicitud_orden_anterior ts.numero_orden AS solicitud_orden_anterior
FROM turnero_muestras tm FROM turnero_muestras tm
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra
WHERE ts.paciente_id = ? WHERE ts.paciente_id = ?
AND tm.solicitud_id != ? AND tm.solicitud_id != ?
AND tm.estado = 'pendiente' AND tm.estado IN ('pendiente','rechazada')
ORDER BY tm.id ASC" ORDER BY tm.id ASC"
); );
$stmtPrev->execute([$solicitud['paciente_id'], $solicitud['id']]); $stmtPrev->execute([$solicitud['paciente_id'], $solicitud['id']]);
@@ -429,12 +448,13 @@ if ($incluirSolicitud) {
"SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at, "SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at,
COALESCE(lt.nombre, tm.tipo_muestra) AS label, COALESCE(lt.nombre, tm.tipo_muestra) AS label,
1 AS es_pendiente_anterior, 1 AS es_pendiente_anterior,
ts.id AS solicitud_id_anterior,
ts.numero_orden AS solicitud_orden_anterior ts.numero_orden AS solicitud_orden_anterior
FROM turnero_muestras tm FROM turnero_muestras tm
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra
WHERE ts.paciente_id = ? WHERE ts.paciente_id = ?
AND tm.estado = 'pendiente' AND tm.estado IN ('pendiente','rechazada')
ORDER BY tm.id ASC" ORDER BY tm.id ASC"
); );
$stmtPrev->execute([$pacId]); $stmtPrev->execute([$pacId]);
@@ -442,6 +462,33 @@ if ($incluirSolicitud) {
} catch (\Throwable $_) {} } catch (\Throwable $_) {}
} }
// Cargar exámenes de la solicitud original para cada muestra pendiente de visita anterior,
// para que al recibirla se vea el contexto completo del servicio (no solo el tipo de muestra)
$solIdsAnteriores = array_values(array_unique(array_filter(
array_column($muestras, 'solicitud_id_anterior')
)));
if ($solIdsAnteriores) {
$ph = implode(',', array_fill(0, count($solIdsAnteriores), '?'));
$stmtExAnt = $pdo->prepare(
"SELECT tei.solicitud_id, et.nombre, et.codigo
FROM turnero_examen_items tei
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
WHERE tei.solicitud_id IN ($ph)
ORDER BY et.nombre ASC"
);
$stmtExAnt->execute($solIdsAnteriores);
$examPorSolicitud = [];
foreach ($stmtExAnt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$examPorSolicitud[(int)$row['solicitud_id']][] = ['nombre' => $row['nombre'], 'codigo' => $row['codigo']];
}
foreach ($muestras as &$m) {
if (!empty($m['solicitud_id_anterior'])) {
$m['examenes_anteriores'] = $examPorSolicitud[(int)$m['solicitud_id_anterior']] ?? [];
}
}
unset($m);
}
$respuesta['solicitud'] = $solicitud; $respuesta['solicitud'] = $solicitud;
$respuesta['paciente'] = $paciente; $respuesta['paciente'] = $paciente;
$respuesta['examenes'] = $examenes; $respuesta['examenes'] = $examenes;
+148 -29
View File
@@ -7,6 +7,7 @@
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio) * - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
* - desglose por prioridad * - desglose por prioridad
* - desglose por lugar * - desglose por lugar
* - desglose por recepcionista y por bacteriólogo (quién atendió a cuántos)
* - lista de turnos del día (para tabla detalle) * - lista de turnos del día (para tabla detalle)
*/ */
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
@@ -41,10 +42,12 @@ if (!$sesion) {
'tiempo_espera_promedio_min' => null, 'tiempo_espera_promedio_min' => null,
'tiempo_servicio_promedio_min' => null, 'tiempo_servicio_promedio_min' => null,
], ],
'por_prioridad' => [], 'por_prioridad' => [],
'por_lugar' => [], 'por_lugar' => [],
'consent_stats' => [], 'por_recepcionista' => [],
'turnos' => [], 'por_bacteriologo' => [],
'consent_stats' => [],
'turnos' => [],
]); ]);
} }
@@ -54,28 +57,51 @@ $sesionId = (int)$sesion['id'];
$stmtRes = $pdo->prepare( $stmtRes = $pdo->prepare(
"SELECT "SELECT
COUNT(*) AS total, COUNT(*) AS total,
SUM(estado IN ('finalizado','en_servicio')) AS atendidos, SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera, SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
SUM(estado = 'ausente') AS ausentes, SUM(t.estado = 'ausente') AS ausentes,
SUM(estado = 'cancelado') AS cancelados, SUM(t.estado = 'cancelado') AS cancelados,
ROUND( ROUND(
AVG( AVG(
CASE CASE
WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL WHEN t.inicio_recepcion_at IS NOT NULL AND t.creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0 THEN TIMESTAMPDIFF(SECOND, t.creado_at, t.inicio_recepcion_at) / 60.0
END END
), 1 ), 1
) AS tiempo_espera_promedio_min, ) AS tiempo_espera_promedio_min,
-- En los protocolos prolongados la atención se corta en la PRIMERA toma:
-- lo que sigue son horas de espera del examen (una curva de glucosa son
-- 2 horas), no tiempo de trabajo. Contarlas triplicaba el promedio —21,9
-- min contra 7,4— y hacía ver los puestos mucho más lentos de lo que son.
ROUND( ROUND(
AVG( AVG(
CASE CASE
WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL WHEN t.inicio_lugar_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0 AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
END END
), 1 ), 1
) AS tiempo_servicio_promedio_min ) AS tiempo_servicio_promedio_min,
FROM turnero_turnos ROUND(
WHERE sesion_id = ?" AVG(
CASE
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 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]); $stmtRes->execute([$sesionId]);
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC); $resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
@@ -104,12 +130,20 @@ $stmtLug = $pdo->prepare(
SUM(t.estado IN ('en_espera_lugar')) AS en_espera, SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
ROUND(AVG( ROUND(AVG(
CASE CASE
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL WHEN t.inicio_lugar_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0 AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
END END
), 1) AS tiempo_servicio_promedio_min ), 1) AS tiempo_servicio_promedio_min
FROM turnero_turnos t FROM turnero_turnos t
JOIN turnero_lugares l ON l.id = t.lugar_destino_id JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN (
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
GROUP BY tc.turno_id
) prot ON prot.turno_id = t.id
WHERE t.sesion_id = ? WHERE t.sesion_id = ?
GROUP BY l.id GROUP BY l.id
ORDER BY l.sort_order ASC, l.nombre ASC" ORDER BY l.sort_order ASC, l.nombre ASC"
@@ -117,6 +151,63 @@ $stmtLug = $pdo->prepare(
$stmtLug->execute([$sesionId]); $stmtLug->execute([$sesionId]);
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC); $porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
// ── 4b. Desglose por recepcionista ────────────────────────────
// Mismo molde que el ranking de enfermeras de lab_domicilios
// (api/lab/get_metricas.php). atendido_recepcion_por guarda al PRIMER
// recepcionista que tomó el turno (COALESCE en cambiar_estado.php).
$stmtRec = $pdo->prepare(
"SELECT u.id,
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
COUNT(t.id) AS total,
SUM(t.estado IN ('finalizado','en_servicio','en_espera_lugar')) AS atendidos,
SUM(t.estado = 'ausente') AS ausentes,
SUM(t.estado = 'cancelado') AS cancelados,
ROUND(AVG(
CASE
WHEN t.fin_recepcion_at IS NOT NULL AND t.inicio_recepcion_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_recepcion_at, t.fin_recepcion_at) / 60.0
END
), 1) AS tiempo_promedio_min
FROM turnero_turnos t
JOIN admin_users u ON u.id = t.atendido_recepcion_por
WHERE t.sesion_id = ?
GROUP BY u.id, u.full_name, u.username
ORDER BY atendidos DESC, total DESC"
);
$stmtRec->execute([$sesionId]);
$porRecepcionista = $stmtRec->fetchAll(PDO::FETCH_ASSOC);
// ── 4c. Desglose por bacteriólogo (quien atendió en el lugar) ──
$stmtBac = $pdo->prepare(
"SELECT u.id,
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
COUNT(t.id) AS total,
SUM(t.estado = 'finalizado') AS finalizados,
SUM(t.estado = 'en_servicio') AS en_servicio,
SUM(t.estado = 'ausente') AS ausentes,
ROUND(AVG(
CASE
WHEN t.inicio_lugar_at IS NOT NULL
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
END
), 1) AS tiempo_promedio_min
FROM turnero_turnos t
JOIN admin_users u ON u.id = t.atendido_lugar_por
LEFT JOIN (
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
GROUP BY tc.turno_id
) prot ON prot.turno_id = t.id
WHERE t.sesion_id = ?
GROUP BY u.id, u.full_name, u.username
ORDER BY finalizados DESC, total DESC"
);
$stmtBac->execute([$sesionId]);
$porBacteriologo = $stmtBac->fetchAll(PDO::FETCH_ASSOC);
// ── 5. Lista de turnos del día ──────────────────────────────── // ── 5. Lista de turnos del día ────────────────────────────────
$stmtTurnos = $pdo->prepare( $stmtTurnos = $pdo->prepare(
"SELECT t.id, t.codigo, t.numero, t.estado, "SELECT t.id, t.codigo, t.numero, t.estado,
@@ -133,7 +224,8 @@ $stmtTurnos = $pdo->prepare(
ul.full_name AS atendido_lugar_nombre, ul.full_name AS atendido_lugar_nombre,
ts.observaciones AS notas, ts.observaciones AS notas,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min, ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, t.fin_lugar_at) / 60.0, 1) AS total_min
FROM turnero_turnos t FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
@@ -148,22 +240,47 @@ $stmtTurnos = $pdo->prepare(
$stmtTurnos->execute([$sesionId]); $stmtTurnos->execute([$sesionId]);
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC); $turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
// Enriquecer con exámenes y comentarios // Enriquecer con exámenes y comentarios — en lote.
foreach ($turnos as &$turno) { // Antes eran 2 consultas por turno dentro del foreach (N+1): con 80 turnos en
// el día eran 160 consultas evitables. Mismo patrón que get_historial.php.
$turnoIds = array_column($turnos, 'id');
$examenesPorTurno = [];
$comentariosPorTurno = [];
if ($turnoIds) {
$ph = implode(',', array_fill(0, count($turnoIds), '?'));
$stmtEx = $pdo->prepare( $stmtEx = $pdo->prepare(
"SELECT DISTINCT et.nombre FROM turnero_examen_items tei "SELECT DISTINCT ts.turno_id, et.nombre
JOIN exam_tipos et ON et.id = tei.exam_tipo_id FROM turnero_solicitudes ts
WHERE tei.solicitud_id = (SELECT id FROM turnero_solicitudes WHERE turno_id = ? LIMIT 1)" JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
WHERE ts.turno_id IN ($ph)
ORDER BY et.nombre ASC"
); );
$stmtEx->execute([$turno['id']]); $stmtEx->execute($turnoIds);
$turno['examenes'] = $stmtEx->fetchAll(PDO::FETCH_ASSOC); foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $row) {
$examenesPorTurno[(int)$row['turno_id']][] = ['nombre' => $row['nombre']];
}
$stmtCom = $pdo->prepare( $stmtCom = $pdo->prepare(
"SELECT usuario_nombre, comentario, tipo, creado_at "SELECT turno_id, usuario_nombre, comentario, tipo, creado_at
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at ASC" FROM turnero_comentarios
WHERE turno_id IN ($ph)
ORDER BY turno_id, creado_at ASC"
); );
$stmtCom->execute([$turno['id']]); $stmtCom->execute($turnoIds);
$turno['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC); foreach ($stmtCom->fetchAll(PDO::FETCH_ASSOC) as $row) {
$tid = (int)$row['turno_id'];
unset($row['turno_id']);
$comentariosPorTurno[$tid][] = $row;
}
}
foreach ($turnos as &$turno) {
$tid = (int)$turno['id'];
$turno['examenes'] = $examenesPorTurno[$tid] ?? [];
$turno['comentarios'] = $comentariosPorTurno[$tid] ?? [];
} }
unset($turno); unset($turno);
@@ -192,6 +309,8 @@ jsonOk([
'resumen' => $resumen, 'resumen' => $resumen,
'por_prioridad' => $porPrioridad, 'por_prioridad' => $porPrioridad,
'por_lugar' => $porLugar, 'por_lugar' => $porLugar,
'por_recepcionista' => $porRecepcionista,
'por_bacteriologo' => $porBacteriologo,
'consent_stats' => $consentStats, 'consent_stats' => $consentStats,
'turnos' => $turnos, 'turnos' => $turnos,
]); ]);
@@ -76,6 +76,8 @@ if (empty($escritoriosRec)) {
JOIN turnero_prioridades p ON p.id = t.prioridad_id JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ? WHERE t.sesion_id = ?
AND t.estado = 'en_recepcion' AND t.estado = 'en_recepcion'
-- Misma regla que en los puestos: el turno vale hasta medianoche
AND DATE(t.llamado_recepcion_at) = CURDATE()
ORDER BY t.llamado_recepcion_at DESC" ORDER BY t.llamado_recepcion_at DESC"
); );
$stmtRec->execute([$sesionId]); $stmtRec->execute([$sesionId]);
@@ -120,6 +122,10 @@ foreach ($lugares as $lugar) {
WHERE t.sesion_id = ? WHERE t.sesion_id = ?
AND t.estado = 'en_servicio' AND t.estado = 'en_servicio'
AND t.lugar_destino_id = ? AND t.lugar_destino_id = ?
-- Un turno vale hasta la medianoche de su día. Quedan bastantes sin
-- cerrar, y sin este filtro el puesto mostraba como \"llamado ahora\"
-- a un paciente de días atrás en cuanto se cerraba el turno real.
AND DATE(t.llamado_lugar_at) = CURDATE()
ORDER BY t.llamado_lugar_at DESC ORDER BY t.llamado_lugar_at DESC
LIMIT 1" LIMIT 1"
); );
@@ -0,0 +1,46 @@
<?php
/**
* GET get_especialidades.php?ids[]=20&ids[]=21
* Devuelve pacientes esperando en lugares de especialidad (ginecología, pediatría).
* "Esperando" = inicio_lugar_at IS NOT NULL AND fin_lugar_at IS NULL
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('GET');
$pdo = db();
$ids = array_filter(array_map('intval', (array)($_GET['ids'] ?? [])));
if (!$ids) jsonOk(['grupos' => []]);
$sesionId = (int)($pdo->query(
"SELECT id FROM turnero_sesiones ORDER BY id DESC LIMIT 1"
)->fetchColumn() ?: 0);
if (!$sesionId) jsonOk(['grupos' => []]);
$in = implode(',', $ids);
$lugares = $pdo->query(
"SELECT id, nombre FROM turnero_lugares WHERE id IN ($in) AND activo=1 ORDER BY sort_order"
)->fetchAll(PDO::FETCH_ASSOC);
$grupos = [];
foreach ($lugares as $l) {
$lid = (int)$l['id'];
$stmt = $pdo->prepare("
SELECT t.id, t.paciente_nombre, t.inicio_lugar_at, ts.numero_orden
FROM turnero_turnos t
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
WHERE t.sesion_id = ? AND t.lugar_destino_id = ?
AND t.estado IN ('en_espera_lugar','en_servicio') AND t.fin_lugar_at IS NULL
ORDER BY ts.numero_orden ASC
");
$stmt->execute([$sesionId, $lid]);
$grupos[] = [
'lugar_id' => $lid,
'lugar_nombre' => $l['nombre'],
'pacientes' => $stmt->fetchAll(PDO::FETCH_ASSOC),
];
}
jsonOk(['grupos' => $grupos]);
+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']),
]);

Some files were not shown because too many files have changed in this diff Show More