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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>