412 Commits
Author SHA1 Message Date
lizandrogd aa0f4ac68a fix(docker): descargar redis PECL por HTTPS (el filtro de red devuelve 403 a los .tgz por HTTP) 2026-08-17 17:13:11 +00: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
Lizandro GuarnizoandClaude Sonnet 4.6 c6d2caef55 add: SigWebTablet.js de Topaz en assets/js
Copiado de C:\Users\ZAIDA\AppData\Local\TopazSigWeb\SigWebTablet.js
en SOPORTE-3 (39 KB, versión instalada localmente).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 21:21:03 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8ac5224425 fix: cargar SigWebTablet.js desde servidor ERP en vez de localhost
El JS no es servido por SigREST sino instalado en AppData del usuario.
Se agrega /assets/js/SigWebTablet.js como primera URL; los puertos
localhost quedan como fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 21:16:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f9619db8ef fix(sigweb): intentar HTTPS :47290 antes de HTTP :47289 para páginas HTTPS
Chrome bloquea scripts HTTP desde páginas HTTPS; SigWeb >=1.6.4 expone
el SDK en https://localhost:47290 para este caso. Fallback a http://47289
para instalaciones antiguas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 20:20:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 49a622e05f fix(turnero): corregir 6 hallazgos de auditoría en tomas previas
- cancelar_toma_pendiente: añade JOIN por paciente_id para prevenir IDOR
- cancelar_toma_pendiente: requiere turno_id para validar propiedad
- get_tomas_pendientes: porta filtros condicionales y _tomas_config de
  guardar_toma.php para mostrar conteo de firmas correcto
- lugar.php: refresca sección tomas previas al completar via turneroFirmado
- lugar.php: muestra error en catch de cargarTomasPrevias en vez de silenciar
- lugar.php: limpia _tomaAlertados al cancelar toma previa
- lugar.php: pasa turno_id en POST de cancelar para ownership check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 20:58:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 814bd2ce5c feat(turnero): mostrar tomas prolongadas pendientes de visita anterior
Cuando el paciente regresa con un turno nuevo, toma de muestras muestra
las tomas en_progreso de visitas anteriores con botón Continuar o Cancelar.
Cancelar marca estado='rechazado' (campo ya existía en el enum).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 20:37:17 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8bc9cbdd35 feat(turnero): 4 campos antecedentes en un solo renglón (F-LAB-08)
Gesta/Partos/Cesáreas/Abortos ahora caben en una fila usando CSS :has(.campo-cuarto)
que activa grid de 4 columnas solo cuando el campo tiene clase campo-cuarto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 20:25:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b1264da979 create_turno: priority F skips recepcion, goes direct to toma de muestras
When a patient selects 'Muestra pendiente' (prioridad F) at the kiosk,
the turn is immediately set to en_espera_lugar pointing to the first
active muestras location. Recepcion never sees it; toma de muestras
picks it up from its queue with no consent forms required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 17:53:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 17c9e1a1f2 ver_formulario_enviado: auto-fill hora on Minuto 0 (fresh form)
_mpAutoFillHora was gated behind hayFirmaIniciada, so it never ran
on a fresh form where no signatures exist yet. Now always fills the
hora field of the first visible firma widget; scroll only when at
least one signature has already been saved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 17:36:01 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7a08e63e44 send_consentimiento: read wa template from lab_config instead of hardcoding
Was sending 'consentimiento_turno' (plain text fallback) while the
configured template is 'consentimiento_turno_v2'. Now reads
turnero_wa_template and turnero_wa_lang from lab_config, matching
the same logic used in create_turno.php (kiosk).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 17:18:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 26bb9e1844 rips: prevent reuse of already-consumed exam records
- Add turno_id column to rips_examenes_pendientes (already migrated)
- Filter turno_id IS NULL in get_examenes_rips.php query
- New marcar_rips_usado.php marks record as consumed when loaded
- cargarExamenesRips() calls marker after loading exams into the turn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 16:39:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d46c983776 recepcion: auto-check RIPS when turn has a cedula as patient name
If the kiosk captured a numeric document number as paciente_nombre,
fire consultarExamenesRips immediately on turn open without waiting
for the receptionist to manually search the patient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 16:35:29 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 44e9b86b27 recepcion: revert patient history to collapsed on load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 16:30:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 89b407f5cc recepcion: auto-expand patient history with exams on patient select
Previously the history section required a manual click to expand and load.
Now it opens and fetches automatically when a patient is selected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 16:30:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7842de0d21 kiosk: remove --disable-print-preview to unblock silent printing
--disable-print-preview conflicted with --kiosk-printing, routing to
the OS print dialog instead of printing silently. Also added AHK
fallback script for edge cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 15:30:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 065928e8c1 turnero: bat kiosko usa Microsoft Edge con --edge-kiosk-type=fullscreen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 15:22:56 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4a523204a1 turnero: simplificar bat kiosko — una sola línea sin detección de ruta
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 15:22:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 28fe662f40 turnero: bat kiosko busca Chrome en ambas rutas y fuerza pantalla completa
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 15:21:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4a92425238 turnero: agregar ?autoprint=1 al bat del kiosko
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 15:18:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5dd6ee5040 turnero: integración QZ Tray para impresión silenciosa en kiosko
- Genera par de llaves RSA (cert en config/qz/)
- api/qz_sign.php firma los print jobs con SHA512
- assets/js/qz-print.js conecta QZ Tray y envía trabajo HTML
- ver_formulario_enviado.php carga QZ cuando ?autoprint=1
- Fallback a window.print() si QZ no está instalado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 14:52:19 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2cfb77b1a6 turnero: bat kiosko mata Chrome antes de lanzar para que apliquen los flags
taskkill + timeout 2s antes de abrir; agrega --disable-print-preview.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 14:39:04 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b8763f9e15 turnero: bat para lanzar Chrome en modo kiosko con impresión automática
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 14:36:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a70f49f42b turnero: parámetro ?autoprint=1 para imprimir automáticamente al cargar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 14:33:31 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 538bc0f35a turnero: diagnóstico detallado en consola para T-LBK460 / SigWeb
Detecta si el pad físico responde (GetTabletState), si falla instanciar
SigWebTablet (posible conflicto con SigPlus), y guía paso a paso.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 12:01:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3c3f3f1ee9 turnero: logging de consola para debug de conexión SigWeb en recepción
Muestra URL, hora de intento, tiempo de respuesta y estado de SigWebTablet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 11:39:36 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c0a2389a77 turnero: mostrar Topaz desconectado en rojo cuando SigWeb no responde
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 11:34:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e2f663c6ed turnero: indicador "Topaz en línea" en header de recepción
Detecta SigWeb al cargar; muestra badge verde solo si localhost:47289
responde. Si no hay pad, no aparece nada.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 11:33:56 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 90a940c379 turnero: mostrar punto verde y "Tableta en línea" cuando SigWeb está conectado
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 11:24:47 -05:00
Lizandro Guarnizo 243003ab31 Revert "turnero: fallback a puerto 8080 si SigWeb no responde en 47289"
This reverts commit 3d035d3699.
2026-07-21 11:24:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3d035d3699 turnero: fallback a puerto 8080 si SigWeb no responde en 47289
Intenta localhost:47289 primero; si falla prueba localhost:8080.
Funciona para instalaciones con puerto no estándar sin cambiar Oracle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 11:21:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 dd1e377df7 turnero: ocultar botón Tableta si SigWeb no está disponible en el equipo
Detecta al cargar la página si localhost:47289 responde; si no,
oculta todos los .btn-topaz para evitar el error al usuario.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 10:52:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4a5e5fd9c9 turnero: reemplazar alert() de Topaz SigWeb por toast no bloqueante
El alert() bloqueaba la UI. Ahora muestra un banner flotante de 5s
que no interrumpe el flujo y sugiere usar firma manual como alternativa.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 10:49:01 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 992ce9d663 turnero: botón Ver abre el formulario en la misma modal en lugar de nueva pestaña
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:40:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2cd7525bb4 turnero: countdown en padre para que alerta sobreviva al cerrar el modal
El iframe pasa targetMs al padre via postMessage. El padre corre su propio
intervalo (5s) que dispara la notificación si el modal está cerrado.
Si el iframe alcanza el 0 primero, cancela el del padre para no duplicar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:38:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 019d2f4fb0 turnero: notificación del sistema cuando vence el countdown de toma progresiva
Usa Web Notifications API via postMessage iframe→parent para alertar
incluso cuando el tab está en segundo plano o la pantalla ocupada.
Pide permiso al iniciar el protocolo (primer gesto del usuario).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:36:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6ce2fc340b turnero: aplicar _mpEffectiveLabel en countdown y alerta de siguiente toma
La alerta y el countdown mostraban "Toma · Otro" sin el minuto.
Ahora usan _mpEffectiveLabel para mostrar ej: "Toma · Otro - 2".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:34:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e0041c8d24 turnero: ordenar firmasCampos por minuto antes de calcular siguiente_toma_at
Sin el sort, Otro (2 min) aparecía después de Minuto 15 en schema order,
haciendo que el countdown arrancara con 15 min en vez de 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:20:17 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ba2728a25f turnero: mostrar minuto de Otro en label de card de toma progresiva
Agrega _mpEffectiveLabel() que añade " - N" al label cuando la sección
es Otro y _mpOtroHora tiene valor, mostrando ej: "Toma · Otro - 2".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:16:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 815f136dca fix: secciones de toma se renderizan en orden cronológico
El render iteraba $esquema en orden original del JSON — Otro siempre
quedaba de último. Ahora se re-ordena $esquema agrupando las secciones
de toma por su minuto (usando $_mpSepOrder construido del usort ya
existente) antes de entrar al foreach de render.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:11:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2d125f9533 ux: consentimientos antes de recepción de muestras en lugar.php
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:37:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 53bc184894 ux: lugar destino al final, refresh en consentimientos, fix pasarALugar
- sel-lugar movido a última sección (antes de acciones) para que el
  usuario lo seleccione justo antes de guardar/pasar
- resetCheckboxes() ahora también limpia sel-lugar entre turnos
- pasarALugar() lee sel-lugar directamente como primera opción,
  evitando que un solicitudActiva.lugar_id desactualizado cause errores
- Botón refresh (fa-sync-alt) en cabecera de consentimientos llama
  refrescarConsentimientosLugar() para ver estado sin recargar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:33:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3ae57a78e4 feat: chip Otro se posiciona por minuto en modal de tomas prolongadas
- Cada chip envuelto en .mp-chip-grp con CSS order = minuto
- Otro arranca en order:9999 (último); al digitar el valor se mueve
  en tiempo real al lugar cronológico correcto
- Si ya tiene valor guardado (_mpOtroHora), abre posicionado
- Click handler usa .mp-chip-grp para encontrar input/unit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:13:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ecc9b74247 fix: mostrar consentimientos del turnero en lab_formularios.php
- fecha usa COALESCE(firmado_at, enviado_at, t.creado_at) porque
  turnero siempre deja enviado_at en NULL
- filtro por fecha también usa ese COALESCE para no excluir registros
- total_envios y total_completados suman turnero_consentimientos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 07:44:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1d8e461b3e fix+polish: restaurar filtro DATE en DELETE rips, fallback PHP_INT_MAX en guardar_toma, pulir chip Otro (number/min/pre-carga), limpiar interval resumen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 14:29:12 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 18c683ed9b feat(toma-progresiva): resumen visible 10s con barra y cuenta regresiva antes de cerrar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 21:43:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5a5e4b17ba fix(guardar_toma): usar _otro_hora para calcular countdown de la toma Otro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 18:41:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 65c3cc8c20 feat(toma-progresiva): alarma más fuerte 3 ciclos con sawtooth+compressor y vibración 5x900ms
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 18:36:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7f522a683e fix(toma-progresiva): ordenar toma Otro por su hora real en lugar de ponerla al final
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 18:34:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ecc582177c fix(toma-progresiva): no hacer scroll al abrir si no hay ninguna firma iniciada
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 18:28:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1a08d9dba7 fix(pagos): agregar bono al ENUM, guardar refs en lab_pagos_det, consumir cache rips al crear solicitud
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 18:18:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 057439e140 feat(recepcion): pedir N.º referencia en transferencia y tarjeta (simple y combinado)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 18:15:06 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3dcb4fcb42 fix(display_global): eliminar de llamados los turnos ya finalizados
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 18:09:29 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 76d915a391 feat: toma 0 pre-seleccionada y validación de hora en chip Otro
- Chip "0 min" queda marcado por defecto al abrir configurar tiempos
- Si se selecciona "Otro", bloquea guardar hasta que se ingrese la hora
- La hora de "Otro" se guarda en datos_respuestas._otro_hora

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 18:02:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2f6efa7498 fix: ordenar tomas por tiempo cronológico antes de construir grupos
Garantiza que la cadena de tomas siempre sea 0→15→30→... sin importar
el orden en que se guardaron en _tomas_config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 17:55:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 71440a8bbb fix: guardar en recepción pasa al lugar directamente sin mostrar consentimientos
Los consentimientos se gestionan en toma de muestras, no en recepción.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 17:41:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c1cb5906a9 fix: autocomplete CIE-10 no seleccionaba por comillas rotas en onmousedown
Reemplaza JSON.stringify en atributo HTML por data-atributos + event
delegation, evitando que las comillas dobles rompan el handler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 17:23:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 67f6e23ffd fix: clearCache en TomSelect para aplicar filtro de género correctamente
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 16:51:29 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3eaade80ff feat: restricción de exámenes por género del paciente
- DB: columna genero_permitido en exam_tipos (M/F/NULL)
  GX,FV → solo femenino | PSA,FPSA → solo masculino
- recepcion.php: TomSelect muestra badge rojo y deshabilita opciones
  incompatibles al seleccionar paciente; elimina los ya agregados
- create_solicitud.php: validación backend 422 si hay conflicto género/examen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 16:45:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b280dfcb7a feat: panel configurar tiempos con chips en F-LAB-28
- Schema F-LAB-28: reemplaza 9 secciones hardcodeadas por 17 puntos de
  tiempo (0,15,30,...,195 min, 3pm, 4pm, Otro)
- PHP: detecta sección "Otro" como toma válida (sentinel PHP_INT_MAX)
- JS/CSS: reemplaza checkboxes por chips clicables en el panel de
  configuración; chip "Otro" muestra input de hora libre

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 16:33:19 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 95acc0a09c revert: ventana de exámenes recientes a 30 minutos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 16:00:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 37364b0628 fix: reducir ventana de exámenes recientes a 5 minutos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 16:00:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e950e2c146 feat: write to rips_examenes_pendientes on solicitud creation
Al guardar una solicitud en recepción, escribe los exámenes en la bandeja
temporal WhatsApp (rips_examenes_pendientes) con DELETE+INSERT del día.
Al volver a seleccionar el mismo paciente, get_examenes_rips los detecta
como fuente=cache y los auto-carga sin confirmación.

También fix en get_examenes_rips: exámenes con codigo_legacy=NULL ahora
se buscan por codigo como fallback (COALESCE).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:56:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f70b29abf8 fix: rellamar no aparece al iniciar servicio sin F5
Si _llamar no actualiza turnoActivo a en_servicio (fallo silencioso del
cliente), recuperarTurnoActivo() vuelve a consultar el servidor y carga
el turno correctamente, mostrando el botón rellamar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:46:29 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e595f5e569 fix: add db() to ingest_paciente; get_examenes_rips and recepcion updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:43:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6bba6c6165 fix: cerrar dropdown de exámenes al seleccionar uno
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:25:40 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c717d474f7 feat: autocomplete CIE-10 en diagnóstico y fix tab en exámenes
- buscar_cie10.php: busca por código o descripción, límite 12 resultados
- inp-diag: dropdown estilo empresa, Enter selecciona primero
- selectOnTab:false en TomSelect de exámenes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:13:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4fdc8e2c13 fix: ocultar tomas no configuradas en formulario aunque esté en modoEditar
Añade flag $_mpSkipSec para secciones excluidas por _tomas_config; la
condición anterior dependía de !$modoEditar y nunca ocultaba nada
cuando el consentimiento está en_progreso.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:09:53 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 418485197b fix: add db() helper to ingest_diagnosticos.php
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 15:07:56 -05:00
Lizandro Guarnizo 2ac8f3ad43 Merge branch 'main' of ssh://git.u-s.app:22222/usite-sas-bic/whatsapp 2026-07-17 11:13:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3d0ae95d1e fix: catch Throwable instead of Exception, add prepare() null checks
PHP's PDO in silent mode returns false on prepare() when table doesn't
exist; calling ->execute() on false throws TypeError (Error, not
Exception). Changed catch to \Throwable and added explicit checks so
the real error message is returned as JSON instead of HTTP 500 empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 10:58:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 82c76393e5 fix: tomas_total respeta _tomas_config al calcular progreso del badge
El badge 1/9 ignoraba la configuración guardada e incluía todas las tomas
sin condición. Ahora filtra relevantIds por los firma-ids en _tomas_config,
igual que lo hace guardar_toma.php y el render PHP del formulario.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 10:28:15 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f5de2c90ca feat: add CIE-10 diagnosticos migration endpoint and table
- migrations/20260717_cie10_diagnosticos.sql: CREATE TABLE cie10_diagnosticos
- api/lab/ingest_diagnosticos.php: POST endpoint that receives batches of
  {cod, concepto} rows and upserts into cie10_diagnosticos (ON DUPLICATE KEY)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 10:27:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0fe4948a34 feat: seleccionar médico tratante con Enter en recepción
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 09:54:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 45732c221f feat: seleccionar empresa con Enter en el buscador de recepción
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 09:52:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 69793dd9c4 fix: propagar genero al seleccionar paciente recién creado o editado
Al crear un paciente nuevo desde el modal, el objeto no incluía genero
por lo que el bloque embarazada no se ocultaba para masculinos. Al editar
también se actualiza genero y se re-evalúa la visibilidad del bloque.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 09:46:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5bc6ddfadd F-LAB-28: permitir modificar tomas configuradas (solo las no firmadas)
- PHP: restaura auto-apply de _tomas_config para mostrar tomas correctas en recarga
- Badge: agrega botón 'Modificar' cuando el examen ya está configurado
- Panel: en modo modificar pre-marca las tomas guardadas y deshabilita
  las ya firmadas (con ícono ✓ y opacity reducida)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:47:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1715af4506 F-LAB-28: siempre mostrar panel de config, sin auto-aplicar config guardada
- PHP: elimina auto-aplicación de _tomas_config guardada; las tomas
  siempre parten limpias hasta que el profesional configure manualmente
- JS: elimina auto-guardado cuando hay 1 sola toma por grupo
- JS: restaura _mpGetGroupsForExam a mostrar todos los grupos disponibles
  (condicionados del examen + genéricos sin condición)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:42:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c0f45dd47a F-LAB-28: panel config muestra solo tomas del examen seleccionado
_mpGetGroupsForExam ahora solo incluye los grupos genéricos ('Toma')
cuando el examen no tiene secciones propias condicionadas.
Para Taukit/Heliprobe/etc. aparecen únicamente sus tomas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:39:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d5e6f9a794 F-LAB-28: simplificar panel configurar tomas
- Texto: "Marque las tomas que va a realizar."
- Título de grupo solo aparece cuando hay más de un grupo;
  con un solo grupo (caso habitual) los checkboxes salen directamente

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:34:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1622bc3050 F-LAB-28: panel configurar tomas abre con todas desmarcadas
El profesional elige manualmente cuáles tomas aplicar antes
de iniciar el protocolo, en vez de tener que desmarcar las que no usa.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:30:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3a865d5c9f recepcion: ocultar checkbox embarazada si el paciente es masculino
Al vincular un paciente con genero='M' se oculta el bloque y
se desmarca el check. Al desvincular/resetear vuelve a mostrarse.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:25:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0a09b07ea1 formulario consent embed: 3 columnas para inputs cortos + checkboxes más grandes
- Grid pasa de 2 a 3 columnas en modo compact; campo-full abarca las 3
- texto/textarea siguen en fila completa (campo-full)
- numero, edad, fecha, hora ocupan 1/3 → caben 3 por fila
- form-check-input 1.35em, min-height 2.2rem para área táctil cómoda en tablet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:16:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 50446a3914 fix: forzar HTTPS en link de consentimiento + botón abrir en navegador
- create_turno.php: reemplaza http:// por https:// en el link enviado
  por WhatsApp, evitando que Android CCT bloquee páginas HTTP
- ver_formulario_enviado.php: agrega botón "Abrir en navegador" en la
  barra de acción cuando el modo es turnero, como fallback para Android

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 21:08:19 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8b0bdc6b30 turnero/recepcion: botón para generar nuevo turno desde recepción
Modal con select de prioridad (cargado desde BD) y campo opcional
de cédula/nombre. Llama a create_turno.php que ya maneja sesión,
duplicados, SSE y notificación WhatsApp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:54:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8917daf9be turnero: orden estricto de llegada + color de prioridad en cola recepción
- get_cola.php: quita orden por orden_peso, deja solo creado_at ASC
- recepcion.php: cards con prioridad distinta a E (general) muestran
  borde izquierdo y fondo tintado con el color de la prioridad

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:48:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0f6c837d35 feat: campo número de recibo al pagar con tarjeta
- Campo inp-recibo aparece/desaparece al seleccionar 'Tarjeta' en forma de pago
- Se incluye en el payload como numero_recibo y se guarda en turnero_solicitudes
- Migración: ADD COLUMN numero_recibo VARCHAR(40) en turnero_solicitudes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:42:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7506f63627 chore: migración lab_eps y lab_ciudades en run_pendientes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:40:00 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1237552cb2 fix: selects de EPS/ciudad no pierden valor si el paciente tiene texto fuera del catálogo
Si eps o ciudad del paciente no existe en el select, se agrega como opción
temporal para no sobrescribir con vacío al guardar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:38:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4cb1a135b3 feat: CRUD de ciudades + select en modal de paciente
- Tabla lab_ciudades creada y poblada con 86 ciudades existentes
- Módulo lab_ciudades: listado, crear, editar, activar/desactivar, eliminar
- API /api/lab/ciudades.php: GET list, POST save/toggle/delete
- Modal paciente: campo ciudad cambiado a select, cargado junto con EPS
  en un solo Promise.all al iniciar; Cúcuta preseleccionada por defecto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:36:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 da2bfde38f feat: CRUD de EPS + select en modal de paciente
- Tabla lab_eps creada y poblada con valores existentes de lab_pacientes
- Módulo lab_eps: listado, crear, editar, activar/desactivar, eliminar
- API /api/lab/eps.php: GET list, POST save/toggle/delete
- Modal paciente en recepción: campo EPS cambiado de texto libre a select
  cargado desde lab_eps al iniciar la página

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:33:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fb8fb9bc7a feat: pre-llenar cédula del kiosko al crear paciente desde recepción
El kiosko guarda la cédula en paciente_nombre del turno. Al abrir el
modal de nuevo paciente, si el valor es numérico se pone en el campo
documento (no en nombre). El foco cae en nombre para que el recepcionista
complete los datos restantes. Campo editable normalmente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:30:15 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 35cba08004 feat: precios particulares automáticos al seleccionar exámenes
- Sin empresa: usa precio_base de exam_tipos (tarifa particular)
- Panel de precios se muestra con solo seleccionar exámenes (no requiere convenio)
- Total se aplica automáticamente al campo de cobro sin clic manual

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:27:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fa338e9e11 feat: overlay de turno llamado visible 5 s (antes 2.3 s)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:21:19 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bf3b128a14 feat: pantalla display — quitar turno-activo con video, left-area toma el espacio
Con video activo: layout pasa de 3 columnas (37/39/24%) a 2 (1fr/29%).
El panel central (turno-activo) se oculta via CSS; left-area (listado)
absorbe el espacio liberado y el video sube de 24% a 29%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:17:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9df5f19c08 feat: búsqueda de paciente en tiempo real + Enter selecciona el primero
- Búsqueda automática con debounce 300ms al escribir (sin necesidad de lupa)
- Enter: si ya hay resultados selecciona el primero, si no dispara la búsqueda

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 20:09:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 123ab0e034 fix: selección de empresa/convenio rota por JSON en atributo HTML
JSON.stringify(e) dentro de onmousedown="..." producía comillas dobles
que el parser HTML interpretaba como cierre del atributo, dejando la
función con un objeto incompleto. Ahora los resultados se guardan en
_empresaResultados[] y el handler pasa solo el índice numérico.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 19:44:44 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c22c38a58f feat: exámenes en recepción — lista vertical + búsqueda por CUPS
- Items seleccionados se muestran en filas verticales con chip de CUPS/código
- Búsqueda por CUPS funciona (código CUPS incluido en texto de <option>)
- Dropdown muestra chip de CUPS junto al nombre del examen
- PHP SELECT incluye columna cups de exam_tipos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 19:15:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7a4413e173 fix: banner toma no se cierra al abrir directamente por 'Siguiente toma'
El banner de alerta solo se cerraba vía 'Ver paciente'. Si el profesional
hacía clic en 'Siguiente toma' directamente, el banner seguía parpadeando
y sonando aunque el formulario ya estuviera abierto.

- abrirTomaProgresiva() ahora llama cerrarBannerToma() + _tomaAlertados.delete()
- renderConsentimientos() preserva alertado:true si la siguiente_toma_at no
  cambió, evitando beep doble al re-sincronizar después de firmar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 13:05:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d1ac82886d fix: campos condicionales no aparecen en modo editar
PHP saltaba el render de separadores y campos hijos cuando _saltarSeccion=true,
incluso en modoEditar, así que el JS evaluar() nunca encontraba los elementos.
En modoEditar ahora se renderizan con estilo oculto inicial; JS gestiona la
visibilidad cuando el usuario marca/desmarca la condición.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 12:30:06 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a2b46c464a feat: tarjetas por toma con estados y barra de progreso
- Cada bloque separador+hora+observaciones+firma se envuelve en una
  toma-card con 3 estados visuales: signed (verde colapsable), active
  (color primario pulsante) y locked (gris opaco).
- Barra de progreso con puntos y porcentaje encima de las tarjetas.
- Al firmar: tarjeta activa pasa a signed (muestra hora anotada y
  miniatura colapsable), siguiente pasa a active con scroll automático.
- Countdown de espera aparece en el header de la tarjeta bloqueada
  además del banner global.
- Fix: variable 'minutos' indefinida en pct del countdown → 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 11:51:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 86ec692028 refactor: mejorar flujo de tomas prolongadas (A+B+C+D)
A) Fix bug: grupos fuera de _tomas_config contaminaban $_mpMap y bloqueaban
   firmas configuradas. Ahora se vacían y sus secciones se agregan a
   $_mpSkippedSepIds para que PHP no las renderice.

B) Quitar campo Resultado del esquema (14 campos eliminados del form 15 en
   BD). save_tomas_config.php ya no genera campo Resultado en exámenes nuevos.
   Patrón queda: separador → hora → observaciones → firma_profesional.

C) Condicionamiento implícito: con el fix de A, solo aparecen las secciones
   del grupo configurado en _tomas_config, sin cambios en el esquema DB.

D) UX: reemplaza popup automático por badges bajo el campo _c8j2g16.
   Cada examen seleccionado muestra badge con estado (pendiente/configurado)
   y botón "Configurar tiempos" que abre el panel solo al hacer clic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 11:20:15 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3b61ee6f35 fix: auto-select examen nuevo usa checkbox no radio; añade Observaciones
- Auto-select tras crear examen: buscaba input[type=radio] pero el campo
  _c8j2g16 es checkbox → nunca encontraba el elemento. Usa _mpSetExamChecked.
- save_tomas_config.php: añade campo Observaciones (texto) entre Resultado
  y firma_profesional, igual que las secciones Toma existentes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 10:57:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 51c4973796 feat: botón × para cerrar panel de configuración de tomas
El panel mp-cfg-overlay no tenía forma de cerrarse. Añade botón × en
el encabezado que oculta el panel, desmarca el tipo de examen y resetea
la visibilidad de secciones.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 10:46:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 65b18facdb fix: checkbox _c8j2g16 usa name[] en HTML, no name exacto
El campo tipo examen es checkbox → HTML renderiza name="_c8j2g16[]".
Todos los querySelector y el listener comparaban name="_c8j2g16" exacto
→ nunca matcheaban → visibilidad de secciones y panel de config nunca
se activaban. Añade _mpGetExamName()/_mpSetExamChecked() que aceptan
ambas variantes y los usa en todos los puntos de uso.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 10:36:41 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 182dc01a77 fix: restaurar selección de examen tras reload del panel de config de tomas
Tras guardar la config de tomas (_mpGuardarConfig), location.reload() borraba
el checkbox seleccionado → _mpApplyExamVisibility(null) ocultaba toda la UI.
Ahora se guarda el examen en sessionStorage antes del reload y se restaura en
DOMContentLoaded, reactivando la visibilidad de firmas y tiempos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 10:25:12 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 34a0ae2c35 fix: 5 bugs en modal de tomas progresivas F-LAB-28
- Fix 1: countdown se recupera al reabrir el modal (lee siguiente_toma_at de BD)
- Fix 2: _mpIniciarCountdown recibe timestamp exacto en vez de minutos redondeados
- Fix 3: window._fpwPreFirma se emite una sola vez (no N veces por campo firma)
- Fix 4: postMessage('turneroFirmado') unificado a 2000ms en todos los paths
- Fix 5: resumen de tomas usa hora cacheada al guardar; elimina traversal DOM frágil

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 09:45:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 388f6ff7c9 Fix: _mpApplyExamVisibility no debe depender de _mpModoTurnero
El listener bloqueaba todo con el guard de _mpModoTurnero antes de llamar
a _mpApplyExamVisibility. Ahora la visibilidad siempre aplica al seleccionar
examen; el panel de config sigue siendo solo para modo turnero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 12:09:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8ec121e4e0 Fix: usar .campos-grid como contenedor real de campos para visibilidad de tomas
Los campos están en div.campos-grid (hijo de .doc-body), no directamente en .doc-body.
Elimina todos los console.log de debug y simplifica _mpApplyExamVisibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 12:06:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ee1cd684a8 Debug: detectar contenedor anidado cuando doc-body no tiene esquema-sep como hijo directo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 12:03:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f22553391c Debug: log docBody children count y fix listener para checkbox/radio en _c8j2g16
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 12:02:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d12b605afd Fix: iterar hijos directos de doc-body para ocultar section-title sin data-campo-id
Los div.section-title ("Firma del profesional") no tienen data-campo-id y eran
ignorados por querySelectorAll. Ahora se itera docBody.children en orden para
que inHidden aplique a todos los hijos incluyendo los que no tienen atributo.
Agrega console.log [tomas] para diagnóstico del estado de _mpAllGroups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:57:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 393c226b7c Fix: ocultar todas las secciones de tomas excepto las del examen seleccionado
Lógica anterior usaba sepToExam (vacío cuando labels son genéricos "Toma · X").
Nueva lógica: reúne TODOS los sep_ids de _mpAllGroups como allTomaSeps,
luego muestra solo los del grupo que coincide con el examen seleccionado
(vía _mpGetGroupsForExam). Si el examen no tiene grupos o no hay selección,
todas las secciones de toma quedan ocultas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:41:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 25ff224e5e Fix: ocultar todas las secciones de tomas si no hay examen seleccionado
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:34:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bf0fc39fee Fix: visibilidad de secciones de tomas por examen seleccionado
- _mpApplyExamVisibility(): oculta/muestra secciones en el DOM usando
  sep_id de _mpAllGroups, sin depender de condicion en el esquema
- Funciona aunque la BD no tenga condiciones restauradas aún
- _restore_tomas_conditions.php: migración para restaurar condiciones
  en separadores usando prefijo del label vs opciones de _c8j2g16

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:30:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 da2b67589e Tomas: panel de ciclos dinámico activado al seleccionar tipo de examen
- Panel ya no se renderiza server-side, sino que JS lo muestra al instante
  cuando el usuario selecciona un tipo de examen del selector _c8j2g16
- _mpGetGroupsForExam() filtra los grupos correspondientes al examen elegido
- Si solo hay 1 toma por grupo, auto-guarda sin mostrar panel
- Si al cargar ya hay examen seleccionado y sin _tomas_config, muestra panel
- configurar_tomas.php: guarda también _c8j2g16 (exam_type) en datos_respuestas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:19:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f0fb0c7492 Tomas: wizard inline para crear tipo de examen con tomas configurables
- Botón "Añadir tipo de examen…" abre wizard directo en el formulario
- Paso 1: nombre del examen + cantidad de tomas (1-12) con +/-
- Cada toma: label, tipo (minutos / hora fija), valor
- Al guardar llama api/lab/save_tomas_config.php y recarga la página
- Auto-selecciona el nuevo examen tras recargar (sessionStorage)
- Restaura filtrado de $_gruposConfig por examen seleccionado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:10:51 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 be55a087aa Tomas: añadir tipo de examen inline sin página separada
- Botón "Añadir tipo de examen…" debajo del selector _c8j2g16
- Al escribir y guardar, añade la opción vía API y la selecciona al instante
- api/lab/add_exam_option.php: guarda la nueva opción en el esquema del formulario
- Migración actualizada: solo elimina condiciones de separadores (no toca el selector)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:05:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4e6da5d7eb Simplificar tomas prolongadas: campo texto libre + sin condiciones
- _c8j2g16 pasa a texto libre ("Tipo de examen"), sin opciones predefinidas
- Separadores de tomas sin condición → siempre visibles
- Eliminar lab_tomas_config.php (ya no necesario)
- $_gruposConfig incluye todos los grupos directamente
- Script _migrate_tomas_simplify.php para actualizar esquema en BD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 11:01:53 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 645c9b2c46 Fix: reemplazar Layout::open/close por estructura HTML estándar en lab_tomas_config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 10:58:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b80b81fe84 Temp: script migración para registrar lab_tomas_config en sidebar (auto-elimina al ejecutar)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 10:55:25 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e58ebf57e1 Migración: registrar lab_tomas_config en system_modules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 10:49:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a39be5b31e Fix: quitar <?php duplicado dentro de bloque PHP (error de sintaxis)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 10:40:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 578873a10f Tomas prolongadas: panel configurable + link acceso directo
- Panel de protocolo ahora tiene botón "Usar todas" para cerrar sin configurar
- Si todos los grupos ya tienen una sola toma, se guarda config silenciosamente y se recarga sin mostrar panel
- Lógica de guardado extraída a _mpGuardarConfig() compartida entre ambos botones
- Botón de acceso a lab_tomas_config.php en tarjeta del formulario es_toma_progresiva

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 10:36:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2eefe3f5c8 feat: ciclos configurables + gestión de exámenes en Tomas Prolongadas
Feature 1 — Ciclos configurables por paciente:
- ver_formulario_enviado.php: pantalla de configuración aparece la primera
  vez que el profesional abre el formulario (antes de firmar); permite
  seleccionar qué tomas realizar para cada protocolo. $_mpSkippedSepIds
  oculta las secciones no seleccionadas en render. $_mpMap se reconstruye
  desde los grupos filtrados para que next_firma_id salte tomas omitidas.
- configurar_tomas.php (nuevo): guarda _tomas_config en datos_respuestas
  del consentimiento; valida que cada grupo tenga al menos una toma.
- guardar_toma.php: aplica el filtro _tomas_config al array firmasCampos
  para que el conteo de tomas firmadas/pendientes sea correcto.

Feature 2 — Gestión de tipos de examen (admin):
- lab_tomas_config.php (nuevo): página admin que muestra los exámenes
  actuales con sus tomas y permite añadir nuevos tipos con su esquema
  completo (separador + hora + resultado + firma_profesional por toma).
- api/lab/save_tomas_config.php (nuevo): modifica el esquema JSON del
  formulario id=15; add_exam agrega opción al selector y los campos de
  cada toma; delete_exam elimina la opción y los separadores exclusivos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 10:19:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3e110324a8 feat: auto-fill hora de toma con hora actual en F-LAB-28
Al activarse cada sección de toma (carga inicial, toma inmediata,
fin de countdown), se rellena automáticamente el campo hora con la
hora actual del dispositivo. Solo actúa si el campo está vacío, por
lo que no sobreescribe valores ya ingresados manualmente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 09:44:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 55bccae0ad fix: dark mode completamente reescrito con Bootstrap 5.3 data-bs-theme
- Agrega script inline en <head> de Layout.php, lugar.php y recepcion.php
  que detecta prefers-color-scheme y activa data-bs-theme="dark" en <html>
  antes del primer paint (evita flash de contenido blanco)
- sidebar.php: script de fallback para páginas con head propio no actualizado
- Layout.php: corrige background hardcodeado #f8f9fa en .main-content y
  reemplaza bg-white por bg-body-tertiary en barra de título
- styles.css: elimina bloque @media duplicado y contradictorio; nuevo bloque
  unificado cubre @media (fallback sin JS) + [data-bs-theme="dark"] para
  elementos custom (sidebar, chat); Bootstrap maneja el resto automáticamente
- Bump styles.css a v=15 en los tres templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 20:42:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fdc04d615c Mejoras F-LAB-28 tomas prolongadas: bugfixes + UX + datos
- guardar_toma.php: reconoce firma campo_id_svg (compat. registros históricos)
- get_consentimientos.php: mismo fix para conteo tomas_firmadas
- ver_formulario_enviado.php: $_mpMap incluye hora fija (Cortisol 3pm/4pm),
  agrupa por condición de separador para no mezclar Glicemia/Cortisol,
  muestra resumen de tomas al completar antes de cerrar el modal
- lugar.php: banner rojo + beep cuando el countdown de una toma llega a cero,
  resetea alerta al cerrar el modal; función _playBeepLugar() compartida

DB Form 15: +10 campos (firma_profesional Minuto 60 Glicemia + campo
Resultado numérico en cada toma Glicemia y Cortisol)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 20:26:12 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 848dd8e4d0 Toma progresiva: auto-scroll, cierre automático, countdown en cola y fix filtro por examen
- guardar_toma.php: filtrar firmas por condición del examen seleccionado; corrige
  countdown espurio y detección de 'completado' cuando hay múltiples tipos de examen.
  También descarta siguiente_toma_at si ya es pasado (minuto 0).
- ver_formulario_enviado.php: auto-scroll a primera firma pendiente al cargar el modal;
  si el examen se selecciona y la firma no está en DOM, guarda y recarga el iframe.
  Al completar tomas, cierra el modal directamente sin botón "Finalizar".
- get_cola.php: incluye siguiente_toma_at y en_toma_progresiva por turno desde
  turnero_consentimientos para mostrar cuenta regresiva en tarjetas.
- lugar.php: muestra chip de cuenta regresiva en tarjetas de cola para pacientes
  en toma progresiva; color cambia a warn (<2 min) y crit (¡Ahora!).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 20:05:56 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c2605fb274 turnero: badge tomas por examen seleccionado + fix firma paciente
- get_consentimientos: tomas_total ahora filtra por examen elegido en
  datos_respuestas (no cuenta todos los 13 campos globales); badge
  se oculta (total=0) hasta que se seleccione un examen.
- ver_formulario_enviado: agrega f.es_toma_progresiva al SELECT y lo
  usa para forzar _soloFirmaPro=true, evitando que aparezca el canvas
  de firma del paciente cuando la DB indica toma progresiva.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 19:41:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1296918c20 fix: tomas prolongadas — firma no guardaba y countdown no aparecía
ver_formulario_enviado.php:
- $_mpPrimeraPendiente, $_mpBloqueado y $fSvg buscaban la clave campoId_svg
  pero guardar_toma.php guarda con clave campoId (sin sufijo). Se añade
  fallback a ambas claves para compatibilidad hacia adelante y atrás.

lugar.php:
- new Date('2026-07-09 15:30:00') no parsea en Safari; se reemplaza el
  espacio por 'T' para formato ISO antes de construir el timestamp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 16:12:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 590795c199 fix: permitir firma en consentimientos con firma paciente + profesional (H.pylori)
Formularios con ambos tipos de firma (paciente + profesional) en modo embebido
quedaban bloqueados porque $_mostrarCanvasPro exigía $_soloFirmaPro.

Flujo corregido:
- Canvas profesional ahora se muestra en cualquier form con firma_profesional
  cuando el operador está logueado en modo turnero embebido.
- El botón "Confirmar" del paciente detecta si hay canvas del profesional:
  si hay, guarda como borrador (sin marcar firmado) y hace scroll al canvas pro.
- El profesional firma último con solo_profesional=true, marcando firmado y
  cerrando el modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 16:08:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f4807e41ae turnero display_global: izquierda 40→37%, centro 36→39%
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 16:07:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bec08cf600 turnero display_global: ajuste grid y tipografía segunda pasada
Grid 40/36/24%, fuentes izq -15%, fuentes centro -10%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 16:02:30 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4135e74d84 fix: contador y countdown de tomas prolongadas en lugar.php
- _guardarFirmaMP ahora llama guardar_toma.php en vez del draft POST,
  guardando la firma con clave campoId (sin _svg) que es la que lee
  get_consentimientos para el contador X/13.
  guardar_toma.php también actualiza estado→en_progreso y siguiente_toma_at,
  lo que activa el countdown en lugar.php via polling SSE.
- Al completar la última toma envía postMessage turneroFirmado para cerrar
  el modal automáticamente.
- Botón cerrar del modal siempre habilitado (el operador puede cerrar
  en cualquier momento durante el proceso).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 15:58:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d2c1b58c2e turnero display_global: ajuste layout y tipografía
Centro 46→40%, izquierda 25→31%; fuentes centro -30%, lugar izq -10%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 15:53:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1c3bd9868f feat: auto-fill médico solicitante en formularios presenciales
Agrega medico_nombre al __paciente prefilled de crear_envio_presencial.php
haciendo JOIN con medicos vía turnero_solicitudes.medico_id, de modo que
el campo linked 'Médico solicitante' del formulario id=15 (tomas prolongadas)
se auto-cargue con el médico registrado en recepción.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 15:45:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 78471d5441 feat: skip forms/consents for solo-entrega-muestras and show pending samples
- get_consentimientos: skip auto-create of consentimientos when solo_muestras=1
- get_consentimientos: add paciente_id to turno query; fallback muestras fetch
  when no solicitud exists but turno has paciente_id (F-turns without reception)
- lugar: badge "Solo entrega de muestras" in ficha header for prioridad F
- lugar: hide sec-consent and sec-form-embebido when solo_muestras
- lugar: skip consent polling and form embedding for F-turns
- lugar: restore sec-consent visibility on resetFicha

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 15:30:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ac492b4e2c fix: apply conditional field visibility in editable form (ver_formulario_enviado)
Add data-campo-id attributes to all editable field wrappers and separators,
then run iniciarCondiciones-equivalent JS on page load so conditional sections
(e.g. Tipo de dolor when Dolores is checked) appear/disappear without needing
to close and reopen the modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 14:55:17 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b29061c010 feat: auto-populate patient data in DATOS TOMA DE MUESTRAS form and add conditional dolor field
- Add fecha_nacimiento to __paciente in modoTurnero so linked fields render it
- Format fecha_nacimiento as dd/mm/YYYY in static completed view
- Evaluate separator conditions in static view to skip hidden sections
- DB: add linked fields (numero_documento, tipo_documento, fecha_nacimiento, eps, telefono) to form id=16
- DB: add conditional "Tipo de dolor" field that appears only when Dolores is checked

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 14:46:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9a3cd24454 feat: block duplicate active turns for the same patient
Before creating a new turn, check if the patient (identified by
paciente_id) already has a turn in today's session that is not
finalizado/ausente/cancelado. Returns 409 with the existing turn
code so the receptionist or kiosko can show a clear message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 14:10:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d0a42dba33 feat: auto-load RIPS exams when patient is selected in recepcion
When a patient with a cedula is selected, the system queries RIPS
Manager for exams registered in the last 5 minutes. A green banner
shows the count with a "Cargar" button that pre-fills TomSelect.
Unmapped exam codes shown as a warning. Banner clears on deselect/reset.

- config.php: RIPS_MANAGER_URL constant from env var
- api/lab/get_examenes_rips.php: proxy + codigo_legacy mapper
- recepcion.php: banner HTML + consultarExamenesRips/cargarExamenesRips JS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 14:05:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bffa38e9c0 fix: hide médico tratante when solo entrega de muestras is checked
Toggling the checkbox hides the doctor field and clears any selection.
Unchecking or resetting the form restores the field to visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 13:41:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fd991103b9 fix: remove Ver Pantalla button from recepcion header
display_recepcion is not in use — only display_global is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 13:33:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b9312fe0d9 feat: add bono to pago combinado in recepcion
New Bono field in the combined payment panel — included in the
running total sum, reset, and pagos_detalle payload on submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 13:31:14 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 417b53528b fix: detect device by token (cookie) before IP fallback in sidebar menu
For recepcionista and bacteriologo roles, module.php now checks the
turnero_token cookie first — same order as recepcion.php — so token-
registered devices show only their assigned desk/lugar in the menu.
IP fallback now requires token IS NULL to avoid ambiguous matches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 13:27:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 dfff945066 feat: clear TomSelect input after exam selection
After pressing Enter to add an exam, the typed text is cleared
and the dropdown closes so the next code can be typed immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 13:22:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 813b81e0f6 feat: Enter auto-selects first exam match in TomSelect
onType highlights the first visible option on each keystroke so pressing
Enter immediately selects it — no need to arrow-down manually.
Also adds selectOnTab for the same effect with Tab key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 13:06:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3bf70f48d0 fix: convert detail panel from col-lg-5 inline to fixed drawer overlay
Table now uses full col-12 width — no more horizontal scroll.
Detail panel slides in from right as a fixed overlay with backdrop.
Also renames stats card from "Importados RIPS" to "Importados Lab".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 12:06:01 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 912798fbcc feat(pacientes): origen 'lab' en vez de 'rips' + tabla ampliada + proteger origen en update
- Valor de BD cambia de 'rips' a 'lab' (más genérico)
- ingest_paciente: en UPDATE nunca pisa origen (conserva manual/whatsapp/lab)
- Badge muestra 'Resultados Lab' con ícono matraz; retrocompat con valor 'rips'
- Tabla agrega: teléfono+WA, email, EPS, género en nombre
- filtro dropdown usa 'lab' en lugar de 'rips'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:34:03 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 68cf64edf7 chore: migration web runner columna origen lab_pacientes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:26:17 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fa81573927 chore: script de migración columna origen en lab_pacientes
Ejecutar en el servidor: php run_20260709_origen.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:25:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 828e323eba feat(lab_pacientes): columna origen + vista mejorada con stats y filtros
- Migration: columna 'origen' (manual|rips|whatsapp) en lab_pacientes
- ingest_paciente.php: guarda origen='rips' en cada paciente importado
- Paciente::listar(): filtro por origen + orden por created_at DESC
- Paciente::stats(): conteo total/importados/con_wa/manuales
- get_pacientes.php: expone ?origen= y ?stats=1
- lab_pacientes.php: cards de stats clicables, badge origen por fila,
  columna ciudad, columna fecha registro, dropdown filtro por origen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:16:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 32c166360c add: runner para migración dispositivos token
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:06:11 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 046803bd15 feat(ingest_paciente): modo insertar/upsert — migración inicial no pisa existentes
Nuevo campo 'modo' en el body:
- "insertar": si la cédula ya existe devuelve skipped sin tocar la BD
- "upsert" (default): crea o actualiza como antes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:05:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3204671edc fix(ingest_paciente): agregar CORS headers para preflight OPTIONS
Incluye Access-Control-Allow-Headers con X-Lab-Key para que el
navegador pueda hacer el preflight desde RIPS Manager.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:56:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d5b6de1f46 turnero: restricción por token de navegador en lugar de IP pública
- Token UUID generado en localStorage del navegador, persistido como cookie
- turnero_dispositivos.token: nueva columna para identificar equipos
- recepcion.php y lugar.php: chequean token (cookie) primero, IP como fallback
- configuracion.php: banner con token del equipo actual + botón "Registrar este equipo"
- save_dispositivo.php: acepta campo token al crear/editar dispositivo
- Migración: 20260709_dispositivos_token.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:56:53 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9651f14302 feat(lab): endpoint server-to-server ingest_paciente para sync desde RIPS
Agrega /api/lab/ingest_paciente.php con autenticación por X-Lab-Key
(LAB_SYNC_KEY en config). Hace UPSERT por numero_documento sin requerir
sesión de admin — permite que RIPS Manager sincronice pacientes directo
a lab_pacientes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:48:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1f45f58205 sidebar: mostrar IP del servidor (REMOTE_ADDR) y LAN separadas para diagnóstico
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:47:59 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 abe4ed4df1 sidebar: mejorar detección IP local con STUN y modal de ayuda si no detecta
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:45:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8b3188b21b sidebar: detectar IP LAN local via WebRTC y mostrarla en el pie del menú
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:35:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 60d186253e sidebar: mostrar IP local del cliente al pie del menú
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:32:23 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f5b38a331e turnero/configuracion: mostrar IP actual del cliente en sección dispositivos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:27:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9afadb9a6f turnero: chequeo IP en vivo en cada carga de página
En lugar de confiar solo en la sesión (seteada al login), recepcion.php
y lugar.php ahora consultan turnero_dispositivos por IP en cada request.
Esto cubre sesiones preexistentes y evita que aparezca el selector cuando
la IP ya tiene un lugar asignado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:25:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 003d16b6a2 turnero: auto-redirigir a desk/lugar asignado sin mostrar selector
- authenticateUser incluye turnero_lugar_id en la sesión
- login.php redirige con desk_id/lugar_id si el usuario tiene lugar fijo (sin necesitar IP)
- recepcion.php redirige con desk_id si la sesión tiene turnero_lugar_id, evitando el modal de selección

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:17:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f4da864db7 feat(turnero): toma prolongada secuencial + bloqueo muestras/cierre
- ver_formulario_enviado: auto-fill médico (nombres/especialidad/código) en datos_prefilled desde solicitud
- ver_formulario_enviado: canvases de toma progresiva bloqueados secuencialmente — solo el turno activo es firmable, los siguientes aparecen al expirar el countdown
- ver_formulario_enviado: countdown prominente con reloj grande, color dinámico, sonido (Web Audio) y vibración al llegar la hora
- lugar: bloquear cierre del modal durante toma progresiva activa (botón X deshabilitado hasta mp_completar)
- lugar: bloquear Finalizar atención si hay muestras pendientes sin decisión (recibida/rechazada)
- lugar: _syncBtnFinalizar combina consentimientos + muestras para el aviso y estado del botón
- lugar: restricción de usuario a lugar específico via turnero_lugar_id (admin_users) + bloqueo JS en confirmarLugar()
- migrations: turnero_lugar_id en admin_users para restringir bacteriólogos por usuario

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 21:16:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0e116359f8 feat(recepcion): agregar método de pago Bono
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 18:46:04 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8a44cb8bae feat(turnero): mostrar muestras pendientes de visitas anteriores en entrega
Cuando la solicitud tiene solo_muestras=1, get_consentimientos.php
busca turnero_muestras pendientes de otras solicitudes del mismo
paciente y las antepone en el array muestras con es_pendiente_anterior=1.

lugar.php renderiza un tag 'visita anterior' en cada muestra previa
para que el bacteriólogo identifique el origen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 18:42:19 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6f1e2adafe feat(turnero): mostrar nota de recepción en toma de muestras
El campo observaciones de la solicitud ya venía en el JSON de
get_consentimientos.php pero nunca se renderizaba en lugar.php.
Se agrega un bloque amber visible solo cuando sol.observaciones
tiene contenido, y se limpia al hacer resetFicha.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 18:13:58 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 71e97f2d95 fix(turnero): asignar recepcion_desk_id al llamar turno específico
llamarTurnoEspecifico usaba cambiar_estado.php sin pasar desk_id, por
lo que recepcion_desk_id quedaba NULL. El display TV filtra por
recepcion_desk_id = ?, así el turno llamado no aparecía en pantalla.

También se corrige llamado_recepcion_at: era COALESCE (no actualizaba
si ya tenía valor), ahora es NOW() para que el display reaccione al
re-llamar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 16:07:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bfab8418b6 fix(turnero): restaurar consentimientos en recepción
El else branch de 3062cd7 marcaba todos los formularios de lugar con
origen_lugar_id=-1 cuando lugar_destino_id es NULL, ocultándolos en
recepción. Como create_solicitud ya no crea consentimientos de lugar,
el else es innecesario — en recepción fidsDeLugar queda vacío y los
consentimientos de examen pasan el filtro !c.origen_lugar_id.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 15:20:24 -05:00
Lizandro Guarnizo bb02f3d6f1 fix: migration uses id=10, not codigo (column doesn't exist) 2026-07-08 10:26:46 -05:00
Lizandro Guarnizo 08ec1eda14 fix: allow explicit es_toma_progresiva override per form; F-LAB-28 forced off 2026-07-08 10:23:19 -05:00
Lizandro Guarnizo 2d3eebea4b temp: debug form schema without underscore prefix 2026-07-08 10:20:40 -05:00
Lizandro Guarnizo 392b8629f7 temp: debug scripts to inspect F-LAB-28 schema 2026-07-08 10:12:16 -05:00
Lizandro Guarnizo 726cbdd21a temp: debug script to inspect lab_formularios schema for F-LAB-28 2026-07-08 10:07:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5109db4a4b feat: borrador de comentario en toma de muestras (localStorage)
Al escribir en el textarea de comentarios se guarda automáticamente
en localStorage con clave toma_draft_<turno_id>. Al abrir la ficha
del mismo turno el texto se restaura con borde amarillo de aviso.
Al guardar exitosamente o al hacer resetFicha se borra el draft.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 10:02:06 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3062cd7e95 fix: no mostrar consentimientos de toma de muestras al guardar en recepción
create_solicitud.php: cambia de crear consents por lugar (toma de muestras)
a crear consents por examen (exam_tipo_consentimientos). Los de lugar se
crean cuando el paciente llega a la estación vía get_consentimientos.php.

get_consentimientos.php: cuando lugar_destino_id es NULL (turno en recepción),
busca TODOS los formularios de turnero_lugar_consentimientos y los marca con
origen_lugar_id = -1 (truthy), para que el filtro !c.origen_lugar_id de
recepcion.php los excluya correctamente aunque no haya destino asignado aún.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 09:23:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7b4827f70f fix: filtrar por sesión del día al llamar siguiente turno
siguienteTurnoEnCola ahora acepta sesion_id opcional. llamar_turno.php
pasa la sesión de hoy para que nunca agarre turnos viejos de días
anteriores que quedaron en estado 'espera'.

Sin el filtro, el primer llamado del día podía agarrar un turno de ayer,
la ficha abría en recepción pero la pantalla TV (get_display_recepcion.php)
filtra por sesion_id=hoy y no lo encontraba → el número no salía en display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 09:14:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8aa2f35585 fix(turnero/chat): recepcionista siempre vuelve a recepción, no a dashboard
IP registrada → su desk; sin IP registrada → primer desk activo de recepción.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 08:56:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1f2198225c fix(turnero): IP real con X-Forwarded-For; chat back solo para recepcionista
- App.php: clientIp() público resuelve X-Forwarded-For/X-Real-IP
- module.php: usa el mismo IP resolution en sidebar de recepcionista
- chat.php: botón Menú regresa al desk solo si rol=recepcionista con IP
  registrada; bacteriólogo no aplica lógica de IP en este flujo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 08:53:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6f56734b53 fix(turnero/chat): botón Menú regresa al desk asignado por IP
Si la IP está registrada en turnero_dispositivos para una recepción,
el botón volver apunta a ese desk en lugar de al dashboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 08:48:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 985bb7eace fix(formulario): checkboxes en fila horizontal con flex-wrap
Los checkboxes del campo tipo checkbox quedaban apilados verticalmente.
Se envuelven en d-flex flex-wrap gap-2 para quedar lado a lado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 08:47:14 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ea1f0e30c9 feat(turnero): tomas de muestras prolongadas con temporizador y progreso
- DB: ALTER TABLE turnero_consentimientos ADD siguiente_toma_at, toma_inicio_at;
  MODIFY estado para incluir 'en_progreso'
- guardar_toma.php (nuevo): guarda firma parcial por campo_id, parsea el
  esquema para calcular siguiente_toma_at desde 'Minuto X' o hora fija,
  marca 'en_progreso' o 'firmado' según tomas restantes
- get_consentimientos.php: expone siguiente_toma_at, es_toma_progresiva,
  tomas_total, tomas_firmadas, campos_firma_pro en cada consentimiento
- lugar.php: estado en_progreso con badge X/Y, countdown en tiempo real,
  animación de alerta cuando el tiempo llega, botón 'Siguiente toma' que
  abre el modal embebido en modo toma_progresiva

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 08:42:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 cf8d011f09 feat(lab_examenes): múltiples consentimientos por examen
Mismo patrón que turnero/configuracion: form_ids[] en lugar de
formulario_id único, checkboxes en la vista, INSERT múltiple en save.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 08:19:06 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b2bc0f950a feat(turnero): múltiples consentimientos por tipo de examen
- save_ex_tipo.php: GET devuelve form_ids[], POST acepta form_ids[] y
  hace DELETE+INSERT por cada uno (la tabla ya soportaba muchos a muchos)
- configuracion.php: reemplaza select único por checkboxes en formulario
  Agregar y modal Editar; JS lee los checks y envía form_ids[]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 08:10:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b6179574c3 feat(turnero): recepcionista con restricción de desk por IP
- module.php: si la IP está registrada en turnero_dispositivos para un
  lugar de tipo recepción, el sidebar muestra solo ese desk; resto de
  vistas (chat, verificar, TV) siempre visibles
- App.php: guard que redirige al recepcionista a su desk si intenta
  acceder manualmente a otro desk_id; IP libre no tiene restricción

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 08:03:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b2e7130762 feat(turnero): bacteriólogo sin dashboard/historial, redirige por IP
- Auth: agrega isBacteriologo()
- App: guard que bloquea dashboard e historial para bacteriólogo y
  redirige según IP (turnero_dispositivos); IP registrada → ese lugar,
  IP libre → primer lugar de muestras
- module.php: sidebar bacteriólogo sin Dashboard/Historial; IP
  registrada muestra solo ese lugar, IP libre muestra TV + todos los
  lugares muestras

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 07:54:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a776c5733c fix(turnero): origen_lugar_id calculado al vuelo, sin depender de columna DB
Elimina dependencia de tc.origen_lugar_id en SELECT; calcula el flag
dinámicamente consultando turnero_lugar_consentimientos contra el lugar
destino del turno. Funciona para registros históricos y sin migración.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:19:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 233144c017 feat(usuarios): gestión de firma por profesional desde perfil de usuario
Botón firma en tabla de usuarios (verde si ya tiene, gris si no).
Modal con canvas para dibujar o subir imagen, vista previa de firma
actual y opción de eliminar. APIs get/save_firma_usuario.php para admins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:16:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 bad3d42a92 feat(turnero): gestión de dispositivos/tablets en configuración
Sección nueva en tab Lugares con tabla CRUD de turnero_dispositivos:
IP, nombre, lugar asignado, estado activo/inactivo.
API save_dispositivo.php para crear/editar/eliminar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:08:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fb0e77523c feat: módulo lab_examenes — CRUD catálogo de exámenes, items y tarifas
- Nuevo módulo lab_examenes con lista paginada, buscador y filtro por categoría
- Vista de detalle/edición con campos completos de exam_tipos (CUPS, protocolo,
  tipo muestra, nivel, abreviatura, seremite, ayuno, instrucciones)
- Sub-tabla inline de items de resultado (lab_items_resultado) con edición por fila
- Sub-tabla de precios por tarifa (lab_tarifas) con modal de edición
- APIs: list, get, save, save_item, save_tarifa, get_tarifas
- Script ETL Firebird→MySQL (scripts/etl_examenes.php) para la migración inicial

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:06:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 229977785b fix(turnero): no mostrar consentimientos de toma de muestras en recepcion
Agrega origen_lugar_id a turnero_consentimientos para distinguir consents
creados por examen (null) vs por estación (= lugar_id destino).
Recepcion filtra los de origen_lugar_id != null; lugar.php muestra
solo los suyos + los de examen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 23:05:51 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 333e856f38 feat(turnero): buscador y paginación en tab de examenes
Filtrado en tiempo real + paginación client-side (15 por página).
Categorías vacías se ocultan automáticamente al buscar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:53:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 18d50a3a16 fix: evitar datos del paciente duplicados en formularios con campos linked
Cuando el esquema tiene campos tipo linked que ya muestran nombre, documento,
etc., el bloque top Datos del paciente queda oculto para no repetir la info.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:51:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d24b234e30 fix(display): corregir turnos que no aparecen en pantalla TV
- sse_turno.php: sleep(2)→sleep(1) para detectar notificarSSE en máx 1s
- display*.php: visibilitychange + focus → fetch inmediato al despertar pantalla
- display*.php: Wake Lock API para evitar throttling del browser en TVs
- display.php: SSE open → cargarSnapshot() inmediato al reconectar

Causa raíz: browsers throttlean setInterval a >1min en tabs sin foco;
las TVs inactivas dejaban de recibir actualizaciones aunque el servidor
las enviara correctamente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:47:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8760b685ad feat(turnero): dispositivos por IP, firmas, formularios y muestras prolongadas
- login.php: detectar dispositivo por IP al autenticar y redirigir al lugar asignado
- dashboard.php / lugar.php / recepcion.php: bloquear acceso por dispositivo (IP→lugar)
- lugar.php: modal firma profesional pre-guardada; compact 2-col en iframe; botón WA
  solo cuando el formulario requiere firma del paciente
- ver_formulario_enviado.php: modo compact tablet, auto-save borrador (merge), firma
  profesional en 1 clic, Muestras Prolongadas (countdown + Finalizar), auto_profesional
  pre-fill desde sesión, fecha→fecha_hoy en campos de toma/recepción
- get_consentimientos.php: añadir requiere_firma_paciente para controlar botón WA
- save_firma_profesional.php: API para guardar firma pre-configurada del profesional
- migrations: turnero_dispositivos, admin_users.firma_svg, clon VIH F-LAB-05 para turnero

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:58:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4de9c2f9b4 turnero: simplificar URLs de pantalla TV y añadir URLs de gestión por lugar
- Quita pantallas individuales por estación de muestras y kiosko del bloque anterior
- Deja solo Pantalla Global y Kiosko como accesos de visualización
- Añade bloque de URLs de gestión por escritorio de Recepción (recepcion&desk_id=X)
- Añade bloque de URLs de gestión por estación de Muestras (lugar&lugar_id=X)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 10:34:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 27c1ac7c5d chore: quitar display_errors de depuración
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 09:02:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a9f2528a2d fix: agregar columna numero_orden a lab_domicilios y turnero_solicitudes
La columna se usaba en ver_formulario_enviado.php pero no existía
en la BD, causando error 500.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 08:54:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 30a146ad5c debug: habilitar display_errors temporalmente para diagnosticar 500
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 08:52:17 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f695b04f5b feat(caja): generar lab_recepciones al confirmar solicitud en turnero
- create_solicitud.php crea lab_recepciones (es_historico=0) dentro de
  la transacción: num_factura auto-secuencial para registros nuevos,
  campos empresa/subgrupo/autorización/diagnóstico tomados de la solicitud
- Inserta lab_relaciones por cada examen con precio del snapshot JSON
- Inserta lab_pagos + lab_pagos_det si hay metodo_pago registrado
- Bloque try/catch: si migración 11 aún no corrió solo se loguea,
  no se revierte la solicitud principal
- recepcion.php muestra toast "Recepción #N generada" al guardar
- Migración 11: ALTER lab_recepciones.id → AUTO_INCREMENT,
  ADD solicitud_id, ALTER lab_pagos.numcaja_legacy → nullable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 22:12:48 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a7aac706ee feat(recepcion): motor de precios + selector empresa/convenio
- Nueva sección empresa/convenio en recepcion.php: búsqueda live por
  nombre/NIT, selector de subgrupo, campo autorización (cuando req_autoriza),
  diagnóstico CIE-10
- Tabla de precios calculados en tiempo real: resolución por tarifa de la
  empresa o subgrupo, descuento empresa aplicado, botón "Aplicar al cobro"
- Al agregar/quitar exámenes o cambiar empresa/subgrupo se recalcula automáticamente
- Nuevo API get_precios_examenes.php: resuelve precios desde lab_tarifas
  con soporte de tarifas derivadas por porcentaje
- create_solicitud.php guarda nit_empresa, subgrupo_id, autorizacion,
  diag_ppal, items_precio (snapshot JSON al momento de la recepción)
- Migración 10: ALTER turnero_solicitudes añade los 5 campos nuevos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 22:09:26 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b9bb4fc628 feat(lab_empresas): módulo CRUD de empresas, convenios y tarifas
- Nuevo módulo lab_empresas: lista paginada, modal create/edit empresa,
  gestión de subgrupos inline y CRUD de catálogo lab_tarifas_id
- APIs: empresas.php (list/get/save/toggle/delete),
  empresa_subgrupos.php, tarifas_id.php
- Migración 09 registra el módulo en system_modules y asigna permiso admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 22:05:14 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5fedd233f4 feat(lis): migración completa Firebird→MySQL + tracking de muestras en turnero
- Migraciones LIS 01-08: schema completo del nuevo LIS (secciones, protocolos,
  ítems de resultado, perfiles, tarifas, empresas, histórico transaccional)
- ETL Firebird→MySQL: script CLI con conversión WIN1252→UTF-8, batches de 500,
  resolución de FKs y deduplicación de pacientes
- turnero_muestras: tracking pendiente/recibida/rechazada por tipo de tubo
- lugar.php: widget de recepción de muestras (solo tipo=muestras)
- update_muestra_estado.php: API para marcar estado de muestra
- create_solicitud.php: auto-crea muestras al guardar solicitud
- get_consentimientos.php: incluye muestras[] en el response
- 6 vistas SQL: v_muestras_hoy, v_recepcion_completa, v_examen_precio, etc.
- numero_orden en encabezado del formulario firmado (D-/F- color diferenciado)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 21:44:22 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 86ed70db34 fix(recepcion): corregir lectura de data-consent en Tom Select
Tom Select expone los data-* de los <option> como data.consent (sin prefijo),
no como data.element.dataset.consent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 16:00:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 56b8fc98df feat(recepcion): Tom Select para selección de exámenes con badge de consentimiento
Reemplaza la lista de checkboxes por Tom Select (sin jQuery):
- Búsqueda integrada mientras se escribe
- Exámenes agrupados por categoría (optgroup)
- Cada examen con consentimiento asociado muestra badge ⚠ Consentimiento
  en el dropdown y marcador visual en el tag seleccionado
- Solo entrega de muestras deshabilita el select automáticamente
- Remove_button plugin para quitar ítems individualmente
- Elimina filtrarExamenes / toggleCategoria / actualizarCatChk (ya innecesarios)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 15:54:36 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c566c1f33f feat(recepcion): destacar turnos en atención en la cola
Los turnos ya tomados (en_recepcion) muestran fondo azul pálido,
borde izquierdo azul grueso y badge "EN ATENCIÓN" en lugar del
chip de tiempo, para que el recepcionista identifique de un vistazo
qué turno ya está siendo atendido y no llame otro.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 15:19:54 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1d285da6ae feat(recepcion): mostrar tiempo de espera en tarjetas de cola
Cada turno en la lista muestra cuánto lleva el paciente esperando,
igual que en toma de muestras — verde < 10 min, amarillo < 25 min,
rojo ≥ 25 min.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 15:15:34 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 53b483d51a feat(turnero): UX audit improvements — lugar & recepcion
lugar.php:
- Bell button enlarged to 44×44px touch target
- Fecha nac. / Celular fields hidden from bacteriologist ficha
- Cancel button added to station-selector overlay; shows only when changing
- ESC key closes overlay when a station is already selected
- visibilitychange pauses polling when tab hidden, resumes on return

recepcion.php:
- Consent action buttons: padding 2px 8px → 5px 12px, font-size .72→.82rem
- Exam list: per-category select-all checkbox with indeterminate state support
- guardarSolicitud: auto-calls pasarALugar() when no consents required
- abrirModalPaciente: nome field auto-focused for both new and edit cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 13:46:32 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 69a6859b51 style(turnero): alinear ficha-acciones de recepcion con lugar
- Eliminar display:flex de ficha-acciones (rompía el apilado de filas
  accion-primary-row / accion-secondary-row, quedaban side-by-side)
- Padding: 1rem 0 .25rem → .8rem 1.4rem .9rem (igual que lugar)
- Background: #fff → rgba(255,255,255,.97) + backdrop-filter:blur(6px)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 13:33:44 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 fb1cd2161c style(turnero): alinear botones de recepcion con lugar
- btn-accion-primary: padding .8rem 1rem→.8rem, font-size .97→1rem, opacity .55→.5
- touch target: eliminar override de padding en cola-card que contradecía el base

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 13:28:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 54fc986d82 style(turnero): homologar UI/UX entre lugar y recepcion
lugar.php:
- Cola: 340px→370px, border 1.5px→1px, border-radius 11px, padding .65rem .9rem,
  min-height 58px→62px (touch target)
- Cola header: font-size .72→.78rem, letter-spacing .06→.08em
- btn-llamar-next: padding .6→.65rem
- ficha-sec: padding 1rem 1.1rem→1.2rem, header .72→.78rem, .07→.08em
- consent-row: padding .55→.5rem, min-height 46px añadido
- Transición mobile: .22s→.2s
- Modal paciente: header plano→gradiente azul (#1043a0→#1565c0)

recepcion.php:
- cola-card: border-radius 10→11px
- prio-dot: border-radius 50%→9px (cuadrado redondeado, consistente con lugar)
- btn-accion-sec: font-size .8→.82rem

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 12:57:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 400547bd7a style(turnero): ajustar tamaños de fuente en display_global
- Código turno: clamp(4→3rem, 14→13vw, 10→9rem)
- Nombre lugar: clamp(3→1rem, 10→8vw, 8→6rem)
- Nombre paciente: clamp(1.15→2.65rem, 2.1→3.6vw, 1.45→2.95rem)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 12:23:44 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9afefcf3e7 fix(turnero): turno de recepcion 1 aparece en recepcion 2 en display
get_display_global.php: la query de cada escritorio tenía la condición
  OR t.recepcion_desk_id IS NULL
lo que hacía que un turno llamado sin desk_id (NULL) apareciera como activo
en TODOS los escritorios simultáneamente, anunciando el destino incorrecto.
Se eliminó esa condición para usar match exacto por escritorio.

llamar_turno.php: cambiar COALESCE(?, recepcion_desk_id) por ? directo.
El COALESCE era innecesario porque el UPDATE solo aplica sobre estado='espera'
(primer llamado). Ahora siempre escribe el valor recibido.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 12:15:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4f25cd6b59 fix(turnero): corregir anuncios de sonido en display_global
- Bug 1: announcedCalls usaba el ID del turno como clave, silenciando todas
  las re-llamadas. Ahora usa key=codigo|destino|llamado_at, igual que
  lastSeenKeys, para que cada re-llamada anuncie correctamente.
- Bug 2: anunciarTurno no verificaba sonidoActivo antes de llamar a
  speechSynthesis.speak(); agregado guard al inicio de la función.
- Bug 3: Chrome congela speechSynthesis tras ~15 min sin interacción;
  agregado keepalive de 14s con pause()+resume() para mantener el motor activo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 12:09:51 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5af985581c feat(turnero): UI/UX tablet improvements for recepcion and lugar views
- recepcion: wider queue column (370px), breakpoint at 680px so portrait
  tablets get 2-column layout; prominent "Llamar siguiente" button in queue
  column header; searchable exam filter; touch-friendly action bar with
  large primary/secondary button rows; larger touch targets for cards,
  checkboxes and consent items
- lugar: wider queue column (340px), breakpoint at 680px; larger action
  buttons (.8rem padding, 1rem font) and secondary buttons; consistent
  mobile breakpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 18:55:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b55c7c1a19 feat: mostrar número de orden en ficha del turno (lugar)
get_consentimientos.php ahora incluye s.numero_orden en el SELECT.
En lugar.php el número de orden aparece como badge de solo lectura
en el header de la ficha, junto al código del turno, cuando la
solicitud tiene número de orden asignado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 18:04:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 794db4f0d5 fix: botones Descargar PDF y Cerrar tras firmar consentimiento
Después de firmar (y en carga cuando ya está firmado) no había ninguna
acción disponible — solo texto "Puede cerrar esta ventana" sin botón.

Ahora aparecen dos botones:
- Descargar PDF → window.print() (permite guardar como PDF)
- Cerrar → window.close() (solo cuando no está en iframe)

En modo embebido (iframe desde lugar.php) solo aparece Descargar PDF;
el cierre lo maneja el postMessage ya existente al padre.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 18:01:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 99bfaa4c2f fix: convertir audio webm → ogg con ffmpeg antes de subir a WhatsApp en turnero
El archivo grabado por el browser es WebM (no soportado por WhatsApp).
conversations.php usa ffmpeg para convertirlo; chat_upload_media.php solo
cambiaba el string del MIME pero enviaba el archivo WebM real, causando que
el audio no llegara al destinatario. Ahora se convierte con los mismos
parámetros que upload_media.php (libopus 32k mono 48kHz).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:56:15 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a526a29a68 feat: botón Ver paciente con historial en vista lugar
Agrega botón "Ver" en la sección Paciente de la ficha del turno.
Abre modal con datos completos del paciente (nombre, doc, fecha nac,
celular, médico, embarazo) y timeline de historial de visitas anterior
(misma lógica que recepción), cargado desde get_historial.php.

El botón solo aparece cuando hay un paciente vinculado al turno.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:55:51 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 282c372de3 fix: corregir reproducción de audio en chat turnero
- URL absoluta para local_file y media_url_external (igual que conversations.php)
- Múltiples <source> con MIME types (ogg, mpeg, wav, webm) para compatibilidad
- onerror handler cuando el archivo no está disponible
- Fallback visible cuando mediaSrc es null ("Audio pendiente de descarga")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:50:43 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 53dc492b5e feat: auto-rellenar variable {{name}} con nombre del paciente en nuevo mensaje
Al seleccionar un paciente de la lista, si la plantilla tiene una variable
llamada 'name', se pre-rellena automáticamente con su nombre. Si se ingresa
un número directo, el campo queda vacío para llenarlo manualmente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:48:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e88fb4cc86 fix: mensajes automáticos del turnero registrados en canal principal
create_turno.php y send_consentimiento.php enviaban por WhatsAppService('turnero')
(número correcto) pero sin pasar meta canal='turnero', por lo que saveOutgoingMessage
defaulteaba a canal='bot' y quedaban registrados en las conversaciones del principal.

Agregado $waMeta = ['canal'=>'turnero'] en ambos archivos para sendTemplateMessage
y sendTextMessage (fallback).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:42:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1bd799a501 fix: corregir envío de plantillas con variables nombradas en chat turnero
El parámetro_name enviado a WhatsApp era el índice numérico ("1") en vez
del nombre real de la variable ("name", "fecha", etc.), causando el error
#100 Invalid parameter. Ahora se detecta si las variables son nombradas o
numéricas y se construye el payload correctamente, igual que conversations.php.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:37:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 34c6d2e643 fix: documentos recibidos en chat devuelven 401
Las URLs de CDN de WhatsApp (lookaside.fbsbx.com) requieren Authorization Bearer.
chat_get_messages.php las exponía directamente al browser, que no puede añadir
ese header, resultando en 401 "Authentication Error".

Ahora siempre se enrutan por /api/version/media-url.php (proxy existente) que:
- Sirve el archivo local si existe en disco
- Hace fetch con el token de WA si no está en local
- No requiere sesión para peticiones GET del browser

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:30:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 12c790fb5c fix: mover CSS del modal nuevo-mensaje fuera del media query mobile
Los estilos de #nuevo-modal y .nv-* estaban dentro de @media (max-width: 680px),
por lo que el modal no tenía diseño en desktop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:28:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f975844d9d fix: reacciones y audio en chat turnero
- chat_react: guardaba en tabla `messages` en lugar de `conversations` (tabla real del chat)
- chat_react: columna era `whatsapp_message_id`; la correcta es `message_id`
- chat.php: sendReactionEmoji actualiza la burbuja DOM inmediatamente sin esperar poll
- chat_upload_media: mime_content_type() devuelve video/webm en Linux para audio grabado;
  ahora cualquier mime con "webm" se remapea a audio/ogg antes de determinar mediaType

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:24:35 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ab72750410 feat(chat): botón Nuevo mensaje con búsqueda de paciente y picker de plantilla
- chat_buscar_paciente.php: GET ?q= busca en lab_pacientes por nombre,
  documento o teléfono (mín. 2 chars, máx. 20 resultados)
- chat_start_conversation.php: POST {phone, template_name, lang, params}
  — normaliza teléfono, busca/crea usuario en users, envía plantilla
  vía WhatsAppService('turnero'), devuelve {ok, user_id}
- chat.php: botón "+ Nuevo" en sidebar header; modal 3 pasos:
  1) buscar paciente (debounce 350ms) o ingresar número libre
  2) lista de plantillas habilitadas
  3) variables de la plantilla + Enviar
  Al enviar recarga el sidebar y abre la conversación automáticamente

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:19:18 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 67efd470de feat(firma): integración Topaz SigWeb en ver_formulario_enviado.php
- CSS/HTML: mismo overlay #topaz-overlay con modal de estado
- JS: módulo topaz con target={canvas, onAccept} para canvas
  del profesional (.fpw-canvas) y del paciente turnero (.turnero-cv)
- Botón "Tableta" en canvas del paciente (.turnero-topaz) y del
  profesional (.fpw-topaz); el usuario sigue confirmando manualmente

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:11:29 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9e19c1c8c7 feat(firma): integración pad biométrico Topaz SigWeb
- CSS: overlay #topaz-overlay con modal centrado, estado has-sig
- HTML: modal con área de estado, contador de trazos, botones
  Cancelar / Limpiar / Aceptar
- JS: módulo topaz — carga SigWebTablet.js desde localhost:47289,
  compatible con SigWeb v1 (globals+ctx) y v2 (métodos en objeto),
  soporta fid='__global' para canvas principal e inline (fw-*)
- Botón "Tableta" agregado en canvas area global y en renderFirmaInline
  para campos tipo firma/firma_profesional

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 17:06:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3d6147bf0d fix(template): send parameter_name for named-variable templates
WhatsApp API returns "(#100) Invalid parameter — Parameter name is
missing or empty" for templates that use named variables.

- chat.php sendTemplate(): collect params as [{name, value}] objects
  using data-var-index as the parameter name
- WhatsAppService::sendTemplateMessage(): handle {name, value} objects
  in the indexed-array path by emitting {type, parameter_name, text}
  (backward-compatible — plain strings still work as positional params)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 15:04:31 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a8380ddc81 debug(template): expose Meta error subcode/details, add canal meta
- WhatsAppService::makeRequest: include error_subcode and error_data.details
  in the thrown exception message to surface the real reason for #100 errors
- chat_send_message: log template name/lang/params before sending;
  pass canal+operator_id meta so outgoing templates are tagged correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 14:54:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f60df038d8 fix(chat): capture template name/lang before closing modal in sendTemplate
closeTemplatePicker() sets _tplSelected = null, so accessing
_tplSelected.template_name after the call threw a TypeError caught
as "Error de red al enviar plantilla".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 14:47:31 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 ccdc35932d feat(turnero): add chat plantillas API endpoints
- chat_get_plantillas.php: GET — returns enabled templates from
  turnero_chat_plantillas JOIN message_templates; auto-creates table
- chat_save_plantillas.php: POST {ids:[...]} — replaces all rows in
  turnero_chat_plantillas using DELETE + INSERT IGNORE in a transaction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 14:41:24 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 548ad3140d feat(turnero): template whitelist checklist + 2-step picker in chat
- configuracion.php (sesion tab): loads approved message_templates and
  enabled turnero_chat_plantillas; renders styled checklist; guardarPlantillasChat()
  POSTs to chat_save_plantillas.php
- chat.php: replaces free-text template modal with 2-step modal —
  step 1 lists enabled templates (chat_get_plantillas.php), step 2
  fetches variables via get_template_details.php and renders labeled
  inputs; sendTemplate() builds params array and POSTs to chat_send_message.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 14:37:21 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8a7e7a6658 Fix chat media upload: extract id from uploadMedia result, remap audio/webm
uploadMedia() returns the full API response array, not just the id string.
chat_upload_media.php was passing the whole array as $mediaId, producing
{"image": {"id": {"id": "...", "messaging_product": "..."}}} which
violated WhatsApp JSON schema for the media id field.

Also remap audio/webm (browser MediaRecorder default) to audio/ogg at
upload time since WhatsApp's media API rejects webm but accepts ogg/opus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 14:28:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0cef678848 Add server-side pagination to medicos list (25 per page)
list.php now accepts page/limit params and returns total count.
View shows X-Y de N label, page buttons with ellipsis for large ranges,
and prev/next arrows. Search resets to page 1; save/delete stay on
current page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 14:24:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 00725ccd11 Redesign lugar.php UI/UX: patient-first, better cola, sticky actions
Bloque 1 - Cola:
- Separate sections for "en servicio" vs "en espera" with headers
- Time-in-queue badges (green <10min, amber <25min, red 25min+) from creado_at
- Prominent EN SERVICIO pill (solid green) instead of subtle border
- Larger touch targets on queue cards, bigger bell button
- Count chips show espera + servicio counts separately

Bloque 2 - Ficha header + action bar:
- Patient name is now the dominant element (1.35rem bold) with skeleton
  loader animation while async data loads; turno code becomes a secondary chip
- Action bar restructured: full-width primary button (blue "Anunciar turno" /
  green "Finalizar atención") + smaller secondary row (Re-llamar, Regresar, Ausente)
- Consent warning strip added inside action bar showing pending count;
  blocks Finalizar visually and functionally

Bloque 3 - Sections + mobile:
- Section headers use consistent .ficha-sec-hdr style with icon
- Mobile: fixed sticky "← Cola" top bar inside ficha panel (always visible,
  no hunting for back button)
- ficha-turno uses flex-column so action bar sticks to bottom without scroll

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 14:21:42 -05:00
233 changed files with 27066 additions and 2254 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.*
+6 -1
View File
@@ -36,7 +36,12 @@ RUN apk add --no-cache curl-dev \
opcache opcache
# Instalar Redis extension (versión fija para cache reproducible) # Instalar Redis extension (versión fija para cache reproducible)
RUN pecl install redis-6.0.2 && docker-php-ext-enable redis # Se descarga por HTTPS de forma explícita: el filtro de red perimetral
# responde 403 a los .tgz servidos por HTTP, lo que rompía "pecl install".
RUN apk add --no-cache curl \
&& curl -fsSL https://pecl.php.net/get/redis-6.0.2.tgz -o /tmp/redis-6.0.2.tgz \
&& pecl install /tmp/redis-6.0.2.tgz \
&& docker-php-ext-enable redis
# Instalar Composer # Instalar Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+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)
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* Migración: simplificar formulario de Tomas Prolongadas (id=15)
* - Campo _c8j2g16 pasa de radio/checkbox a texto libre ("Tipo de examen")
* - Se eliminan las condiciones de todos los separadores (siempre visibles)
* Auto-elimina al ejecutar. Acceder como admin.
*/
require_once __DIR__ . '/config/config.php';
if (!isUserLoggedIn() || !in_array($_SESSION['admin_user']['role'] ?? '', ['admin','superadmin'], true)) {
http_response_code(403); die('Acceso denegado.');
}
$pdo = Database::getInstance()->getConnection();
$row = $pdo->query("SELECT esquema FROM lab_formularios WHERE id = 15 LIMIT 1")->fetch(PDO::FETCH_ASSOC);
if (!$row) { die('Formulario id=15 no encontrado.'); }
$esquema = json_decode($row['esquema'], true);
if (!is_array($esquema)) { die('Esquema inválido.'); }
$cambios = 0;
foreach ($esquema as &$campo) {
$id = $campo['id'] ?? '';
$tipo = $campo['tipo'] ?? '';
// Separadores con condición sobre _c8j2g16 → quitar condición
if ($tipo === 'separador' && isset($campo['condicion'])) {
$condCampo = $campo['condicion']['campo_id'] ?? '';
if ($condCampo === '_c8j2g16') {
unset($campo['condicion']);
$cambios++;
}
}
}
unset($campo);
$pdo->prepare("UPDATE lab_formularios SET esquema = ? WHERE id = 15")
->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
@unlink(__FILE__);
echo '<p style="font-family:sans-serif;padding:2rem">
<span style="color:green;font-size:1.2rem">✅ Migración completada</span><br><br>
<strong>' . $cambios . ' campos modificados</strong>:<br>
• Condiciones eliminadas de separadores de tomas<br>
• Campo _c8j2g16 conservado como selector (+ botón "Añadir tipo")<br><br>
<small style="color:#666">Este archivo fue eliminado automáticamente.</small><br><br>
<a href="lab_formularios.php" style="color:#0d6efd">← Volver a Formularios</a>
</p>';
+58
View File
@@ -0,0 +1,58 @@
<?php
/**
* Restaura las condiciones eliminadas por _migrate_tomas_simplify.php
* Para cada separador cuyo label empiece con un tipo de examen conocido
* (ej. "Curva de Glicemia · Minuto 0") → agrega condicion sobre _c8j2g16.
* Auto-elimina al ejecutar. Acceder como admin.
*/
require_once __DIR__ . '/config/config.php';
if (!isUserLoggedIn() || !in_array($_SESSION['admin_user']['role'] ?? '', ['admin','superadmin'], true)) {
http_response_code(403); die('Acceso denegado.');
}
$pdo = Database::getInstance()->getConnection();
$row = $pdo->query("SELECT esquema FROM lab_formularios WHERE id = 15 LIMIT 1")->fetch(PDO::FETCH_ASSOC);
if (!$row) { die('Formulario id=15 no encontrado.'); }
$esquema = json_decode($row['esquema'], true);
if (!is_array($esquema)) { die('Esquema inválido.'); }
// Obtener opciones del selector de examen
$examOptions = [];
foreach ($esquema as $c) {
if (($c['id'] ?? '') === '_c8j2g16') { $examOptions = $c['options'] ?? []; break; }
}
if (empty($examOptions)) { die('No se encontraron tipos de examen en _c8j2g16.'); }
$restaurados = 0;
foreach ($esquema as &$campo) {
if (($campo['tipo'] ?? '') !== 'separador') continue;
if (isset($campo['condicion'])) continue; // ya tiene condicion
$label = $campo['label'] ?? '';
$prefix = trim(explode('·', $label)[0] ?? '');
if (!$prefix) continue;
// Buscar si el prefijo corresponde exactamente a un tipo de examen
if (in_array($prefix, $examOptions, true)) {
$campo['condicion'] = [
'campo_id' => '_c8j2g16',
'valores' => [$prefix],
];
$restaurados++;
}
}
unset($campo);
$pdo->prepare("UPDATE lab_formularios SET esquema = ? WHERE id = 15")
->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
@unlink(__FILE__);
echo '<p style="font-family:sans-serif;padding:2rem">
<span style="color:green;font-size:1.2rem">✅ Condiciones restauradas</span><br><br>
<strong>' . $restaurados . ' separadores</strong> actualizados con condicion sobre _c8j2g16.<br>
<small style="color:#666">Tipos de examen encontrados: ' . implode(', ', array_map('htmlspecialchars', $examOptions)) . '</small><br><br>
<small style="color:#999">Este archivo fue eliminado automáticamente.</small><br><br>
<a href="lab_formularios.php" style="color:#0d6efd">← Volver a Formularios</a>
</p>';
+39
View File
@@ -0,0 +1,39 @@
<?php
/**
* Script de migración de un solo uso — ELIMINAR DESPUÉS DE EJECUTAR
* Acceder como admin para registrar lab_tomas_config en system_modules.
*/
require_once __DIR__ . '/config/config.php';
if (!isUserLoggedIn() || !in_array($_SESSION['admin_user']['role'] ?? '', ['admin','superadmin'], true)) {
http_response_code(403); die('Acceso denegado.');
}
$pdo = Database::getInstance()->getConnection();
$row = $pdo->query("SELECT MAX(sort_order) AS mx FROM system_modules WHERE category = 'clinico'")->fetch(PDO::FETCH_ASSOC);
$nextOrder = (int)($row['mx'] ?? 50) + 10;
$pdo->prepare("
INSERT INTO system_modules (slug, name, icon, category, route, is_active, sort_order, description)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
ON DUPLICATE KEY UPDATE
name=VALUES(name), icon=VALUES(icon), category=VALUES(category),
route=VALUES(route), is_active=1, sort_order=VALUES(sort_order), description=VALUES(description)
")->execute([
'lab_tomas_config',
'Tipos de Examen (Tomas)',
'fas fa-vials',
'clinico',
'/lab_tomas_config.php',
$nextOrder,
'Configurar tipos de examen y ciclos de tomas prolongadas (F-LAB-28)',
]);
// Auto-eliminar el script
@unlink(__FILE__);
echo '<p style="font-family:sans-serif;color:green;padding:2rem">
✅ Módulo <strong>lab_tomas_config</strong> registrado en system_modules (sort_order='.$nextOrder.').<br>
<small>Este archivo fue eliminado automáticamente.</small><br><br>
<a href="lab_tomas_config.php">Ir a Tipos de Examen →</a>
</p>';
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* POST /api/lab/add_exam_option.php
* Añade una opción al campo selector de tipo de examen (_c8j2g16) del formulario id=15.
* Body JSON: { exam_name: string }
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireAdmin();
$body = inputJson();
$examName = trim($body['exam_name'] ?? '');
if (!$examName) jsonError('exam_name requerido.');
if (strlen($examName) > 80) jsonError('Nombre demasiado largo (máx 80 chars).');
if (!preg_match('/\S/', $examName)) jsonError('Nombre inválido.');
$db = Database::getInstance();
$row = $db->fetch("SELECT esquema FROM lab_formularios WHERE id = 15 LIMIT 1");
if (!$row) jsonError('Formulario no encontrado.', 404);
$esquema = json_decode($row['esquema'], true);
if (!is_array($esquema)) jsonError('Esquema inválido.', 500);
$updated = false;
foreach ($esquema as &$campo) {
if (($campo['id'] ?? '') !== '_c8j2g16') continue;
$opts = $campo['options'] ?? [];
if (in_array($examName, $opts, true)) jsonError("El tipo \"$examName\" ya existe.");
$opts[] = $examName;
$campo['options'] = $opts;
$updated = true;
break;
}
unset($campo);
if (!$updated) jsonError('Campo selector de examen no encontrado en el formulario.', 500);
$db->getConnection()->prepare("UPDATE lab_formularios SET esquema = ? WHERE id = 15")
->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
jsonOk(['exam_name' => $examName], "Tipo \"$examName\" añadido.");
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* /api/lab/ciudades.php
* GET ?action=list [solo_activas=1] → lista ciudades
* POST {action:save, id?, nombre} → crear / renombrar
* POST {action:toggle, id} → activar / desactivar
* POST {action:delete, id} → eliminar (solo si no hay pacientes)
*/
require_once __DIR__ . '/_helpers.php';
$pdo = db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$soloActivas = isset($_GET['solo_activas']) && $_GET['solo_activas'] == '1';
$sql = 'SELECT id, nombre, activa, orden FROM lab_ciudades' . ($soloActivas ? ' WHERE activa = 1' : '') . ' ORDER BY nombre';
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
jsonOk(['ciudades' => $rows]);
}
requireMethod('POST');
requireAdmin();
$body = inputJson();
$action = trim($body['action'] ?? '');
if ($action === 'save') {
$nombre = trim($body['nombre'] ?? '');
$id = (int)($body['id'] ?? 0);
if (!$nombre) jsonError('nombre requerido.');
if (strlen($nombre) > 100) jsonError('nombre demasiado largo (máx 100).');
if ($id) {
$pdo->prepare('UPDATE lab_ciudades SET nombre = ? WHERE id = ?')->execute([$nombre, $id]);
jsonOk(['id' => $id], 'Ciudad actualizada.');
} else {
$st = $pdo->prepare('INSERT INTO lab_ciudades (nombre) VALUES (?)');
try {
$st->execute([$nombre]);
} catch (\PDOException $e) {
if ($e->getCode() == 23000) jsonError('Ya existe una ciudad con ese nombre.');
throw $e;
}
jsonOk(['id' => (int)$pdo->lastInsertId()], 'Ciudad creada.');
}
}
if ($action === 'toggle') {
$id = (int)($body['id'] ?? 0);
if (!$id) jsonError('id requerido.');
$pdo->prepare('UPDATE lab_ciudades SET activa = NOT activa WHERE id = ?')->execute([$id]);
jsonOk([], 'Estado actualizado.');
}
if ($action === 'delete') {
$id = (int)($body['id'] ?? 0);
if (!$id) jsonError('id requerido.');
$uso = $pdo->prepare('SELECT COUNT(*) FROM lab_pacientes WHERE ciudad = (SELECT nombre FROM lab_ciudades WHERE id = ?)');
$uso->execute([$id]);
if ((int)$uso->fetchColumn() > 0) jsonError('No se puede eliminar: hay pacientes con esta ciudad. Desactívela en su lugar.');
$pdo->prepare('DELETE FROM lab_ciudades WHERE id = ?')->execute([$id]);
jsonOk([], 'Ciudad eliminada.');
}
jsonError('action inválida.');
+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;
} }
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* /api/lab/empresa_subgrupos.php
*
* GET ?nit_empresa= → subgrupos de la empresa
* POST {action:save, ...} → upsert subgrupo
* POST {action:delete, id} → elimina subgrupo
*/
require_once __DIR__ . '/_helpers.php';
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$nit = trim($_GET['nit_empresa'] ?? '');
if ($nit === '') jsonError('nit_empresa requerido');
$pdo = db();
$s = $pdo->prepare(
"SELECT es.*, ti.nombre AS tarifa_nombre
FROM lab_empresa_subgrupos es
LEFT JOIN lab_tarifas_id ti ON ti.id = es.tarifa_id
WHERE es.nit_empresa = ?
ORDER BY es.subgrupo ASC"
);
$s->execute([$nit]);
jsonOk(['subgrupos' => $s->fetchAll(PDO::FETCH_ASSOC)]);
}
requireMethod('POST');
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $data['action'] ?? '';
$pdo = db();
if ($action === 'delete') {
$id = (int)($data['id'] ?? 0);
if ($id <= 0) jsonError('id requerido');
$pdo->prepare("DELETE FROM lab_empresa_subgrupos WHERE id = ?")->execute([$id]);
jsonOk(['deleted' => true]);
}
if ($action === 'save') {
$id = (int)($data['id'] ?? 0);
$nitEmpresa = trim($data['nit_empresa'] ?? '');
$subgrupo = trim($data['subgrupo'] ?? '');
if ($nitEmpresa === '') jsonError('nit_empresa requerido');
if ($subgrupo === '') jsonError('subgrupo requerido');
$tarifaId = isset($data['tarifa_id']) && $data['tarifa_id'] !== '' ? (int)$data['tarifa_id'] : null;
$refSub = trim($data['ref_subgrupo'] ?? '') ?: null;
$codCon = trim($data['cod_contrato'] ?? '') ?: null;
if ($id > 0) {
$pdo->prepare(
"UPDATE lab_empresa_subgrupos
SET subgrupo=?, tarifa_id=?, ref_subgrupo=?, cod_contrato=?
WHERE id=? AND nit_empresa=?"
)->execute([$subgrupo, $tarifaId, $refSub, $codCon, $id, $nitEmpresa]);
} else {
$pdo->prepare(
"INSERT INTO lab_empresa_subgrupos (nit_empresa, subgrupo, tarifa_id, ref_subgrupo, cod_contrato)
VALUES (?, ?, ?, ?, ?)"
)->execute([$nitEmpresa, $subgrupo, $tarifaId, $refSub, $codCon]);
$id = (int)$pdo->lastInsertId();
}
jsonOk(['id' => $id]);
}
jsonError('Acción no reconocida', 400);
+162
View File
@@ -0,0 +1,162 @@
<?php
/**
* /api/lab/empresas.php
*
* GET ?action=list [search, activa, page, limit] → lista paginada
* GET ?action=get &nit= → empresa + subgrupos
* POST {action:save, nit, nombre, ...} → upsert empresa
* POST {action:toggle, nit} → activa/inactiva
* POST {action:delete, nit} → elimina (si sin recepciones)
*/
require_once __DIR__ . '/_helpers.php';
$method = $_SERVER['REQUEST_METHOD'];
// ─── GET ─────────────────────────────────────────────────────────────────────
if ($method === 'GET') {
$action = $_GET['action'] ?? 'list';
$pdo = db();
if ($action === 'get') {
$nit = trim($_GET['nit'] ?? '');
if ($nit === '') jsonError('nit requerido');
$e = $pdo->prepare(
"SELECT e.*, ti.nombre AS tarifa_nombre
FROM lab_empresas e
LEFT JOIN lab_tarifas_id ti ON ti.id = e.tarifa_id
WHERE e.nit = ?"
);
$e->execute([$nit]);
$empresa = $e->fetch(PDO::FETCH_ASSOC);
if (!$empresa) jsonError('Empresa no encontrada', 404);
$s = $pdo->prepare(
"SELECT es.*, ti.nombre AS tarifa_nombre
FROM lab_empresa_subgrupos es
LEFT JOIN lab_tarifas_id ti ON ti.id = es.tarifa_id
WHERE es.nit_empresa = ?
ORDER BY es.subgrupo ASC"
);
$s->execute([$nit]);
$empresa['subgrupos'] = $s->fetchAll(PDO::FETCH_ASSOC);
jsonOk(['empresa' => $empresa]);
}
// list
$search = trim($_GET['search'] ?? '');
$activa = isset($_GET['activa']) && $_GET['activa'] !== '' ? (int)$_GET['activa'] : null;
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = max(1, min(100, (int)($_GET['limit'] ?? 30)));
$offset = ($page - 1) * $limit;
$where = [];
$params = [];
if ($search !== '') {
$where[] = '(e.nombre LIKE ? OR e.nit LIKE ? OR e.razon_social LIKE ?)';
$like = "%{$search}%";
$params[] = $like; $params[] = $like; $params[] = $like;
}
if ($activa !== null) {
$where[] = 'e.activa = ?';
$params[] = $activa;
}
$wSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$total = $pdo->prepare("SELECT COUNT(*) FROM lab_empresas e $wSql");
$total->execute($params);
$totalRows = (int)$total->fetchColumn();
$rows = $pdo->prepare(
"SELECT e.nit, e.nombre, e.razon_social, e.tarifa_id, ti.nombre AS tarifa_nombre,
e.descuento_pct, e.codigo_eps, e.tipo_usuario, e.req_autoriza, e.activa,
(SELECT COUNT(*) FROM lab_empresa_subgrupos es WHERE es.nit_empresa = e.nit) AS total_subgrupos
FROM lab_empresas e
LEFT JOIN lab_tarifas_id ti ON ti.id = e.tarifa_id
$wSql
ORDER BY e.nombre ASC
LIMIT $limit OFFSET $offset"
);
$rows->execute($params);
jsonOk([
'empresas' => $rows->fetchAll(PDO::FETCH_ASSOC),
'total' => $totalRows,
'page' => $page,
'limit' => $limit,
'pages' => (int)ceil($totalRows / $limit),
]);
}
// ─── POST ────────────────────────────────────────────────────────────────────
requireMethod('POST');
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $data['action'] ?? '';
$pdo = db();
if ($action === 'toggle') {
$nit = trim($data['nit'] ?? '');
if ($nit === '') jsonError('nit requerido');
$pdo->prepare("UPDATE lab_empresas SET activa = 1 - activa WHERE nit = ?")->execute([$nit]);
$activa = (int)$pdo->prepare("SELECT activa FROM lab_empresas WHERE nit=?")->execute([$nit]);
$row = $pdo->prepare("SELECT activa FROM lab_empresas WHERE nit=?");
$row->execute([$nit]);
jsonOk(['activa' => (bool)(int)$row->fetchColumn()]);
}
if ($action === 'delete') {
$nit = trim($data['nit'] ?? '');
if ($nit === '') jsonError('nit requerido');
// Block deletion if empresa has recepciones (historical)
$chk = $pdo->prepare("SELECT COUNT(*) FROM lab_recepciones WHERE nit_empresa = ? LIMIT 1");
$chk->execute([$nit]);
if ((int)$chk->fetchColumn() > 0) {
jsonError('No se puede eliminar: la empresa tiene recepciones históricas registradas.', 409);
}
$pdo->prepare("DELETE FROM lab_empresas WHERE nit = ?")->execute([$nit]);
jsonOk(['deleted' => true]);
}
if ($action === 'save') {
$nit = trim($data['nit'] ?? '');
$nombre = trim($data['nombre'] ?? '');
if ($nit === '') jsonError('El NIT es obligatorio');
if ($nombre === '') jsonError('El nombre es obligatorio');
$fields = [
'nombre' => $nombre,
'razon_social' => trim($data['razon_social'] ?? '') ?: null,
'tarifa_id' => isset($data['tarifa_id']) && $data['tarifa_id'] !== '' ? (int)$data['tarifa_id'] : null,
'descuento_pct' => isset($data['descuento_pct']) ? (float)$data['descuento_pct'] : 0,
'codigo_eps' => trim($data['codigo_eps'] ?? '') ?: null,
'tipo_usuario' => trim($data['tipo_usuario'] ?? '') ?: null,
'tipo_usuario_sispro'=> trim($data['tipo_usuario_sispro']?? '') ?: null,
'cod_contrato' => trim($data['cod_contrato'] ?? '') ?: null,
'cod_tercero' => trim($data['cod_tercero'] ?? '') ?: null,
'centro_costo' => trim($data['centro_costo'] ?? '') ?: null,
'req_autoriza' => isset($data['req_autoriza']) ? (int)(bool)$data['req_autoriza'] : 0,
'activa' => isset($data['activa']) ? (int)(bool)$data['activa'] : 1,
];
// Check if exists
$exists = $pdo->prepare("SELECT nit FROM lab_empresas WHERE nit = ?");
$exists->execute([$nit]);
$isNew = !$exists->fetch();
if ($isNew) {
$cols = implode(', ', array_map(fn($k) => "`$k`", array_keys($fields)));
$ph = implode(', ', array_fill(0, count($fields), '?'));
$pdo->prepare("INSERT INTO lab_empresas (nit, $cols) VALUES (?, $ph)")
->execute(array_merge([$nit], array_values($fields)));
} else {
$sets = implode(', ', array_map(fn($k) => "`$k` = ?", array_keys($fields)));
$pdo->prepare("UPDATE lab_empresas SET $sets WHERE nit = ?")
->execute(array_merge(array_values($fields), [$nit]));
}
jsonOk(['nit' => $nit, 'created' => $isNew]);
}
jsonError('Acción no reconocida', 400);
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* /api/lab/eps.php
* GET ?action=list [solo_activas=1] → lista EPS
* POST {action:save, id?, nombre} → crear / renombrar
* POST {action:toggle, id} → activar / desactivar
* POST {action:delete, id} → eliminar (solo si no hay pacientes)
*/
require_once __DIR__ . '/_helpers.php';
$pdo = db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$soloActivas = isset($_GET['solo_activas']) && $_GET['solo_activas'] == '1';
$sql = 'SELECT id, nombre, activa, orden FROM lab_eps' . ($soloActivas ? ' WHERE activa = 1' : '') . ' ORDER BY nombre';
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
jsonOk(['eps' => $rows]);
}
requireMethod('POST');
requireAdmin();
$body = inputJson();
$action = trim($body['action'] ?? '');
if ($action === 'save') {
$nombre = trim($body['nombre'] ?? '');
$id = (int)($body['id'] ?? 0);
if (!$nombre) jsonError('nombre requerido.');
if (strlen($nombre) > 120) jsonError('nombre demasiado largo (máx 120).');
if ($id) {
$pdo->prepare('UPDATE lab_eps SET nombre = ? WHERE id = ?')->execute([$nombre, $id]);
jsonOk(['id' => $id], 'EPS actualizada.');
} else {
$st = $pdo->prepare('INSERT INTO lab_eps (nombre) VALUES (?)');
try {
$st->execute([$nombre]);
} catch (\PDOException $e) {
if ($e->getCode() == 23000) jsonError('Ya existe una EPS con ese nombre.');
throw $e;
}
jsonOk(['id' => (int)$pdo->lastInsertId()], 'EPS creada.');
}
}
if ($action === 'toggle') {
$id = (int)($body['id'] ?? 0);
if (!$id) jsonError('id requerido.');
$pdo->prepare('UPDATE lab_eps SET activa = NOT activa WHERE id = ?')->execute([$id]);
jsonOk([], 'Estado actualizado.');
}
if ($action === 'delete') {
$id = (int)($body['id'] ?? 0);
if (!$id) jsonError('id requerido.');
$uso = $pdo->prepare('SELECT COUNT(*) FROM lab_pacientes WHERE eps = (SELECT nombre FROM lab_eps WHERE id = ?)');
$uso->execute([$id]);
if ((int)$uso->fetchColumn() > 0) jsonError('No se puede eliminar: hay pacientes con esta EPS. Desactívela en su lugar.');
$pdo->prepare('DELETE FROM lab_eps WHERE id = ?')->execute([$id]);
jsonOk([], 'EPS eliminada.');
}
jsonError('action inválida.');
+4
View File
@@ -32,6 +32,8 @@ 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';
+157
View File
@@ -0,0 +1,157 @@
<?php
/**
* GET /api/lab/get_examenes_rips.php?cedula=X
* Consulta al RIPS Manager los exámenes registrados en los últimos 5 minutos
* para la cédula indicada, y los mapea a exam_tipos locales por codigo_legacy.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
$cedula = trim($_GET['cedula'] ?? '');
if (!$cedula) jsonError('cedula requerida', 400);
// ── 1. Buscar en cache local (exámenes enviados por el scheduler de RIPS) ─────
$examenes = [];
$fuenteCache = false;
$cacheRow = db()->prepare(
"SELECT datos, recepcion_id, hora_recepcion
FROM rips_examenes_pendientes
WHERE numero_documento = ?
AND DATE(created_at) = CURDATE()
AND created_at >= NOW() - INTERVAL 30 MINUTE
AND turno_id IS NULL
ORDER BY created_at DESC
LIMIT 1"
);
$cacheRow->execute([$cedula]);
$cache = $cacheRow->fetch(PDO::FETCH_ASSOC);
if ($cache) {
$examenes = json_decode($cache['datos'], true) ?: [];
$fuenteCache = true;
}
// ── 2. Si no hay cache, consultar RIPS Manager ────────────────────────────────
if (!$examenes) {
$ripsUrl = defined('RIPS_MANAGER_URL') ? rtrim(RIPS_MANAGER_URL, '/') : '';
if (!$ripsUrl) jsonError('RIPS_MANAGER_URL no configurado en el servidor', 503);
$url = $ripsUrl . '/pacientes/examenes?cedula=' . urlencode($cedula);
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => 'X-Lab-Key: ' . LAB_SYNC_KEY . "\r\n",
'timeout' => 8,
'ignore_errors' => true,
],
]);
$resp = @file_get_contents($url, false, $ctx);
if ($resp === false) jsonError('No se pudo conectar con RIPS Manager', 503);
$data = json_decode($resp, true);
if (!($data['ok'] ?? false)) {
jsonError($data['error'] ?? 'Error en RIPS Manager', 502);
}
$examenes = $data['examenes'] ?? [];
}
if (!$examenes) {
jsonOk(['encontrados' => [], 'no_mapeados' => [], 'total_rips' => 0]);
}
// Mapear COD_EXAMEN → exam_tipos por codigo_legacy
$codigos = array_values(array_unique(array_filter(
array_map(fn($e) => trim($e['cod_examen'] ?? ''), $examenes)
)));
$encontrados = [];
$no_mapeados = [];
if ($codigos) {
$ph = implode(',', array_fill(0, count($codigos), '?'));
$stmt = db()->prepare(
"SELECT id AS exam_tipo_id, codigo, nombre,
COALESCE(codigo_legacy, codigo) AS match_key
FROM exam_tipos
WHERE (codigo_legacy IN ($ph) OR (codigo_legacy IS NULL AND codigo IN ($ph)))
AND activo = 1"
);
$stmt->execute(array_merge($codigos, $codigos));
$mapa = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
$mapa[trim($r['match_key'])] = $r;
}
foreach ($codigos as $cod) {
if (isset($mapa[$cod])) {
$encontrados[] = [
'exam_tipo_id' => (int)$mapa[$cod]['exam_tipo_id'],
'codigo' => $mapa[$cod]['codigo'],
'nombre' => $mapa[$cod]['nombre'],
'cod_rips' => $cod,
];
} else {
$no_mapeados[] = $cod;
}
}
}
$meta = $examenes[0] ?? [];
// ── Médico ordenante ──────────────────────────────────────────────────────────
$medicoObj = null;
$docidmedico = trim($meta['medico_docidmedico'] ?? '');
if ($docidmedico) {
$stm = db()->prepare(
"SELECT id, codigo, CONCAT(nombres, ' ', apellidos) AS nombre_completo, cod_especialidad
FROM medicos WHERE docidmedico = ? LIMIT 1"
);
$stm->execute([$docidmedico]);
$row = $stm->fetch(PDO::FETCH_ASSOC);
if ($row) {
$medicoObj = [
'id' => (int)$row['id'],
'codigo' => $row['codigo'],
'nombre' => $row['nombre_completo'],
'especialidad' => $row['cod_especialidad'] ?? '',
];
}
}
// ── Empresa / EPS ─────────────────────────────────────────────────────────────
$empresaObj = null;
$nitEmpresa = trim($meta['nit_empresa'] ?? '');
if ($nitEmpresa) {
$stm = db()->prepare(
"SELECT e.nit, e.nombre, e.razon_social, e.tarifa_id,
ti.nombre AS tarifa_nombre, e.descuento_pct,
e.tipo_usuario, e.req_autoriza, e.activa
FROM lab_empresas e
LEFT JOIN lab_tarifas_id ti ON ti.id = e.tarifa_id
WHERE e.nit = ? AND e.activa = 1
LIMIT 1"
);
$stm->execute([$nitEmpresa]);
$row = $stm->fetch(PDO::FETCH_ASSOC);
if ($row) {
$empresaObj = $row;
$empresaObj['activa'] = (bool)$empresaObj['activa'];
$empresaObj['req_autoriza'] = (bool)$empresaObj['req_autoriza'];
$empresaObj['descuento_pct'] = (float)$empresaObj['descuento_pct'];
}
}
jsonOk([
'encontrados' => $encontrados,
'no_mapeados' => $no_mapeados,
'total_rips' => count($examenes),
'recepcion_id' => $meta['recepcion_id'] ?? null,
'hora' => $meta['hora'] ?? null,
'fuente' => $fuenteCache ? 'cache' : 'rips',
'diagnostico_cod' => $meta['diagnostico_cod'] ?? null,
'diagnostico_nombre' => $meta['diagnostico_nombre'] ?? null,
'medico' => $medicoObj,
'empresa' => $empresaObj,
'valor_total' => isset($meta['valor_total']) ? (float)$meta['valor_total'] : null,
]);
+16
View File
@@ -0,0 +1,16 @@
<?php
/**
* GET ?user_id=X — Devuelve la firma_svg de un usuario (solo admins).
*/
require_once __DIR__ . '/_helpers.php';
requireAdmin();
$userId = (int)($_GET['user_id'] ?? 0);
if ($userId <= 0) jsonError('user_id requerido.');
$db = Database::getInstance()->getConnection();
$row = $db->prepare("SELECT firma_svg FROM admin_users WHERE id = ? LIMIT 1");
$row->execute([$userId]);
$data = $row->fetch(PDO::FETCH_ASSOC);
jsonOk(['firma_svg' => $data['firma_svg'] ?? null]);
+2
View File
@@ -21,6 +21,7 @@ $users = $db->fetchAll("
u.enfermera_id, u.enfermera_id,
u.last_login, u.last_login,
u.created_at, u.created_at,
(u.firma_svg IS NOT NULL) AS tiene_firma,
r.name AS role_name, r.name AS role_name,
r.color AS role_color, r.color AS role_color,
r.slug AS role_slug, r.slug AS role_slug,
@@ -33,6 +34,7 @@ $users = $db->fetchAll("
foreach ($users as &$u) { foreach ($users as &$u) {
$u['is_active'] = (bool)$u['is_active']; $u['is_active'] = (bool)$u['is_active'];
$u['tiene_firma'] = (bool)$u['tiene_firma'];
$u['role_name'] = $u['role_name'] ?? ucfirst($u['role'] ?? 'admin'); $u['role_name'] = $u['role_name'] ?? ucfirst($u['role'] ?? 'admin');
$u['role_color'] = $u['role_color'] ?? '#0d6efd'; $u['role_color'] = $u['role_color'] ?? '#0d6efd';
} }
+7 -1
View File
@@ -13,11 +13,17 @@ try {
jsonOk(['data' => [$row]]); jsonOk(['data' => [$row]]);
} }
if (isset($_GET['stats'])) {
jsonOk(['stats' => $pac->stats()]);
}
$busqueda = trim($_GET['busqueda'] ?? $_GET['search'] ?? ''); $busqueda = trim($_GET['busqueda'] ?? $_GET['search'] ?? '');
$pagina = max(1, (int)($_GET['page'] ?? $_GET['pagina'] ?? 1)); $pagina = max(1, (int)($_GET['page'] ?? $_GET['pagina'] ?? 1));
$por = max(1, min(100, (int)($_GET['limit'] ?? $_GET['por_pagina'] ?? 30))); $por = max(1, min(100, (int)($_GET['limit'] ?? $_GET['por_pagina'] ?? 30)));
$origen = in_array($_GET['origen'] ?? '', ['manual', 'lab', 'whatsapp'], true)
? $_GET['origen'] : '';
jsonOk($pac->listar($busqueda, $pagina, $por)); jsonOk($pac->listar($busqueda, $pagina, $por, $origen));
} catch (Exception $e) { } catch (Exception $e) {
jsonError($e->getMessage(), 500); jsonError($e->getMessage(), 500);
} }
+70
View File
@@ -0,0 +1,70 @@
<?php
/**
* POST /api/lab/ingest_diagnosticos.php
* Recibe un lote de diagnósticos CIE-10 desde RIPS Manager y los upserta en cie10_diagnosticos.
* Body: { "rows": [{"cod": "A000", "concepto": "..."}, ...] }
*/
ob_start();
require_once __DIR__ . '/../../config/config.php';
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', '0');
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
ob_clean();
echo json_encode(['ok' => false, 'error' => 'Método no permitido']);
exit;
}
$keyHeader = $_SERVER['HTTP_X_LAB_KEY'] ?? $_SERVER['HTTP_X_LAB_SYNC_KEY'] ?? '';
if (!$keyHeader || !hash_equals(LAB_SYNC_KEY, $keyHeader)) {
http_response_code(401);
ob_clean();
echo json_encode(['ok' => false, 'error' => 'API key inválida']);
exit;
}
function db(): \PDO {
return Database::getInstance()->getConnection();
}
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data) || empty($data['rows']) || !is_array($data['rows'])) {
http_response_code(400);
ob_clean();
echo json_encode(['ok' => false, 'error' => 'Body inválido: se espera {rows: [...]}']);
exit;
}
try {
$pdo = db();
$stmt = $pdo->prepare(
"INSERT INTO cie10_diagnosticos (cod_diag, concepto)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE concepto = VALUES(concepto)"
);
if (!$stmt) {
$info = $pdo->errorInfo();
throw new \RuntimeException("Prepare falló [{$info[0]}]: {$info[2]}");
}
$insertados = 0;
foreach ($data['rows'] as $row) {
$cod = trim($row['cod'] ?? '');
$concepto = trim($row['concepto'] ?? '');
if (!$cod || !$concepto) continue;
$stmt->execute([$cod, $concepto]);
$insertados++;
}
ob_clean();
echo json_encode(['ok' => true, 'insertados' => $insertados]);
} catch (\Throwable $e) {
ob_clean();
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
+235
View File
@@ -0,0 +1,235 @@
<?php
/**
* POST /api/lab/ingest_paciente.php
* Endpoint server-to-server para ingesta de pacientes desde RIPS Manager.
* Autenticación: header X-Lab-Key: <LAB_SYNC_KEY> (sin sesión de usuario).
*
* Body JSON (todos opcionales excepto nombre_completo en creación):
* { numero_documento, tipo_documento, nombre_completo, telefono, email,
* fecha_nacimiento, genero, direccion, ciudad, eps, origen }
*
* Respuesta:
* { ok, action: "created"|"updated"|"skipped", id, message }
*/
ob_start();
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../classes/lab/Paciente.php';
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', '0');
ini_set('html_errors', '0');
register_shutdown_function(function () {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
ob_clean();
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Error interno: ' . $err['message']]);
}
});
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Lab-Key, X-Lab-Sync-Key');
header('Access-Control-Max-Age: 86400');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'Método no permitido']);
exit;
}
// ── Autenticación por API key ────────────────────────────────────────────────
$keyHeader = $_SERVER['HTTP_X_LAB_KEY']
?? $_SERVER['HTTP_X_LAB_SYNC_KEY']
?? '';
if (!$keyHeader || !hash_equals(LAB_SYNC_KEY, $keyHeader)) {
http_response_code(401);
ob_clean();
echo json_encode(['ok' => false, 'error' => 'API key inválida']);
exit;
}
// ── Leer body ────────────────────────────────────────────────────────────────
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
if (!is_array($data)) {
http_response_code(400);
ob_clean();
echo json_encode(['ok' => false, 'error' => 'Body JSON inválido']);
exit;
}
// ── Normalizar campos ────────────────────────────────────────────────────────
$campos = [];
if (!empty($data['nombre_completo'])) {
$campos['nombre_completo'] = mb_strtoupper(trim($data['nombre_completo']));
}
if (!empty($data['numero_documento'])) {
$campos['numero_documento'] = trim($data['numero_documento']);
}
if (!empty($data['tipo_documento'])) {
$tiposValidos = ['CC', 'CE', 'TI', 'PA', 'NIT', 'RC', 'MS'];
$t = strtoupper(trim($data['tipo_documento']));
$campos['tipo_documento'] = in_array($t, $tiposValidos) ? $t : 'CC';
}
if (!empty($data['telefono'])) {
$tel = preg_replace('/[^0-9+]/', '', $data['telefono']);
if (strlen($tel) >= 6) {
$campos['telefono'] = $tel;
}
}
if (!empty($data['email']) && filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$e = strtolower(trim($data['email']));
// Ignorar emails de relleno generados por RIPS
if (!str_contains($e, '@sinregistro.co') && !str_contains($e, 'sinregistro')) {
$campos['email'] = $e;
}
}
if (!empty($data['fecha_nacimiento'])) {
$fn = trim($data['fecha_nacimiento']);
// Acepta dd/mm/yyyy o yyyy-mm-dd
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $fn, $m)) {
$campos['fecha_nacimiento'] = "{$m[3]}-{$m[2]}-{$m[1]}";
} elseif (preg_match('/^\d{4}-\d{2}-\d{2}$/', $fn)) {
$campos['fecha_nacimiento'] = $fn;
}
}
if (!empty($data['genero'])) {
$g = strtoupper(trim($data['genero']));
if (in_array($g, ['M', 'F', 'O'])) {
$campos['genero'] = $g;
}
}
if (!empty($data['direccion'])) {
$campos['direccion'] = trim($data['direccion']);
}
if (!empty($data['ciudad'])) {
$campos['ciudad'] = trim($data['ciudad']);
}
if (!empty($data['eps'])) {
$campos['eps'] = trim($data['eps']);
}
// origen solo se aplica en creación, nunca en update
$origenValidos = ['manual', 'lab', 'whatsapp'];
$campos['origen'] = in_array($data['origen'] ?? '', $origenValidos, true)
? $data['origen'] : 'lab';
// ── Modo: "insertar" (solo nuevos) | "upsert" (crea o actualiza) ─────────────
$modo = trim($data['modo'] ?? 'upsert');
if (!in_array($modo, ['insertar', 'upsert'], true)) {
$modo = 'upsert';
}
// ── Exámenes opcionales (vienen del scheduler de RIPS) ───────────────────────
$examenesRaw = $data['examenes'] ?? null;
$examenesGuardados = 0;
function db(): \PDO {
return Database::getInstance()->getConnection();
}
function guardarExamenesPendientes(string $doc, array $examenes): int {
if (!$examenes || !$doc) return 0;
$pdo = db();
$stDel = $pdo->prepare(
"DELETE FROM rips_examenes_pendientes
WHERE numero_documento = ? AND DATE(created_at) = CURDATE()"
);
if (!$stDel) {
$info = $pdo->errorInfo();
throw new \RuntimeException("rips_examenes_pendientes no existe o error: {$info[2]}");
}
$stDel->execute([$doc]);
$meta = $examenes[0] ?? [];
$stIns = $pdo->prepare(
"INSERT INTO rips_examenes_pendientes
(numero_documento, datos, recepcion_id, hora_recepcion)
VALUES (?, ?, ?, ?)"
);
if (!$stIns) {
$info = $pdo->errorInfo();
throw new \RuntimeException("INSERT rips_examenes_pendientes falló: {$info[2]}");
}
$stIns->execute([
$doc,
json_encode($examenes, JSON_UNESCAPED_UNICODE),
$meta['recepcion_id'] ?? null,
$meta['hora'] ?? null,
]);
return count($examenes);
}
// ── UPSERT / INSERT-ONLY ─────────────────────────────────────────────────────
try {
$pac = new Paciente();
$existente = null;
if (!empty($campos['numero_documento'])) {
$existente = $pac->porDocumento($campos['numero_documento']);
}
ob_clean();
if ($existente) {
if (!empty($examenesRaw) && is_array($examenesRaw)) {
$examenesGuardados = guardarExamenesPendientes($campos['numero_documento'], $examenesRaw);
}
if ($modo === 'insertar') {
echo json_encode([
'ok' => true,
'action' => 'skipped',
'id' => $existente['id'],
'message' => 'Paciente ya existe',
'examenes_guardados' => $examenesGuardados,
]);
} else {
// Nunca pisar origen en update — conservar el que ya tiene en BD
unset($campos['origen']);
$pac->actualizar($existente['id'], $campos, null);
echo json_encode([
'ok' => true,
'action' => 'updated',
'id' => $existente['id'],
'message' => 'Paciente actualizado',
'examenes_guardados' => $examenesGuardados,
]);
}
} elseif (!empty($campos['nombre_completo'])) {
$id = $pac->crear($campos, null);
if (!empty($examenesRaw) && is_array($examenesRaw)) {
$examenesGuardados = guardarExamenesPendientes($campos['numero_documento'], $examenesRaw);
}
echo json_encode([
'ok' => true,
'action' => 'created',
'id' => $id,
'message' => 'Paciente creado',
'examenes_guardados' => $examenesGuardados,
]);
} else {
http_response_code(422);
echo json_encode([
'ok' => false,
'action' => 'skipped',
'error' => 'Sin número de documento ni nombre: registro omitido',
]);
}
} catch (\Throwable $e) {
ob_clean();
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
+26
View File
@@ -0,0 +1,26 @@
<?php
/**
* POST /api/lab/marcar_rips_usado.php
* Marca el registro RIPS de una cédula como consumido por un turno.
* Body: { cedula, turno_id }
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$cedula = trim($body['cedula'] ?? '');
$turnoId = (int)($body['turno_id'] ?? 0);
if (!$cedula || !$turnoId) jsonError('cedula y turno_id requeridos', 400);
db()->prepare(
"UPDATE rips_examenes_pendientes
SET turno_id = ?
WHERE numero_documento = ?
AND DATE(created_at) = CURDATE()
AND turno_id IS NULL
ORDER BY created_at DESC
LIMIT 1"
)->execute([$turnoId, $cedula]);
jsonOk(['marcado' => true]);
+23 -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']);
@@ -91,6 +97,19 @@ try {
$eid = enfermeraId(); $eid = enfermeraId();
if ($eid) $datos['enfermera_id'] = $eid; if ($eid) $datos['enfermera_id'] = $eid;
} }
// Asignar número de orden D-YYYYMMDD-NNN si no viene uno
if (empty($datos['numero_orden'])) {
$pdo = db();
$prefix = 'D-' . date('Ymd') . '-';
$st = $pdo->prepare(
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden, '-', -1) AS UNSIGNED)) AS ultimo
FROM lab_domicilios WHERE numero_orden LIKE ?"
);
$st->execute([$prefix . '%']);
$ultimo = (int)($st->fetch(\PDO::FETCH_ASSOC)['ultimo'] ?? 0);
$datos['numero_orden'] = $prefix . str_pad($ultimo + 1, 3, '0', STR_PAD_LEFT);
}
$id = $dom->crear($datos, $admin); $id = $dom->crear($datos, $admin);
// Crear asignación automática si se indicó enfermera_id // Crear asignación automática si se indicó enfermera_id
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* POST — Admin guarda/borra la firma pre-configurada de cualquier usuario.
* Body JSON: { user_id: 5, firma_svg: "data:image/png;base64,..." }
* { user_id: 5, _borrar: true }
*/
require_once __DIR__ . '/_helpers.php';
requireAdmin();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$userId = (int)($body['user_id'] ?? 0);
if ($userId <= 0) jsonError('user_id requerido.');
$db = Database::getInstance()->getConnection();
if (!empty($body['_borrar'])) {
$db->prepare("UPDATE admin_users SET firma_svg = NULL WHERE id = ?")->execute([$userId]);
jsonOk(['mensaje' => 'Firma eliminada.']);
}
$svg = $body['firma_svg'] ?? '';
if (strlen($svg) < 100) jsonError('Firma requerida.');
if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
jsonError('Formato de firma no válido.');
}
$db->prepare("UPDATE admin_users SET firma_svg = ? WHERE id = ?")->execute([$svg, $userId]);
jsonOk(['mensaje' => 'Firma guardada correctamente.']);
+139
View File
@@ -0,0 +1,139 @@
<?php
/**
* POST /api/lab/save_tomas_config.php
* Gestiona los tipos de examen del formulario de Tomas Prolongadas (lab_formularios.id=15).
*
* Body JSON:
* action string 'add_exam' | 'delete_exam'
* exam_name string Nombre del nuevo tipo de examen
* tomas array [{label, tipo:'minutos'|'hora_fija', valor}] (solo add_exam)
*/
require_once __DIR__ . '/_helpers.php';
requireAdmin();
requireMethod('POST');
$body = inputJson();
$action = trim($body['action'] ?? '');
if (!in_array($action, ['add_exam', 'delete_exam'], true)) jsonError('action inválida.');
$db = Database::getInstance();
$row = $db->fetch("SELECT id, esquema FROM lab_formularios WHERE id = 15 LIMIT 1");
if (!$row) jsonError('Formulario de tomas no encontrado.', 404);
$esquema = json_decode($row['esquema'], true);
if (!is_array($esquema)) jsonError('Esquema del formulario no válido.', 500);
// ── Localizar el campo selector de tipo de examen ────────────
$idxSelector = null;
foreach ($esquema as $i => $c) {
if (($c['id'] ?? '') === '_c8j2g16') { $idxSelector = $i; break; }
}
if ($idxSelector === null) jsonError('Campo selector de examen (_c8j2g16) no encontrado.', 500);
// ── ADD EXAM ─────────────────────────────────────────────────
if ($action === 'add_exam') {
$examName = trim($body['exam_name'] ?? '');
$tomas = $body['tomas'] ?? [];
if (!$examName) jsonError('exam_name requerido.');
if (strlen($examName) > 80) jsonError('exam_name demasiado largo (máx 80 chars).');
if (!is_array($tomas) || empty($tomas)) jsonError('tomas requeridas.');
if (count($tomas) > 20) jsonError('Máximo 20 tomas por examen.');
// Verificar que el examen no exista ya
$currentOptions = $esquema[$idxSelector]['options'] ?? [];
if (in_array($examName, $currentOptions, true)) {
jsonError("El tipo de examen '$examName' ya existe.");
}
// Validar tomas
foreach ($tomas as $i => $t) {
$tipo = $t['tipo'] ?? '';
$valor = $t['valor'] ?? '';
$label = trim($t['label'] ?? '');
if (!in_array($tipo, ['minutos', 'hora_fija'], true)) jsonError("Toma $i: tipo inválido.");
if (!$label) jsonError("Toma $i: label requerido.");
if ($tipo === 'minutos' && (!is_numeric($valor) || (int)$valor < 0))
jsonError("Toma $i: valor de minutos inválido.");
if ($tipo === 'hora_fija' && !preg_match('/^\d{1,2}:\d{2}$/', $valor))
jsonError("Toma $i: hora_fija debe ser HH:MM.");
}
// Prefijo corto para IDs (basado en nombre del examen, sanitizado)
$prefix = '_' . substr(preg_replace('/[^a-z0-9]/i', '', strtolower($examName)), 0, 8) . '_';
$uid = substr(md5($examName . microtime()), 0, 4);
// 1. Agregar opción al campo selector
$esquema[$idxSelector]['options'][] = $examName;
// 2. Agregar campos al final del esquema
$condCampoId = '_c8j2g16';
foreach ($tomas as $idx => $t) {
$label = trim($t['label']);
$tipo = $t['tipo'];
$valor = $t['valor'];
// Construir label del separador
if ($tipo === 'minutos') {
$sepLabel = "$examName · Minuto $valor";
} else {
// hora_fija: convertir HH:MM a "H:MM a.m./p.m."
[$hh, $mm] = explode(':', $valor);
$h = (int)$hh; $ampm = $h >= 12 ? 'p.m.' : 'a.m.';
$h12 = $h > 12 ? $h - 12 : ($h === 0 ? 12 : $h);
$sepLabel = "$examName · {$h12}:{$mm} {$ampm}";
}
$sepId = $prefix . 's' . $idx . $uid;
$horaId = $prefix . 'h' . $idx . $uid;
$obsId = $prefix . 'o' . $idx . $uid;
$firmaId = $prefix . 'f' . $idx . $uid;
$esquema[] = [
'id' => $sepId,
'tipo' => 'separador',
'label' => $sepLabel,
'condicion'=> ['campo_id' => $condCampoId, 'valores' => [$examName]],
];
$esquema[] = ['id' => $horaId, 'tipo' => 'hora', 'label' => 'Hora de toma'];
$esquema[] = ['id' => $obsId, 'tipo' => 'texto', 'label' => 'Observaciones', 'placeholder' => '', 'required' => false];
$esquema[] = ['id' => $firmaId, 'tipo' => 'firma_profesional', 'label' => 'Firma del profesional'];
}
$db->getConnection()->prepare(
"UPDATE lab_formularios SET esquema = ? WHERE id = 15"
)->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
jsonOk([
'exam_name' => $examName,
'tomas_count' => count($tomas),
'options_count'=> count($esquema[$idxSelector]['options']),
], "Examen '$examName' agregado con " . count($tomas) . " tomas.");
}
// ── DELETE EXAM ──────────────────────────────────────────────
if ($action === 'delete_exam') {
$examName = trim($body['exam_name'] ?? '');
if (!$examName) jsonError('exam_name requerido.');
$options = $esquema[$idxSelector]['options'] ?? [];
if (!in_array($examName, $options, true)) jsonError("El examen '$examName' no existe.");
// Eliminar opción del selector
$esquema[$idxSelector]['options'] = array_values(array_filter($options, fn($o) => $o !== $examName));
// Eliminar separadores condicionados únicamente a este examen
$esquema = array_values(array_filter($esquema, function($c) use ($examName) {
$cond = $c['condicion'] ?? null;
if (!$cond) return true;
$vals = $cond['valores'] ?? [];
return !(count($vals) === 1 && $vals[0] === $examName);
}));
$db->getConnection()->prepare(
"UPDATE lab_formularios SET esquema = ? WHERE id = 15"
)->execute([json_encode($esquema, JSON_UNESCAPED_UNICODE)]);
jsonOk(['exam_name' => $examName], "Examen '$examName' eliminado.");
}
+71
View File
@@ -0,0 +1,71 @@
<?php
/**
* /api/lab/tarifas_id.php
*
* GET → lista todas las tarifas (para selects en formularios)
* POST {action:save, id?, nombre, porcentaje, tarifa_origen?} → upsert
* POST {action:delete, id} → elimina si sin precios asociados
*/
require_once __DIR__ . '/_helpers.php';
$method = $_SERVER['REQUEST_METHOD'];
$pdo = db();
if ($method === 'GET') {
$rows = $pdo->query(
"SELECT t.id, t.nombre, t.porcentaje, t.tarifa_origen,
tb.nombre AS tarifa_origen_nombre
FROM lab_tarifas_id t
LEFT JOIN lab_tarifas_id tb ON tb.id = t.tarifa_origen
ORDER BY t.id ASC"
)->fetchAll(PDO::FETCH_ASSOC);
jsonOk(['tarifas' => $rows]);
}
requireMethod('POST');
$data = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $data['action'] ?? '';
if ($action === 'delete') {
$id = (int)($data['id'] ?? 0);
if ($id <= 0) jsonError('id requerido');
$chk = $pdo->prepare("SELECT COUNT(*) FROM lab_tarifas WHERE tarifa_id = ? LIMIT 1");
$chk->execute([$id]);
if ((int)$chk->fetchColumn() > 0) {
jsonError('No se puede eliminar: la tarifa tiene precios de exámenes asociados.', 409);
}
$pdo->prepare("DELETE FROM lab_tarifas_id WHERE id = ?")->execute([$id]);
jsonOk(['deleted' => true]);
}
if ($action === 'save') {
$id = isset($data['id']) && $data['id'] !== '' ? (int)$data['id'] : null;
$nombre = trim($data['nombre'] ?? '');
$porcentaje = isset($data['porcentaje']) ? (float)$data['porcentaje'] : 0;
$origen = isset($data['tarifa_origen']) && $data['tarifa_origen'] !== '' ? (int)$data['tarifa_origen'] : null;
if ($nombre === '') jsonError('nombre requerido');
if ($id !== null) {
$chk = $pdo->prepare("SELECT id FROM lab_tarifas_id WHERE id = ?");
$chk->execute([$id]);
if ($chk->fetch()) {
$pdo->prepare(
"UPDATE lab_tarifas_id SET nombre=?, porcentaje=?, tarifa_origen=? WHERE id=?"
)->execute([$nombre, $porcentaje, $origen, $id]);
} else {
$pdo->prepare(
"INSERT INTO lab_tarifas_id (id, nombre, porcentaje, tarifa_origen) VALUES (?,?,?,?)"
)->execute([$id, $nombre, $porcentaje, $origen]);
}
} else {
$maxId = (int)$pdo->query("SELECT COALESCE(MAX(id),0)+1 FROM lab_tarifas_id")->fetchColumn();
$pdo->prepare(
"INSERT INTO lab_tarifas_id (id, nombre, porcentaje, tarifa_origen) VALUES (?,?,?,?)"
)->execute([$maxId, $nombre, $porcentaje, $origen]);
$id = $maxId;
}
jsonOk(['id' => $id]);
}
jsonError('Acción no reconocida', 400);
+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'] ?? '']);
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* POST /api/qz_sign.php
* Firma el request de QZ Tray con la llave privada RSA del servidor.
* QZ Tray verifica la firma usando el digital-certificate.txt instalado en el cliente.
*/
header('Content-Type: text/plain');
header('Access-Control-Allow-Origin: *');
$request = file_get_contents('php://input');
if (!$request) { http_response_code(400); exit('missing request'); }
$keyPath = __DIR__ . '/../config/qz/private-key.pem';
$key = openssl_pkey_get_private('file://' . $keyPath);
if (!$key) { http_response_code(500); exit('key error'); }
openssl_sign($request, $signature, $key, 'SHA512');
echo base64_encode($signature);
+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",
+114 -119
View File
@@ -935,32 +935,6 @@ body {
#sidebar-toggle { display: none !important; } #sidebar-toggle { display: none !important; }
} }
/* Dark Mode Support */
@media (prefers-color-scheme: dark) {
:root {
--light-bg: #1f2937;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--border-color: #374151;
}
body {
background-color: var(--dark-bg);
color: var(--text-primary);
}
.card {
background: #374151;
}
.card-header {
background: #4b5563;
}
.table th {
background: #4b5563;
}
}
/* Utility Classes */ /* Utility Classes */
.text-center { text-align: center; } .text-center { text-align: center; }
@@ -1483,104 +1457,125 @@ body {
border: 1px solid rgba(255, 255, 255, 0.5); border: 1px solid rgba(255, 255, 255, 0.5);
} }
/* ===== Ajustes para modo nocturno (dark mode) ===== */ /* ─────────────────────────────────────────────────────────────────────────
MODO OSCURO
Activación dual:
1) @media (prefers-color-scheme: dark) — OS sin JS
2) [data-bs-theme="dark"] en <html> — Bootstrap 5.3 via script inline
Bootstrap 5.3 maneja automáticamente (cuando data-bs-theme está activo):
card, modal, table, form-control, form-select, dropdown, badge, alert,
list-group, nav, pagination, offcanvas, toast, popover, etc.
Aquí solo cubrimos variables custom y elementos no-Bootstrap.
───────────────────────────────────────────────────────────────────────── */
/* ── Variables custom para elementos no-Bootstrap ── */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--dm-body: #0a1628;
--dm-surf: #071322;
--dm-surf2: #0b1c32;
--dm-bdr: #163240;
--dm-txt: #e6eef1;
--dm-muted: #9ca3af;
--dm-link: #a6f3c9;
/* Alias compatibilidad con código que usa estas vars */
--light-bg: #071322; --light-bg: #071322;
--text-primary: #e6eef1; --text-primary: #e6eef1;
--text-secondary: #9ca3af; --text-secondary: #9ca3af;
--border-color: #163240; --border-color: #163240;
} }
body {
background-color: var(--light-bg);
color: var(--text-primary);
}
/* Encabezados y títulos */
.content-header h1,
.card-header h5,
.sidebar-header h4,
.status-text,
.status-indicator,
.conversation-empty {
color: var(--text-primary) !important;
}
/* Contenedores */
.content-header,
.card,
.chat-container {
background: #071322;
color: var(--text-primary);
border-color: var(--border-color);
box-shadow: none;
}
.card-header {
background: transparent;
border-bottom-color: rgba(255,255,255,0.04);
}
/* Formularios y botones */
.form-control {
background: #072033;
color: var(--text-primary);
border-color: #163240;
}
.btn {
color: inherit;
}
/* Sidebar */
.sidebar {
background: linear-gradient(135deg,#08131a,#0b2221);
color: #e6eef1;
}
.conversation-actions .btn {
background: #0b1220;
border-color: #1f2937;
color: var(--text-secondary);
}
/* Chat específico */
.chat-header {
background: linear-gradient(135deg, #1f5a3f 0%, #0f513e 100%);
color: #fff;
}
.message-bubble {
color: #e6eef1;
}
.message-incoming {
background: #0b1220;
color: #e6eef1;
box-shadow: none;
}
.message-outgoing {
background: #0f513e;
color: #fff;
}
.message-document,
.media-preview {
background: rgba(255,255,255,0.03);
color: var(--text-primary);
}
.conversation-search {
background: rgba(255,255,255,0.04);
color: var(--text-primary);
}
a, .nav-link {
color: #a6f3c9;
}
/* Placeholders visibles */
::placeholder { color: #9ca3af !important; opacity: 1; }
} }
[data-bs-theme="dark"] {
--dm-body: #0a1628;
--dm-surf: #071322;
--dm-surf2: #0b1c32;
--dm-bdr: #163240;
--dm-txt: #e6eef1;
--dm-muted: #9ca3af;
--dm-link: #a6f3c9;
--light-bg: #071322;
--text-primary: #e6eef1;
--text-secondary: #9ca3af;
--border-color: #163240;
}
/* ── Reglas para modo OS sin JS (fallback) ── */
@media (prefers-color-scheme: dark) {
body { background-color: var(--dm-body); color: var(--dm-txt); }
/* Fondo principal */
.main-content { background-color: var(--dm-body) !important; }
/* Bootstrap no cambia bg-white — forzamos adaptación */
.bg-white { background-color: var(--dm-surf) !important; color: var(--dm-txt) !important; }
/* Encabezados y texto */
.content-header { background: var(--dm-surf); border-color: var(--dm-bdr) !important; color: var(--dm-txt); }
.content-header h1, .card-header h5, .sidebar-header h4,
.status-text, .status-indicator, .conversation-empty { color: var(--dm-txt) !important; }
/* Componentes Bootstrap (fallback sin data-bs-theme) */
.card { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); box-shadow: none; }
.card-header { background: transparent; border-color: rgba(255,255,255,.06); }
.form-control, .form-select {
background: var(--dm-surf2);
color: var(--dm-txt);
border-color: var(--dm-bdr);
}
.form-control:focus, .form-select:focus {
background: var(--dm-surf2);
color: var(--dm-txt);
border-color: #2d6a8a;
box-shadow: 0 0 0 .2rem rgba(45,106,138,.25);
}
.table { color: var(--dm-txt); border-color: var(--dm-bdr); }
.table th { background: var(--dm-surf2); color: var(--dm-txt); }
.modal-content { background: var(--dm-surf); color: var(--dm-txt); }
.modal-header, .modal-footer { border-color: var(--dm-bdr); }
.dropdown-menu { background: var(--dm-surf); border-color: var(--dm-bdr); }
.dropdown-item { color: var(--dm-txt); }
.dropdown-item:hover { background: var(--dm-surf2); color: var(--dm-txt); }
.list-group-item { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); }
.input-group-text { background: var(--dm-surf2); color: var(--dm-txt); border-color: var(--dm-bdr); }
.nav-tabs .nav-link { color: var(--dm-muted); }
.nav-tabs .nav-link.active { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); }
/* Sidebar custom */
.sidebar { background: linear-gradient(135deg, #08131a, #0b2221); color: #e6eef1; }
/* Chat */
.chat-container { background: var(--dm-surf); color: var(--dm-txt); border-color: var(--dm-bdr); }
.chat-header { background: linear-gradient(135deg, #1f5a3f, #0f513e); color: #fff; }
.message-bubble { color: var(--dm-txt); }
.message-incoming { background: var(--dm-surf2); color: var(--dm-txt); box-shadow: none; }
.message-outgoing { background: #0f513e; color: #fff; }
.message-document, .media-preview { background: rgba(255,255,255,.03); color: var(--dm-txt); }
.conversation-search { background: rgba(255,255,255,.04); color: var(--dm-txt); }
.conversation-actions .btn { background: #0b1220; border-color: #1f2937; color: var(--dm-muted); }
/* Links */
a:not(.btn):not(.badge), .nav-link { color: var(--dm-link); }
/* Placeholders */
::placeholder { color: var(--dm-muted) !important; opacity: 1; }
}
/* ── Reglas para Bootstrap dark mode activo (data-bs-theme="dark") ── */
/* Bootstrap ya maneja card, modal, table, form, dropdown, badge, alert, etc. */
/* Solo corregimos lo que Bootstrap no toca: elementos custom y clases forzadas */
[data-bs-theme="dark"] .main-content { background-color: var(--dm-body, #0a1628) !important; }
[data-bs-theme="dark"] .bg-white { background-color: var(--bs-body-bg) !important; color: var(--bs-body-color) !important; }
[data-bs-theme="dark"] .sidebar { background: linear-gradient(135deg, #08131a, #0b2221) !important; color: #e6eef1; }
[data-bs-theme="dark"] .chat-container { background: var(--dm-surf, #071322); border-color: var(--dm-bdr, #163240); }
[data-bs-theme="dark"] .chat-header { background: linear-gradient(135deg, #1f5a3f, #0f513e); color: #fff; }
[data-bs-theme="dark"] .message-bubble { color: var(--dm-txt, #e6eef1); }
[data-bs-theme="dark"] .message-incoming { background: var(--dm-surf2, #0b1c32); color: var(--dm-txt, #e6eef1); box-shadow: none; }
[data-bs-theme="dark"] .message-outgoing { background: #0f513e; color: #fff; }
[data-bs-theme="dark"] .message-document,
[data-bs-theme="dark"] .media-preview { background: rgba(255,255,255,.03); color: var(--dm-txt, #e6eef1); }
[data-bs-theme="dark"] .conversation-search { background: rgba(255,255,255,.04); color: var(--dm-txt, #e6eef1); }
[data-bs-theme="dark"] .conversation-actions .btn { background: #0b1220; border-color: #1f2937; color: var(--dm-muted, #9ca3af); }
[data-bs-theme="dark"] a:not(.btn):not(.badge) { color: var(--dm-link, #a6f3c9); }
[data-bs-theme="dark"] ::placeholder { color: var(--dm-muted, #9ca3af) !important; opacity: 1; }
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
/**
* qz-print.js — Impresión silenciosa vía QZ Tray
* Requiere qz-tray.js cargado antes de este archivo.
* Uso: qzPrint({ url, printer })
*/
window.qzPrint = async function({ printer } = {}) {
try {
// 1. Conectar
if (!qz.websocket.isActive()) {
await qz.websocket.connect();
}
// 2. Firma con el servidor
qz.security.setSignatureAlgorithm('SHA512');
qz.security.setSignaturePromise(function(toSign) {
return function(resolve, reject) {
fetch('/api/qz_sign.php', { method: 'POST', body: toSign })
.then(r => r.text()).then(resolve).catch(reject);
};
});
// 3. Certificado público
qz.security.setCertificatePromise(function(resolve) {
fetch('/config/qz/digital-certificate.txt')
.then(r => r.text()).then(resolve);
});
// 4. Seleccionar impresora
var p = printer || await qz.printers.getDefault();
console.log('[QZ] Impresora:', p);
// 5. Imprimir la página actual como HTML
var cfg = qz.configs.create(p);
var data = [{
type : 'pixel',
format: 'html',
flavor: 'plain',
data : document.documentElement.outerHTML,
}];
await qz.print(cfg, data);
console.log('[QZ] Trabajo enviado correctamente');
} catch(e) {
console.warn('[QZ] Error:', e.message || e);
// Fallback al diálogo del navegador si QZ no está disponible
window.print();
}
};
+1 -1
View File
@@ -364,7 +364,7 @@ class Domicilio {
private function filtrarCampos(array $datos): array { private function filtrarCampos(array $datos): array {
$permitidos = [ $permitidos = [
'orden_id', 'paciente_id', 'direccion', 'ciudad', 'barrio', 'orden_id', 'paciente_id', 'numero_orden', 'direccion', 'ciudad', 'barrio',
'indicaciones_dir', 'fecha_programada', 'hora_programada', 'indicaciones_dir', 'fecha_programada', 'hora_programada',
'tipo_servicio', 'tipo_cliente', 'examenes_solicitados', 'tipo_servicio', 'tipo_cliente', 'examenes_solicitados',
'estado', 'motivo_cancelacion', 'estado', 'motivo_cancelacion',
+7 -5
View File
@@ -23,8 +23,10 @@ class Formulario {
$w = $soloActivos ? 'WHERE f.is_active = 1' : ''; $w = $soloActivos ? 'WHERE f.is_active = 1' : '';
return $this->db->fetchAll(" return $this->db->fetchAll("
SELECT f.*, u.full_name AS creado_por_nombre, SELECT f.*, u.full_name AS creado_por_nombre,
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id) AS total_envios, (SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id) +
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id AND e.estado IN ('completado','firmado')) AS total_completados (SELECT COUNT(*) FROM turnero_consentimientos tc WHERE tc.formulario_id = f.id) AS total_envios,
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id AND e.estado IN ('completado','firmado')) +
(SELECT COUNT(*) FROM turnero_consentimientos tc WHERE tc.formulario_id = f.id AND tc.estado = 'firmado') AS total_completados
FROM lab_formularios f FROM lab_formularios f
LEFT JOIN admin_users u ON u.id = f.creado_por LEFT JOIN admin_users u ON u.id = f.creado_por
$w $w
@@ -214,13 +216,13 @@ class Formulario {
$whereB[] = '1=0'; // estados como 'completado'/'expirado' no aplican al turnero $whereB[] = '1=0'; // estados como 'completado'/'expirado' no aplican al turnero
} }
} }
if (!empty($filtros['fecha_desde'])) { $whereB[] = 'DATE(tc.enviado_at) >= ?'; $paramsB[] = $filtros['fecha_desde']; } if (!empty($filtros['fecha_desde'])) { $whereB[] = 'DATE(COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at)) >= ?'; $paramsB[] = $filtros['fecha_desde']; }
if (!empty($filtros['fecha_hasta'])) { $whereB[] = 'DATE(tc.enviado_at) <= ?'; $paramsB[] = $filtros['fecha_hasta']; } if (!empty($filtros['fecha_hasta'])) { $whereB[] = 'DATE(COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at)) <= ?'; $paramsB[] = $filtros['fecha_hasta']; }
if (!empty($filtros['paciente'])) { $whereB[] = 'p.nombre_completo LIKE ?'; $paramsB[] = '%' . $filtros['paciente'] . '%'; } if (!empty($filtros['paciente'])) { $whereB[] = 'p.nombre_completo LIKE ?'; $paramsB[] = '%' . $filtros['paciente'] . '%'; }
if (!empty($filtros['enviado_por'])) { $whereB[] = '1=0'; } // turnero no tiene enviado_por if (!empty($filtros['enviado_por'])) { $whereB[] = '1=0'; } // turnero no tiene enviado_por
$wB = implode(' AND ', $whereB); $wB = implode(' AND ', $whereB);
$sqlB = "SELECT tc.id, tc.formulario_id, tc.estado, COALESCE(tc.enviado_at, tc.firmado_at) AS fecha, $sqlB = "SELECT tc.id, tc.formulario_id, tc.estado, COALESCE(tc.firmado_at, tc.enviado_at, t.creado_at) AS fecha,
f.nombre AS form_nombre, f.categoria, f.nombre AS form_nombre, f.categoria,
p.nombre_completo AS paciente_nombre, p.nombre_completo AS paciente_nombre,
'Turnero' AS enviado_por_nombre, 'Turnero' AS enviado_por_nombre,
+65 -15
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;
@@ -27,20 +36,25 @@ class Paciente {
public function listar( public function listar(
string $busqueda = '', string $busqueda = '',
int $pagina = 1, int $pagina = 1,
int $porPagina = 30 int $porPagina = 30,
string $origen = ''
): array { ): array {
$offset = ($pagina - 1) * $porPagina; $offset = ($pagina - 1) * $porPagina;
$like = "%$busqueda%"; $like = "%$busqueda%";
$params = $busqueda
? [$like, $like, $like, $like]
: [];
$where = $busqueda $conditions = [];
? "WHERE p.nombre_completo LIKE ? $params = [];
OR p.numero_documento LIKE ?
OR p.telefono LIKE ? if ($busqueda) {
OR p.email LIKE ?" $conditions[] = "(p.nombre_completo LIKE ? OR p.numero_documento LIKE ? OR p.telefono LIKE ? OR p.email LIKE ?)";
: ''; $params = [$like, $like, $like, $like];
}
if ($origen) {
$conditions[] = "p.origen = ?";
$params[] = $origen;
}
$where = $conditions ? 'WHERE ' . implode(' AND ', $conditions) : '';
$total = $this->db->fetch( $total = $this->db->fetch(
"SELECT COUNT(*) AS n FROM lab_pacientes p $where", "SELECT COUNT(*) AS n FROM lab_pacientes p $where",
@@ -56,7 +70,7 @@ class Paciente {
FROM lab_pacientes p FROM lab_pacientes p
LEFT JOIN users u ON u.id = p.user_id LEFT JOIN users u ON u.id = p.user_id
$where $where
ORDER BY p.nombre_completo ASC ORDER BY p.created_at DESC, p.nombre_completo ASC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
", array_merge($params, [$porPagina, $offset])); ", array_merge($params, [$porPagina, $offset]));
@@ -69,6 +83,19 @@ class Paciente {
]; ];
} }
public function stats(): array {
return $this->db->fetch("
SELECT
COUNT(*) AS total,
SUM(origen = 'lab') AS importados,
SUM(origen = 'manual') AS manuales,
SUM(origen = 'whatsapp') AS whatsapp,
SUM(user_id IS NOT NULL) AS con_wa
FROM lab_pacientes
WHERE is_active = 1
") ?: ['total' => 0, 'importados' => 0, 'manuales' => 0, 'whatsapp' => 0, 'con_wa' => 0];
}
/** /**
* Un paciente por ID (con datos del usuario WhatsApp). * Un paciente por ID (con datos del usuario WhatsApp).
*/ */
@@ -155,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;
} }
@@ -226,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,
]); ]);
} }
@@ -238,7 +288,7 @@ class Paciente {
'user_id', 'numero_documento', 'tipo_documento', 'user_id', 'numero_documento', 'tipo_documento',
'nombre_completo', 'telefono', 'email', 'nombre_completo', 'telefono', 'email',
'fecha_nacimiento', 'genero', 'direccion', 'fecha_nacimiento', 'genero', 'direccion',
'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active', 'ciudad', 'barrio', 'eps', 'notas_admin', 'is_active', 'origen',
]; ];
$campos = array_intersect_key($datos, array_flip($permitidos)); $campos = array_intersect_key($datos, array_flip($permitidos));
if (!empty($campos['telefono'])) { if (!empty($campos['telefono'])) {
+28
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
*/ */
@@ -402,6 +421,14 @@ if (!defined('BASE_URL')) {
if (!defined('MIGRATION_TOKEN')) { if (!defined('MIGRATION_TOKEN')) {
define('MIGRATION_TOKEN', 'lab2026migrate'); define('MIGRATION_TOKEN', 'lab2026migrate');
} }
// API key server-to-server para ingesta de pacientes desde RIPS Manager
if (!defined('LAB_SYNC_KEY')) {
define('LAB_SYNC_KEY', getenv('LAB_SYNC_KEY') ?: 'rips-lab-sync-2026');
}
// URL base del RIPS Manager (para consultas server-to-server)
if (!defined('RIPS_MANAGER_URL')) {
define('RIPS_MANAGER_URL', getenv('RIPS_MANAGER_URL') ?: '');
}
define('TIMEZONE', 'America/Bogota'); define('TIMEZONE', 'America/Bogota');
// Información del desarrollador // Información del desarrollador
@@ -544,6 +571,7 @@ function authenticateUser($username, $password) {
'role_id' => $admin['role_id'] ?? null, 'role_id' => $admin['role_id'] ?? null,
'home_page' => $homePage, 'home_page' => $homePage,
'enfermera_id' => $admin['enfermera_id'] ?? null, 'enfermera_id' => $admin['enfermera_id'] ?? null,
'turnero_lugar_id' => $admin['turnero_lugar_id'] ?? null,
'modules' => $modules, 'modules' => $modules,
'module_permissions' => $modulePermissions, 'module_permissions' => $modulePermissions,
]; ];
+5
View File
@@ -0,0 +1,5 @@
# Bloquear acceso web a la llave privada
<Files "private-key.pem">
Order deny,allow
Deny from all
</Files>
+24
View File
@@ -0,0 +1,24 @@
-----BEGIN CERTIFICATE-----
MIID+TCCAuGgAwIBAgIULkHXGPyy0w0ieUoN/YOZOO5MPvkwDQYJKoZIhvcNAQEL
BQAwgYsxCzAJBgNVBAYTAkNPMRswGQYDVQQIDBJOb3J0ZSBkZSBTYW50YW5kZXIx
DzANBgNVBAcMBkN1Y3V0YTEjMCEGA1UECgwaTGFib3JhdG9yaW8gWGltZW5hIENh
aWNlZG8xKTAnBgNVBAMMIGVycC5sYWJvcmF0b3Jpb3hpbWVuYWNhaWNlZG8uY29t
MB4XDTI2MDcyMTE5NTEzNloXDTM2MDcxODE5NTEzNlowgYsxCzAJBgNVBAYTAkNP
MRswGQYDVQQIDBJOb3J0ZSBkZSBTYW50YW5kZXIxDzANBgNVBAcMBkN1Y3V0YTEj
MCEGA1UECgwaTGFib3JhdG9yaW8gWGltZW5hIENhaWNlZG8xKTAnBgNVBAMMIGVy
cC5sYWJvcmF0b3Jpb3hpbWVuYWNhaWNlZG8uY29tMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAjbzIjSUEj6qjwHvg+qNAEk0rAjfezbpyGPGkbH1AFnmI
8rz2TZKAN5PWJSvjYS91YEtnfFttEDa0wQpvHOIPhn2l8gAsYNFoDS+i1ilXHBEA
OtAB7uVL2TvrZBTQtcab4upI1ehUjdqA9ae51KBEfqOD3k7dc5X8IeskBDMLfI5G
cuJejGpx6S8aFur/PfZfu3oApHW02b4GXqSguUHwBJZtGauFQWRrN/6VG3YVKXON
g8bDEyFgPCZ2Slp4pHoLK5BIjBkF1t3PUcSq5rRZCxjYzoOcDIfoVV5f4Y+QSspM
u3tI+cMAnqwrKma/TBmSSikTMuK9nb+qjVvlQ/kijwIDAQABo1MwUTAdBgNVHQ4E
FgQUVnCTxhnONKLUYtxlT4SsIPH5rEgwHwYDVR0jBBgwFoAUVnCTxhnONKLUYtxl
T4SsIPH5rEgwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAEL/L
PP8b2UQavVLh3r0D3Is0V7JrVTRsT/iB2FwJLhudHAojVBinh7+tvZboEFkJ5trR
bNG3XxXDprw76awc/BsR19YXWrjZfraROrVNYEVTW1+Mit2HYRuTiEnVxOX1EENR
dAwZrwtFmpy3I2sZNblxQm0cZbDq6g4x/LBTwUXmXQK259Nrv5SEjs8wixeDMDNC
wuA9vAamsw+38nqHCouNtE+yPwM7srinFkfilMnIB2d874eqaVBvEKWmUtBCsWaO
f5OviyodKH9BtF+ACpgU2E/at03VratR5REIjje52qsWOMY1Wnn8FIYsCWJIPQYu
m56yKz4rQLPX/kyeCQ==
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCNvMiNJQSPqqPA
e+D6o0ASTSsCN97NunIY8aRsfUAWeYjyvPZNkoA3k9YlK+NhL3VgS2d8W20QNrTB
Cm8c4g+GfaXyACxg0WgNL6LWKVccEQA60AHu5UvZO+tkFNC1xpvi6kjV6FSN2oD1
p7nUoER+o4PeTt1zlfwh6yQEMwt8jkZy4l6ManHpLxoW6v899l+7egCkdbTZvgZe
pKC5QfAElm0Zq4VBZGs3/pUbdhUpc42DxsMTIWA8JnZKWnikegsrkEiMGQXW3c9R
xKrmtFkLGNjOg5wMh+hVXl/hj5BKyky7e0j5wwCerCsqZr9MGZJKKRMy4r2dv6qN
W+VD+SKPAgMBAAECggEAPqedtfsPrZx+f6ejN9hzicOQCA5/kMzjBBDJoOWrL2Qx
PDB45qikwiy5ZLwmav8qMVOT3v6hUyIDvDPrE0cBGvAvK6+U7oWTLAULRAWJStBf
HCB4Qk0dPt3Ee/zRmBFANspfQSPPQNe+2xj2Rj5EmQCaWerd7Or3xlymEq8n3Dp2
E9pFn4wiEDdjXG69/fqu/LoFpz31E2Mp0q+V4ZdWvMXA4U6akH2/oGWCnjDdhm/O
Wy1shQL5oVxdw/yhHhaC/3GZv/MKVbaW7JeBtnt3S5Szktr6A1O2obSJS+KOw9d+
Y2rqIMAWXv+kHKzAB2LtfczpfkEDkqQ7jCbu+sjX4QKBgQC+3uwfwM9mlxoCt93E
cerMyfrbssTjZjZSsxnHsGz+qmDr8TIyOrwcZnnYs6MNkuTJW6MvgN8hM8oO2arh
E3s0oWrbILD42rzVQUeLr7/N/JjUwK7yyEnfZX/hnQdilRsT+rxpXBIA0HfUheih
6KkddNoOyvs+wu1eOralO117LwKBgQC+Ge4ch1hicRchwX/Bu+h3cdkLVVgdI08+
O7vnSCuhj8Ead7z3C5XjEqJOs75xY+LF4UxPsrqTuCxpWIOupCSnRe+RGMcJdzbR
0i/leyL9fWhvHzP13R4T6sqAHqIXKgCfR7Hx99TW3Whc4N2GoqAnIrjqow+H+ic8
8Hrr+Ul2oQKBgQC1AyRbWLdYS6RXP5gJXR+X51UIVZlzLtQFyeSBBEfZnCselzdL
e3g6VtTnNjVEAjMG4uj3e/gfvMW7H6J2ocsONqbn+TDcUFUUyTvYtWvpJcyqt7Ey
fc/RFKkahZkjXNS5NejI4pAQRaPe4L+mDMeVL+Q8czOiaapC2tusB4i38QKBgCYh
G1Jbj03HcyVRI2ffYcQ7cJZGWvMVNvq7jnfYUPAJ3miJpbxDdZ/jB+0TPlqN91lL
VDwUFDo20ambmGX6BGQMsf1/Y8SxRayWJQc5SI5hjgXj0084N6U1DcLe4hIVWaSZ
A8cNt4IVTK58Z9JuYgMXgtGFPUM/2Ijvjygvix2hAoGBALSgjUzho5Jn/vAQza/M
k4q24RZ0kBJ1Go+/b3iSOieCCVKgow9224r/frcrFvXWzbNjhKs/HAV1bB9uCrxD
/V/1byH0XLYmYIKvhTqpTtBLCYXrv+2m+ox8pHrgPze4KuJy3VdN/V/Dj3CpY0VL
G5Uf9TfOiMk4EiZw/pBR1YT9
-----END PRIVATE KEY-----
+90
View File
@@ -44,6 +44,30 @@ class App
header('Location: ' . APP_ROOT . '/../enfermero_portal.php'); header('Location: ' . APP_ROOT . '/../enfermero_portal.php');
exit; exit;
} }
// Recepcionistas con IP registrada: solo pueden acceder a su desk
if (Auth::role() === 'recepcionista') {
$mod = $router->getModule();
$view = $router->getView();
if ($mod === 'turnero' && $view === 'recepcion') {
$deskId = (int)($_GET['desk_id'] ?? 0);
$assignedDesk = self::recepIpDesk();
if ($assignedDesk && $deskId !== $assignedDesk) {
header('Location: /erp.php?m=turnero&v=recepcion&desk_id=' . $assignedDesk);
exit;
}
}
}
// Bacteriólogos: sin dashboard ni historial; redirige según IP
if (Auth::isBacteriologo()) {
$mod = $router->getModule();
$view = $router->getView();
$blocked = ($mod === 'dashboard')
|| ($mod === 'turnero' && in_array($view, ['dashboard', 'historial'], true));
if ($blocked) {
header('Location: ' . self::bacteDefaultUrl());
exit;
}
}
} }
self::dispatch($router); self::dispatch($router);
@@ -99,6 +123,72 @@ class App
include $viewFile; include $viewFile;
} }
// ─── Helpers de rol ─────────────────────────────────────────────────────
/** IP real del cliente, soporta proxy con X-Forwarded-For. */
public static function clientIp(): string
{
$raw = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
return trim(explode(',', $raw)[0]);
}
/**
* Devuelve el lugar_id de recepción asignado a la IP del cliente, o null si no está registrada.
*/
private static function recepIpDesk(): ?int
{
try {
$pdo = Database::getInstance()->getConnection();
$st = $pdo->prepare(
"SELECT d.lugar_id FROM turnero_dispositivos d
JOIN turnero_lugares l ON l.id = d.lugar_id
WHERE d.ip = ? AND d.activo = 1 AND l.tipo = 'recepcion' LIMIT 1"
);
$st->execute([self::clientIp()]);
$id = $st->fetchColumn();
return $id ? (int)$id : null;
} catch (\Throwable $_) {
return null;
}
}
/**
* URL de destino para bacteriólogo según IP del cliente.
* IP registrada en turnero_dispositivos → ese lugar.
* IP no registrada → primer lugar de tipo muestras.
*/
private static function bacteDefaultUrl(): string
{
$base = '/erp.php?m=turnero&v=lugar&lugar_id=';
try {
$pdo = Database::getInstance()->getConnection();
// 1. Por token de navegador
$tok = trim($_COOKIE['turnero_token'] ?? '');
if ($tok) {
$s = $pdo->prepare(
"SELECT lugar_id FROM turnero_dispositivos WHERE token = ? AND activo = 1 LIMIT 1"
);
$s->execute([$tok]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if ($row) return $base . (int)$row['lugar_id'];
}
// 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(
"SELECT id FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order LIMIT 1"
)->fetch(PDO::FETCH_ASSOC);
if ($first) return $base . (int)$first['id'];
} catch (\Throwable $_) {}
return '/erp.php?m=turnero';
}
// ─── Páginas de error ──────────────────────────────────────────────────── // ─── Páginas de error ────────────────────────────────────────────────────
private static function render404(string $module, string $view): void private static function render404(string $module, string $view): void
+5
View File
@@ -67,6 +67,11 @@ class Auth
return self::role() === 'enfermero'; return self::role() === 'enfermero';
} }
public static function isBacteriologo(): bool
{
return self::role() === 'bacteriologo';
}
public static function isAdmin(): bool public static function isAdmin(): bool
{ {
return self::role() === 'admin'; return self::role() === 'admin';
+6 -3
View File
@@ -60,12 +60,15 @@ class Layout
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $safeTitle ?> — <?= htmlspecialchars($appName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></title> <title><?= $safeTitle ?> — <?= htmlspecialchars($appName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></title>
<!-- Dark mode: aplica data-bs-theme antes del primer paint para evitar flash -->
<script>(function(){try{var m=window.matchMedia('(prefers-color-scheme: dark)');function a(d){document.documentElement.setAttribute('data-bs-theme',d?'dark':'light');}a(m.matches);m.addEventListener('change',function(e){a(e.matches);});}catch(e){}})();</script>
<!-- Bootstrap 5 --> <!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome 6 --> <!-- Font Awesome 6 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- Estilos del sistema --> <!-- Estilos del sistema -->
<link href="<?= $base ?>/assets/css/styles.css?v=14" rel="stylesheet"> <link href="<?= $base ?>/assets/css/styles.css?v=15" rel="stylesheet">
<style> <style>
:root { :root {
@@ -77,7 +80,7 @@ class Layout
.main-content { .main-content {
margin-left: var(--sidebar-width); margin-left: var(--sidebar-width);
min-height: 100vh; min-height: 100vh;
background: #f8f9fa; background: var(--bs-body-bg, #f8f9fa);
} }
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.main-content { margin-left: 0; } .main-content { margin-left: 0; }
@@ -108,7 +111,7 @@ class Layout
<!-- Contenido principal --> <!-- Contenido principal -->
<main class="main-content"> <main class="main-content">
<!-- Barra de título de módulo --> <!-- Barra de título de módulo -->
<div class="bg-white border-bottom px-4 py-3 d-flex align-items-center justify-content-between"> <div class="bg-body-tertiary border-bottom px-4 py-3 d-flex align-items-center justify-content-between">
<h5 class="mb-0 fw-semibold"> <h5 class="mb-0 fw-semibold">
<i class="<?= $safeIcon ?> me-2 text-primary"></i><?= $safeTitle ?> <i class="<?= $safeIcon ?> me-2 text-primary"></i><?= $safeTitle ?>
</h5> </h5>
+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);
} }
}, },
+207 -5
View File
@@ -90,6 +90,29 @@ if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
/* ── Loading screen ──────────────────────────────── */ /* ── Loading screen ──────────────────────────────── */
#loading-screen { min-height:50vh; display:flex; flex-direction:column; #loading-screen { min-height:50vh; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:12px; } align-items:center; justify-content:center; gap:12px; }
/* ── Topaz SigWeb overlay ─────────────────────────── */
#topaz-overlay { display:none; position:fixed; inset:0; z-index:9999;
background:rgba(0,0,0,.55); align-items:center;
justify-content:center; padding:16px; }
.topaz-modal { background:#fff; border-radius:16px; width:100%;
max-width:400px; box-shadow:0 12px 40px rgba(0,0,0,.3); overflow:hidden; }
.topaz-modal-hdr { background:linear-gradient(135deg,#1565c0,#0288d1);
color:#fff; padding:14px 18px; font-weight:700; font-size:.95rem;
display:flex; align-items:center; gap:8px; }
.topaz-modal-body { padding:20px 18px; }
.topaz-pad-area { border:2px dashed #adb5bd; border-radius:10px; padding:24px 16px;
text-align:center; background:#f8fafc; min-height:100px;
display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:6px; transition:border-color .2s; }
.topaz-pad-area.has-sig { border-color:#198754; border-style:solid; background:#f0fff4; }
.topaz-pts-badge { font-size:.8rem; color:#64748b; }
.topaz-modal-footer { display:flex; gap:8px; justify-content:flex-end;
padding:12px 18px; border-top:1px solid #f1f5f9; flex-wrap:wrap; }
.btn-topaz { font-size:.83rem; padding:6px 13px; border-radius:8px; border:1.5px solid #0288d1;
background:#fff; color:#0288d1; cursor:pointer; font-weight:600;
display:inline-flex; align-items:center; gap:5px; transition:all .15s; }
.btn-topaz:hover { background:#e0f2fe; }
</style> </style>
</head> </head>
<body> <body>
@@ -204,11 +227,15 @@ if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
<div id="firma-canvas-area"> <div id="firma-canvas-area">
<p class="text-muted small mb-2">✍️ Dibuja tu firma con el dedo o el mouse.</p> <p class="text-muted small mb-2">✍️ Dibuja tu firma con el dedo o el mouse.</p>
<canvas id="firma-canvas" class="empty"></canvas> <canvas id="firma-canvas" class="empty"></canvas>
<div class="d-flex gap-2 mt-2"> <div class="d-flex gap-2 mt-2 flex-wrap">
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
onclick="firma.limpiarCanvas()"> onclick="firma.limpiarCanvas()">
<i class="fas fa-eraser me-1"></i>Limpiar firma <i class="fas fa-eraser me-1"></i>Limpiar firma
</button> </button>
<button type="button" class="btn-topaz" onclick="topaz.activar('__global')"
title="Usar pad biométrico Topaz">
<i class="fas fa-tablet-alt"></i>Tableta
</button>
<span id="firma-status" class="small text-muted align-self-center">Sin firma</span> <span id="firma-status" class="small text-muted align-self-center">Sin firma</span>
</div> </div>
</div> </div>
@@ -294,6 +321,37 @@ 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> </div>
<!-- ── Topaz SigWeb overlay ───────────────────────────────────────── -->
<div id="topaz-overlay">
<div class="topaz-modal">
<div class="topaz-modal-hdr">
<i class="fas fa-tablet-alt"></i> Pad biométrico Topaz
</div>
<div class="topaz-modal-body">
<div class="topaz-pad-area" id="topaz-pad-area">
<canvas id="topaz-canvas" width="500" height="150"
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
</div>
<div class="topaz-pts-badge">Trazos: <span id="topaz-pts">0</span></div>
</div>
</div>
<div class="topaz-modal-footer">
<button class="btn btn-outline-secondary btn-sm" onclick="topaz.cancelar()">
<i class="fas fa-times me-1"></i>Cancelar
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="topaz.limpiarPad()">
<i class="fas fa-eraser me-1"></i>Limpiar
</button>
<button class="btn btn-success btn-sm fw-semibold" id="topaz-btn-aceptar"
onclick="topaz.aceptar()" disabled>
<i class="fas fa-check me-1"></i>Aceptar firma
</button>
</div>
</div>
</div>
</div> </div>
<script> <script>
@@ -540,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 => `
@@ -648,11 +706,15 @@ function renderFirmaInline(fid, modos, label, required, isPro) {
<p class="text-muted small mb-2">✍️ Dibuja la firma con el dedo o el mouse.</p> <p class="text-muted small mb-2">✍️ Dibuja la firma con el dedo o el mouse.</p>
<canvas id="fw-${fid}-canvas" class="fw-canvas empty" <canvas id="fw-${fid}-canvas" class="fw-canvas empty"
style="border:2px dashed ${borderC};background:${bgC};"></canvas> style="border:2px dashed ${borderC};background:${bgC};"></canvas>
<div class="d-flex gap-2 mt-2"> <div class="d-flex gap-2 mt-2 flex-wrap">
<button type="button" class="btn btn-outline-secondary btn-sm" <button type="button" class="btn btn-outline-secondary btn-sm"
onclick="firmaWidgetLimpiar('${fid}')"> onclick="firmaWidgetLimpiar('${fid}')">
<i class="fas fa-eraser me-1"></i>Limpiar <i class="fas fa-eraser me-1"></i>Limpiar
</button> </button>
<button type="button" class="btn-topaz" onclick="topaz.activar('${fid}')"
title="Usar pad biométrico Topaz">
<i class="fas fa-tablet-alt"></i>Tableta
</button>
<span id="fw-${fid}-status" class="small text-muted align-self-center">Sin firma</span> <span id="fw-${fid}-status" class="small text-muted align-self-center">Sin firma</span>
</div> </div>
</div>`; </div>`;
@@ -1172,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);
} }
@@ -1205,6 +1268,145 @@ function iniciarCondiciones(esquema) {
evaluar(); evaluar();
} }
// ══════════════════════════════════════════════════════════════════════
// TOPAZ SIGWEB
// ══════════════════════════════════════════════════════════════════════
const topaz = (() => {
const SIGWEB_URL = '/assets/js/SigWebTablet.js';
let _loaded = false, _fid = null, _poll = null, _tmr = null;
function _loadScript() {
if (_loaded) return Promise.resolve(true);
return new Promise(res => {
const s = document.createElement('script');
s.src = SIGWEB_URL;
s.onload = () => { _loaded = true; res(true); };
s.onerror = () => res(false);
document.head.appendChild(s);
});
}
function _setStatus(msg, ok) {
const el = $('topaz-status-msg');
if (!el) return;
el.textContent = msg;
el.style.color = ok ? '#198754' : '#64748b';
const area = $('topaz-pad-area');
if (area) area.classList.toggle('has-sig', !!ok);
const icon = $('topaz-pad-icon');
if (icon) icon.style.color = ok ? '#198754' : '#adb5bd';
}
async function activar(fid) {
const ok = await _loadScript();
if (!ok) {
alert('No se detectó SigWeb.\nInstala el servicio Topaz SigWeb y vuelve a intentarlo.');
return;
}
_fid = fid;
try {
const canvas = document.getElementById('topaz-canvas');
const canvasCtx = canvas.getContext('2d');
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
SetImageXSize(500);
SetImageYSize(150);
SetImagePenWidth(3);
ClearTablet();
_tmr = SetTabletState(1, canvasCtx, 50);
} catch(e) {
alert('Error al activar el pad: ' + e.message);
return;
}
$('topaz-overlay').style.display = 'flex';
$('topaz-btn-aceptar').disabled = true;
$('topaz-pts').textContent = '0';
_setStatus('Firme en el pad biométrico', false);
let _lastPts = -1;
_poll = setInterval(() => {
try {
const pts = NumberOfTabletPoints();
$('topaz-pts').textContent = pts;
const hasSig = pts > 0;
$('topaz-btn-aceptar').disabled = !hasSig;
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(); }
}, 400);
}
function _stopPoll() { if (_poll) { clearInterval(_poll); _poll = null; } }
function _cerrarOverlay() {
_stopPoll();
try { SetTabletState(0, _tmr); } catch(e) {}
_tmr = null; _fid = null;
$('topaz-overlay').style.display = 'none';
}
function cancelar() { _cerrarOverlay(); }
function limpiarPad() {
try { _call('ClearTablet'); } catch(e) {}
$('topaz-pts').textContent = '0';
$('topaz-btn-aceptar').disabled = true;
_setStatus('Firme en el pad biométrico', false);
}
function aceptar() {
const fid = _fid;
_cerrarOverlay();
try {
GetSigImageB64(function(b64) {
if (!b64) { alert('No se capturó ninguna firma.'); return; }
const img = new Image();
img.onload = () => {
if (fid === '__global') {
const c = $('firma-canvas');
const cx = c.getContext('2d');
const r = window.devicePixelRatio || 1;
cx.clearRect(0, 0, c.width / r, c.height / r);
cx.drawImage(img, 0, 0, c.offsetWidth, c.offsetHeight);
c.classList.remove('empty');
_firmaDibujada = true;
const st = $('firma-status');
if (st) { st.textContent = '✅ Firma lista (tableta)'; st.className = 'small text-success align-self-center fw-semibold'; }
} else {
const s = _fw[fid];
if (!s?.canvas) return;
const r = window.devicePixelRatio || 1;
s.ctx.clearRect(0, 0, s.canvas.width / r, s.canvas.height / r);
s.ctx.drawImage(img, 0, 0, s.canvas.offsetWidth, s.canvas.offsetHeight);
s.hasFirma = true;
s.canvas.classList.remove('empty');
s.canvas.style.borderStyle = 'solid';
const st = document.getElementById('fw-' + fid + '-status');
if (st) { st.textContent = '✅ Firmado (tableta)'; st.className = 'small text-success align-self-center fw-semibold'; }
}
};
img.src = 'data:image/png;base64,' + b64;
});
} catch(e) { alert('Error al capturar la firma: ' + e.message); }
}
return { activar, cancelar, limpiarPad, aceptar };
})();
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
// INIT // INIT
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
+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;
} }
+20
View File
@@ -0,0 +1,20 @@
; kiosko_autoprint.ahk
; Detecta el diálogo de impresión y lo confirma automáticamente.
; Requiere AutoHotkey v2: https://www.autohotkey.com/
#Persistent
SetTitleMatchMode 2
Loop {
; Esperar cualquier ventana de diálogo de impresión (Chrome, Edge, Windows)
if WinExist("Imprimir") or WinExist("Print") {
WinActivate
Sleep 400
; Intentar presionar el botón Imprimir / OK / Enter
ControlClick "Button1"
Sleep 200
Send "{Enter}"
Sleep 1000
}
Sleep 500
}
+5
View File
@@ -0,0 +1,5 @@
@echo off
taskkill /F /IM msedge.exe >nul 2>&1
timeout /t 2 /nobreak >nul
start "" "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --kiosk "https://erp.laboratorioximenacaicedo.com/erp.php?m=turnero&v=kiosko&autoprint=1" --edge-kiosk-type=fullscreen --kiosk-printing
+17 -3
View File
@@ -681,6 +681,7 @@ async function cargarLista(pag = 1) {
<td> <td>
<div class="fw-semibold">${esc(dom.paciente_nombre)}</div> <div class="fw-semibold">${esc(dom.paciente_nombre)}</div>
<small class="text-muted">${esc(dom.barrio||'')}</small> <small class="text-muted">${esc(dom.barrio||'')}</small>
${dom.numero_orden ? `<div><span style="font-size:.68rem;background:#f0fdf4;color:#15803d;border:1px solid #86efac;border-radius:20px;padding:0 7px;font-weight:700">#${esc(dom.numero_orden)}</span></div>` : ''}
</td> </td>
<td> <td>
${dom.enfermera_nombre ? `<small>${esc(dom.enfermera_nombre)}</small>` : '<span class="text-danger small"><i class="fas fa-exclamation-triangle me-1"></i>Sin asignar</span>'} ${dom.enfermera_nombre ? `<small>${esc(dom.enfermera_nombre)}</small>` : '<span class="text-danger small"><i class="fas fa-exclamation-triangle me-1"></i>Sin asignar</span>'}
@@ -799,6 +800,13 @@ async function verDomicilio(id) {
</dl> </dl>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
${dom.numero_orden ? `<div class="mb-3">
<label class="form-label small text-muted text-uppercase">Número de orden</label><br>
<span style="font-size:.82rem;background:#f0fdf4;color:#15803d;border:1px solid #86efac;
border-radius:20px;padding:2px 12px;font-weight:700">
<i class="fas fa-hashtag me-1" style="font-size:.7rem"></i>${esc(dom.numero_orden)}
</span>
</div>` : ''}
<div class="mb-3"> <div class="mb-3">
<label class="form-label small text-muted text-uppercase">Estado actual</label><br> <label class="form-label small text-muted text-uppercase">Estado actual</label><br>
<span class="badge bg-${COLOR_DOM[dom.estado]||'secondary'} fs-6">${esc(dom.estado)}</span> <span class="badge bg-${COLOR_DOM[dom.estado]||'secondary'} fs-6">${esc(dom.estado)}</span>
@@ -1606,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');
} }
+173 -25
View File
@@ -56,7 +56,28 @@ $brandDark = sprintf('#%02x%02x%02x',
color: #64748b; color: #64748b;
} }
.hist-badge { font-size: .72rem; } .hist-badge { font-size: .72rem; }
#detail-panel { display: none; } /* Drawer de detalle (fixed overlay) */
#detail-backdrop {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,.25);
z-index: 1049;
}
#detail-panel {
position: fixed;
top: 0; right: 0;
height: 100vh;
width: 420px;
z-index: 1050;
background: #fff;
box-shadow: -4px 0 24px rgba(0,0,0,.18);
transform: translateX(110%);
transition: transform .25s cubic-bezier(.4,0,.2,1);
display: flex; flex-direction: column;
}
#detail-panel.open { transform: translateX(0); }
@media (max-width: 480px) { #detail-panel { width: 100vw; } }
#detail-body { flex: 1; overflow-y: auto; padding: 1rem; }
/* ── Modal header ──────────────────────────────── */ /* ── Modal header ──────────────────────────────── */
.modal-header-brand { .modal-header-brand {
@@ -125,9 +146,10 @@ $brandDark = sprintf('#%02x%02x%02x',
/* ── Panel detalle ─────────────────────────────── */ /* ── Panel detalle ─────────────────────────────── */
.detail-header-band { .detail-header-band {
background: linear-gradient(135deg, var(--brand-dark) 0%, var(--brand) 100%); background: linear-gradient(135deg, var(--brand-dark) 0%, var(--brand) 100%);
color: #fff; border-radius: .5rem .5rem 0 0; color: #fff;
padding: .75rem 1rem; padding: .75rem 1rem;
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0;
} }
.detail-header-band .btn-close { filter: invert(1) brightness(2); } .detail-header-band .btn-close { filter: invert(1) brightness(2); }
@@ -155,8 +177,37 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</header> </header>
<div class="container-fluid py-3"> <div class="container-fluid py-3">
<!-- Buscador -->
<div class="row mb-3"> <!-- Stats -->
<div class="row g-2 mb-3" id="stats-row">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm rounded-3 text-center py-2">
<div class="fw-bold fs-4" id="stat-total">—</div>
<div class="text-muted small">Total</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('lab')">
<div class="fw-bold fs-4 text-primary" id="stat-rips">—</div>
<div class="text-muted small"><i class="fas fa-flask me-1 text-primary"></i>Importados Lab</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('whatsapp')">
<div class="fw-bold fs-4 text-success" id="stat-wa">—</div>
<div class="text-muted small"><i class="fab fa-whatsapp me-1 text-success"></i>Vinculados WA</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm rounded-3 text-center py-2" style="cursor:pointer" onclick="filtrarOrigen('manual')">
<div class="fw-bold fs-4 text-secondary" id="stat-manual">—</div>
<div class="text-muted small"><i class="fas fa-pen me-1"></i>Manuales</div>
</div>
</div>
</div>
<!-- Filtros -->
<div class="row mb-3 g-2">
<div class="col-md-5"> <div class="col-md-5">
<div class="input-group"> <div class="input-group">
<span class="input-group-text bg-white border-end-0"><i class="fas fa-search text-muted"></i></span> <span class="input-group-text bg-white border-end-0"><i class="fas fa-search text-muted"></i></span>
@@ -165,11 +216,24 @@ require_once __DIR__ . '/shared/components/sidebar.php';
oninput="debounce(cargarLista, 380)()"> oninput="debounce(cargarLista, 380)()">
</div> </div>
</div> </div>
<div class="col-md-3">
<select id="filtro-origen" class="form-select" onchange="cargarLista(1)">
<option value="">Todos los orígenes</option>
<option value="lab">Resultados Lab</option>
<option value="manual">Manuales</option>
<option value="whatsapp">Desde WhatsApp</option>
</select>
</div>
<div class="col-md-auto ms-auto">
<button class="btn btn-sm btn-outline-secondary" onclick="cargarStats()" title="Actualizar stats">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div> </div>
<div class="row g-3"> <div class="row g-3">
<!-- Lista --> <!-- Lista -->
<div class="col-lg-7" id="lista-col"> <div class="col-12" id="lista-col">
<div class="card border-0 shadow-sm rounded-3"> <div class="card border-0 shadow-sm rounded-3">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
@@ -179,13 +243,17 @@ require_once __DIR__ . '/shared/components/sidebar.php';
<th class="ps-3">Paciente</th> <th class="ps-3">Paciente</th>
<th>Documento</th> <th>Documento</th>
<th>Teléfono</th> <th>Teléfono</th>
<th>Email</th>
<th>Ciudad</th>
<th>EPS</th> <th>EPS</th>
<th>Origen</th>
<th>Registro</th>
<th>Órd.</th> <th>Órd.</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody id="tabla-body"> <tbody id="tabla-body">
<tr><td colspan="6" class="text-center py-4 text-muted"> <tr><td colspan="10" class="text-center py-4 text-muted">
<i class="fas fa-spinner fa-spin me-2"></i>Cargando... <i class="fas fa-spinner fa-spin me-2"></i>Cargando...
</td></tr> </td></tr>
</tbody> </tbody>
@@ -195,20 +263,21 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</main>
<!-- Panel de detalle --> <!-- Backdrop para el drawer de detalle -->
<div class="col-lg-5" id="detail-panel"> <div id="detail-backdrop" onclick="cerrarDetalle()"></div>
<div class="card border-0 shadow-sm rounded-3 overflow-hidden">
<!-- Drawer de detalle (fixed overlay) -->
<div id="detail-panel">
<div class="detail-header-band"> <div class="detail-header-band">
<span class="fw-semibold" id="detail-nombre">Paciente</span> <span class="fw-semibold" id="detail-nombre">Paciente</span>
<button class="btn-close" onclick="cerrarDetalle()"></button> <button class="btn-close" onclick="cerrarDetalle()"></button>
</div> </div>
<div class="card-body" id="detail-body"></div> <div id="detail-body"></div>
</div> </div>
</div>
</div>
</div>
</main>
<!-- Modal Formulario Paciente --> <!-- Modal Formulario Paciente -->
<div class="modal fade" id="modalPaciente" tabindex="-1"> <div class="modal fade" id="modalPaciente" tabindex="-1">
@@ -367,16 +436,76 @@ require_once __DIR__ . '/shared/components/sidebar.php';
let paginaActual = 1; let paginaActual = 1;
const modal = new bootstrap.Modal('#modalPaciente'); const modal = new bootstrap.Modal('#modalPaciente');
// ── Stats ──────────────────────────────────────────────────────────────────
async function cargarStats() {
try {
const r = await fetch('api/lab/get_pacientes.php?stats=1');
const d = await r.json();
const s = d.stats || {};
document.getElementById('stat-total').textContent = s.total ?? '—';
document.getElementById('stat-rips').textContent = s.importados ?? '—';
document.getElementById('stat-wa').textContent = s.con_wa ?? '—';
document.getElementById('stat-manual').textContent = s.manuales ?? '—';
} catch(_) {}
}
function filtrarOrigen(origen) {
const sel = document.getElementById('filtro-origen');
sel.value = sel.value === origen ? '' : origen;
cargarLista(1);
}
// ── Lista ────────────────────────────────────────────────────────────────── // ── Lista ──────────────────────────────────────────────────────────────────
const _origenBadge = {
lab: '<span class="badge" style="background:#e3f2fd;color:#1565c0;font-size:.7rem"><i class="fas fa-flask me-1"></i>Resultados Lab</span>',
whatsapp: '<span class="badge" style="background:#e8f5e9;color:#2e7d32;font-size:.7rem"><i class="fab fa-whatsapp me-1"></i>WhatsApp</span>',
manual: '<span class="badge bg-secondary bg-opacity-10 text-secondary" style="font-size:.7rem"><i class="fas fa-pen me-1"></i>Manual</span>',
rips: '<span class="badge" style="background:#e3f2fd;color:#1565c0;font-size:.7rem"><i class="fas fa-flask me-1"></i>Resultados Lab</span>',
};
function origenBadge(origen) {
return _origenBadge[origen] || _origenBadge.manual;
}
function fmtFecha(str) {
if (!str) return '—';
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();
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(busq)}&page=${pag}&limit=25`); const origen = document.getElementById('filtro-origen').value;
const r = await fetch(
`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(busq)}&page=${pag}&limit=25&origen=${encodeURIComponent(origen)}`
);
const d = await r.json(); const d = await r.json();
const tbody = document.getElementById('tabla-body'); const tbody = document.getElementById('tabla-body');
if (!d.data?.length) { if (!d.data?.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">Sin resultados</td></tr>'; tbody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Sin resultados</td></tr>';
document.getElementById('paginacion').innerHTML = ''; document.getElementById('paginacion').innerHTML = '';
return; return;
} }
@@ -385,11 +514,15 @@ async function cargarLista(pag = 1) {
<tr style="cursor:pointer" onclick="verDetalle(${p.id})"> <tr style="cursor:pointer" onclick="verDetalle(${p.id})">
<td class="ps-3"> <td class="ps-3">
<div class="fw-semibold">${esc(p.nombre_completo)}</div> <div class="fw-semibold">${esc(p.nombre_completo)}</div>
${p.phone_number ? `<small class="text-muted"><i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}</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)} ${esc(p.numero_documento||'—')}</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||'—')}</td> <td class="small">${celdaTelefono(p)}</td>
<td class="small">${esc(p.eps||'—')}</td> <td class="small text-muted">${esc(p.email||'—')}</td>
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
<td class="small text-muted">${esc(p.eps||'—')}</td>
<td>${origenBadge(p.origen)}</td>
<td class="small text-muted">${fmtFecha(p.created_at)}</td>
<td><span class="badge bg-primary hist-badge">${p.total_ordenes||0}</span></td> <td><span class="badge bg-primary hist-badge">${p.total_ordenes||0}</span></td>
<td> <td>
<button class="btn btn-sm btn-outline-secondary py-1 px-2" <button class="btn btn-sm btn-outline-secondary py-1 px-2"
@@ -417,7 +550,8 @@ async function cargarLista(pag = 1) {
// ── Detalle ──────────────────────────────────────────────────────────────── // ── Detalle ────────────────────────────────────────────────────────────────
async function verDetalle(id) { async function verDetalle(id) {
const panel = document.getElementById('detail-panel'); const panel = document.getElementById('detail-panel');
panel.style.display = 'block'; panel.classList.add('open');
document.getElementById('detail-backdrop').style.display = 'block';
document.getElementById('detail-body').innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-lg text-muted"></i></div>'; document.getElementById('detail-body').innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-lg text-muted"></i></div>';
const r = await fetch(`api/lab/get_pacientes.php?id=${id}`); const r = await fetch(`api/lab/get_pacientes.php?id=${id}`);
@@ -434,8 +568,20 @@ 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">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>` : ''}
${p.fecha_nacimiento ? `<dt class="col-5 text-muted">Nacimiento</dt><dd class="col-7">${esc(p.fecha_nacimiento.slice(0,10))}</dd>` : ''} ${p.fecha_nacimiento ? `<dt class="col-5 text-muted">Nacimiento</dt><dd class="col-7">${esc(p.fecha_nacimiento.slice(0,10))}</dd>` : ''}
${p.genero ? `<dt class="col-5 text-muted">Género</dt><dd class="col-7">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</dd>` : ''} ${p.genero ? `<dt class="col-5 text-muted">Género</dt><dd class="col-7">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</dd>` : ''}
@@ -465,7 +611,8 @@ async function verDetalle(id) {
} }
function cerrarDetalle() { function cerrarDetalle() {
document.getElementById('detail-panel').style.display = 'none'; document.getElementById('detail-panel').classList.remove('open');
document.getElementById('detail-backdrop').style.display = 'none';
} }
// ── Formulario ───────────────────────────────────────────────────────────── // ── Formulario ─────────────────────────────────────────────────────────────
@@ -628,6 +775,7 @@ function mostrarToast(msg, tipo = 'success') {
} }
cargarLista(); cargarLista();
cargarStats();
</script> </script>
<script src="assets/js/lab-sidebar.js"></script> <script src="assets/js/lab-sidebar.js"></script>
</body> </body>
+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;
} }
+191
View File
@@ -210,6 +210,56 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</div> </div>
</div> </div>
<!-- ══════════════════════════════════════════════════════════
MODAL — FIRMA DE USUARIO
══════════════════════════════════════════════════════════ -->
<div class="modal fade" id="modalFirmaUsuario" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-signature me-2"></i>Firma de <span id="firma-u-nombre"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="firma-u-id">
<!-- Firma actual -->
<div id="firma-u-actual" class="mb-3 d-none">
<label class="form-label small fw-semibold text-muted">Firma guardada</label>
<div class="border rounded p-2 text-center bg-light">
<img id="firma-u-img" src="" alt="Firma" style="max-height:80px;max-width:100%">
</div>
<button class="btn btn-outline-danger btn-sm mt-2 w-100" onclick="firmaUsuario.borrar()">
<i class="fas fa-trash me-1"></i>Eliminar firma guardada
</button>
</div>
<!-- Canvas para dibujar -->
<label class="form-label small fw-semibold">Dibujar nueva firma</label>
<div style="border:1px solid #dee2e6;border-radius:8px;background:#fff;touch-action:none">
<canvas id="firma-u-canvas" width="460" height="140" style="width:100%;height:140px;border-radius:8px;cursor:crosshair"></canvas>
</div>
<div class="d-flex gap-2 mt-2">
<button class="btn btn-outline-secondary btn-sm" onclick="firmaUsuario.limpiarCanvas()">
<i class="fas fa-eraser me-1"></i>Limpiar
</button>
<div class="ms-auto">
<label class="form-label small fw-semibold mb-0 me-2">O subir imagen:</label>
<input type="file" id="firma-u-file" accept="image/*" class="form-control form-control-sm d-inline-block" style="width:auto" onchange="firmaUsuario.cargarImagen(this)">
</div>
</div>
<div class="alert alert-danger mt-2 d-none" id="firma-u-error"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-primary" onclick="firmaUsuario.guardar()">
<i class="fas fa-save me-1"></i>Guardar firma
</button>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════ <!-- ══════════════════════════════════════════════════════════
MODAL — ROL MODAL — ROL
══════════════════════════════════════════════════════════ --> ══════════════════════════════════════════════════════════ -->
@@ -460,6 +510,11 @@ const usuarios = {
<td class="text-muted small">${lastLogin}</td> <td class="text-muted small">${lastLogin}</td>
<td class="text-end table-actions"> <td class="text-end table-actions">
<button class="btn btn-sm btn-outline-primary" onclick="usuarios.editar(${u.id})"><i class="fas fa-edit"></i></button> <button class="btn btn-sm btn-outline-primary" onclick="usuarios.editar(${u.id})"><i class="fas fa-edit"></i></button>
<button class="btn btn-sm ${u.tiene_firma ? 'btn-success' : 'btn-outline-secondary'} ms-1"
onclick="firmaUsuario.abrir(${u.id}, '${esc(u.full_name ?? u.username)}')"
title="${u.tiene_firma ? 'Ver / cambiar firma' : 'Subir firma'}">
<i class="fas fa-signature"></i>
</button>
<button class="btn btn-sm btn-outline-danger ms-1" onclick="usuarios.eliminar(${u.id},'${esc(u.username)}')"><i class="fas fa-trash-alt"></i></button> <button class="btn btn-sm btn-outline-danger ms-1" onclick="usuarios.eliminar(${u.id},'${esc(u.username)}')"><i class="fas fa-trash-alt"></i></button>
</td> </td>
</tr>`; </tr>`;
@@ -596,6 +651,142 @@ document.getElementById('r_slug').addEventListener('input', function() {
} }
})(); })();
</script> </script>
<script>
// ── Gestión de firma por usuario ────────────────────────────
const firmaUsuario = (() => {
let _canvas, _ctx, _drawing = false;
function initCanvas() {
_canvas = document.getElementById('firma-u-canvas');
_ctx = _canvas.getContext('2d');
_ctx.strokeStyle = '#1e293b';
_ctx.lineWidth = 2.2;
_ctx.lineCap = 'round';
const pos = e => {
const r = _canvas.getBoundingClientRect();
const t = e.touches?.[0] ?? e;
return [(t.clientX - r.left) * (_canvas.width / r.width),
(t.clientY - r.top) * (_canvas.height / r.height)];
};
const start = e => { e.preventDefault(); _drawing = true; _ctx.beginPath(); _ctx.moveTo(...pos(e)); };
const move = e => { e.preventDefault(); if (!_drawing) return; _ctx.lineTo(...pos(e)); _ctx.stroke(); };
const stop = () => { _drawing = false; };
_canvas.addEventListener('mousedown', start);
_canvas.addEventListener('mousemove', move);
_canvas.addEventListener('mouseup', stop);
_canvas.addEventListener('mouseleave', stop);
_canvas.addEventListener('touchstart', start, { passive: false });
_canvas.addEventListener('touchmove', move, { passive: false });
_canvas.addEventListener('touchend', stop);
}
return {
abrir(userId, nombre) {
document.getElementById('firma-u-id').value = userId;
document.getElementById('firma-u-nombre').textContent = nombre;
document.getElementById('firma-u-error').classList.add('d-none');
document.getElementById('firma-u-file').value = '';
// Mostrar firma actual si existe
const u = (typeof allUsers !== 'undefined' ? allUsers : []).find(x => x.id === userId);
const actualEl = document.getElementById('firma-u-actual');
if (u?.tiene_firma) {
// Cargar imagen desde servidor
fetch(`api/lab/get_firma_usuario.php?user_id=${userId}`)
.then(r => r.json())
.then(j => {
if (j.firma_svg) {
document.getElementById('firma-u-img').src = j.firma_svg;
actualEl.classList.remove('d-none');
}
}).catch(() => {});
} else {
actualEl.classList.add('d-none');
}
if (!_canvas) initCanvas();
this.limpiarCanvas();
new bootstrap.Modal('#modalFirmaUsuario').show();
},
limpiarCanvas() {
if (_ctx) _ctx.clearRect(0, 0, _canvas.width, _canvas.height);
},
cargarImagen(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
if (!_canvas) initCanvas();
this.limpiarCanvas();
const scale = Math.min(_canvas.width / img.width, _canvas.height / img.height);
const w = img.width * scale, h = img.height * scale;
_ctx.drawImage(img, (_canvas.width - w) / 2, (_canvas.height - h) / 2, w, h);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
},
async guardar() {
const userId = parseInt(document.getElementById('firma-u-id').value);
const errEl = document.getElementById('firma-u-error');
errEl.classList.add('d-none');
// Verificar que el canvas tenga algo dibujado
const blank = document.createElement('canvas');
blank.width = _canvas.width; blank.height = _canvas.height;
if (_canvas.toDataURL() === blank.toDataURL()) {
errEl.textContent = 'Dibuja o sube una firma primero.';
errEl.classList.remove('d-none');
return;
}
const png = _canvas.toDataURL('image/png');
try {
const res = await fetch('api/lab/save_firma_usuario.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, firma_svg: png })
});
const json = await res.json();
if (!json.ok) { errEl.textContent = json.error || 'Error'; errEl.classList.remove('d-none'); return; }
bootstrap.Modal.getInstance(document.getElementById('modalFirmaUsuario'))?.hide();
// Marcar tiene_firma en memoria local para actualizar el botón
if (typeof allUsers !== 'undefined') {
const u = allUsers.find(x => x.id === userId);
if (u) u.tiene_firma = true;
if (typeof usuarios !== 'undefined') usuarios.render();
}
} catch (e) { errEl.textContent = 'Error de conexión.'; errEl.classList.remove('d-none'); }
},
async borrar() {
const userId = parseInt(document.getElementById('firma-u-id').value);
if (!confirm('¿Eliminar la firma guardada de este usuario?')) return;
try {
const res = await fetch('api/lab/save_firma_usuario.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, _borrar: true })
});
const json = await res.json();
if (!json.ok) return;
document.getElementById('firma-u-actual').classList.add('d-none');
if (typeof allUsers !== 'undefined') {
const u = allUsers.find(x => x.id === userId);
if (u) u.tiene_firma = false;
if (typeof usuarios !== 'undefined') usuarios.render();
}
} catch (_) {}
}
};
})();
</script>
<script src="assets/js/lab-sidebar.js"></script> <script src="assets/js/lab-sidebar.js"></script>
</body> </body>
</html> </html>
+56
View File
@@ -41,6 +41,40 @@ 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 token o IP
try {
$_pdo2 = Database::getInstance()->getConnection();
$_disp = null;
$_devToken = trim($_COOKIE['turnero_token'] ?? '');
if ($_devToken) {
$_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.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) {
$_SESSION['turnero_dispositivo'] = $_disp;
$url = $_disp['tipo'] === 'recepcion'
? BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_disp['lugar_id']
: BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_disp['lugar_id'];
header('Location: ' . $url); exit;
}
} catch (\Throwable $_) {}
$roleSlug = $adminUser['role'] ?? 'admin'; $roleSlug = $adminUser['role'] ?? 'admin';
$modules = $adminUser['modules'] ?? []; $modules = $adminUser['modules'] ?? [];
@@ -51,10 +85,32 @@ if ($_POST && !$loginBlocked) {
exit; exit;
} }
} }
// Usuario con lugar fijo asignado (sin IP registrada)
$_userLugarId = (int)($adminUser['turnero_lugar_id'] ?? 0);
if ($_userLugarId && in_array('turnero', $modules, true)) {
// Determinar si ese lugar es recepción o toma de muestras
try {
$_lugarTipo = Database::getInstance()->getConnection()
->prepare("SELECT tipo FROM turnero_lugares WHERE id = ? LIMIT 1");
$_lugarTipo->execute([$_userLugarId]);
$_tipo = $_lugarTipo->fetchColumn() ?: 'muestras';
} catch (\Throwable $_) { $_tipo = 'muestras'; }
$url = $_tipo === 'recepcion'
? BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_userLugarId
: BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_userLugarId;
header('Location: ' . $url); exit;
}
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)) {
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'); 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)) {
+25
View File
@@ -0,0 +1,25 @@
<?php
require_once __DIR__ . '/config/config.php';
if (!defined('MIGRATION_TOKEN') || ($_GET['token'] ?? '') !== MIGRATION_TOKEN) {
http_response_code(403); die('Acceso denegado');
}
$pdo = Database::getInstance()->getConnection();
$steps = [
"ALTER TABLE lab_pacientes ADD COLUMN IF NOT EXISTS origen VARCHAR(20) NOT NULL DEFAULT 'manual' COMMENT 'Origen: manual | lab | whatsapp' AFTER notas_admin",
"UPDATE lab_pacientes SET origen = 'whatsapp' WHERE user_id IS NOT NULL AND origen = 'manual'",
"CREATE INDEX IF NOT EXISTS idx_pac_origen ON lab_pacientes (origen)",
];
echo '<pre>';
foreach ($steps as $sql) {
echo htmlspecialchars(substr($sql, 0, 80)) . "...\n";
try {
$pdo->exec($sql);
echo " ✓ OK\n\n";
} catch (\Throwable $e) {
echo " ✗ ERROR: " . htmlspecialchars($e->getMessage()) . "\n\n";
}
}
echo "Listo.\n</pre>";
@@ -0,0 +1,7 @@
-- Agrega número de orden visible a domicilios (formato D-YYYYMMDD-NNN)
ALTER TABLE lab_domicilios
ADD COLUMN numero_orden VARCHAR(20) NULL DEFAULT NULL
COMMENT 'Número de orden del domicilio. Formato D-YYYYMMDD-NNN'
AFTER paciente_id;
CREATE INDEX idx_lab_domicilios_numero_orden ON lab_domicilios (numero_orden);
@@ -0,0 +1,32 @@
-- =============================================================
-- LIS 01 — Catálogos base
-- Crea: lab_secciones, lab_especialidades, lab_tipos_muestra
-- =============================================================
CREATE TABLE IF NOT EXISTS lab_secciones (
codigo VARCHAR(10) NOT NULL,
nombre VARCHAR(100) NOT NULL,
PRIMARY KEY (codigo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Secciones del laboratorio (Hematología, Química, etc.)';
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_especialidades (
codigo VARCHAR(10) NOT NULL,
nombre VARCHAR(100) NOT NULL,
PRIMARY KEY (codigo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Especialidades médicas — replica ESPECIALIDAD Firebird';
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_tipos_muestra (
codigo VARCHAR(20) NOT NULL,
nombre VARCHAR(100) NOT NULL,
color_hex VARCHAR(7) DEFAULT NULL COMMENT 'Color del tubo para UI (#RRGGBB)',
requiere_ayuno TINYINT(1) NOT NULL DEFAULT 0,
activo TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (codigo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Tipos de muestra derivados de EXAMEN.TIPOMUESTRA Firebird';
+43
View File
@@ -0,0 +1,43 @@
-- =============================================================
-- LIS 02 — Protocolos e ítems de resultado (catálogos)
-- Crea: lab_protocolos, lab_items_resultado
-- Depende de: lab_secciones (LIS 01)
-- =============================================================
CREATE TABLE IF NOT EXISTS lab_protocolos (
codigo VARCHAR(20) NOT NULL,
nombre VARCHAR(150) NOT NULL,
cod_seccion VARCHAR(10) DEFAULT NULL,
id_planilla VARCHAR(30) DEFAULT NULL COMMENT 'Identificador de plantilla de impresión',
only_show_items TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (codigo),
KEY idx_seccion (cod_seccion),
CONSTRAINT fk_proto_seccion
FOREIGN KEY (cod_seccion) REFERENCES lab_secciones (codigo)
ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Plantillas de resultado — replica PROTOCOLO Firebird';
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_items_resultado (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
cod_protocolo VARCHAR(20) NOT NULL,
nombre VARCHAR(150) NOT NULL,
tipo_sexo ENUM('M','F') DEFAULT NULL COMMENT 'NULL = aplica a ambos sexos',
tipo ENUM('N','T') DEFAULT NULL COMMENT 'N=numérico T=texto',
medida VARCHAR(30) DEFAULT NULL,
abreviatura VARCHAR(30) DEFAULT NULL,
vmin_ref DECIMAL(12,4) DEFAULT NULL,
vmax_ref DECIMAL(12,4) DEFAULT NULL,
orden SMALLINT NOT NULL DEFAULT 0,
formula VARCHAR(500) DEFAULT NULL COMMENT 'Fórmula de cálculo automático',
cups_detalle VARCHAR(20) DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_protocolo (cod_protocolo),
KEY idx_orden (cod_protocolo, orden),
CONSTRAINT fk_item_proto
FOREIGN KEY (cod_protocolo) REFERENCES lab_protocolos (codigo)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Analitos por protocolo — replica ITEM Firebird';
+26
View File
@@ -0,0 +1,26 @@
-- =============================================================
-- LIS 03 — Perfiles de examen (paquetes)
-- Crea: lab_perfiles, lab_perfil_examenes
-- Depende de: exam_tipos (migración 003)
-- =============================================================
CREATE TABLE IF NOT EXISTS lab_perfiles (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nombre VARCHAR(150) NOT NULL,
activo TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Paquetes de exámenes — replica PERFIL Firebird';
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_perfil_examenes (
perfil_id INT UNSIGNED NOT NULL,
exam_tipo_id INT UNSIGNED NOT NULL,
PRIMARY KEY (perfil_id, exam_tipo_id),
CONSTRAINT fk_pe_perfil FOREIGN KEY (perfil_id)
REFERENCES lab_perfiles (id) ON DELETE CASCADE,
CONSTRAINT fk_pe_exam FOREIGN KEY (exam_tipo_id)
REFERENCES exam_tipos (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Exámenes por perfil — replica PERFIL_EXA Firebird';
@@ -0,0 +1,50 @@
-- =============================================================
-- LIS 04 — Ampliar exam_tipos y lab_pacientes con campos legacy
-- Depende de: lab_tipos_muestra (LIS 01), lab_protocolos (LIS 02)
-- =============================================================
-- -----------------------------------------------------------
-- exam_tipos: agregar campos del EXAMEN Firebird
-- -----------------------------------------------------------
ALTER TABLE exam_tipos
ADD COLUMN IF NOT EXISTS codigo_legacy VARCHAR(20) DEFAULT NULL
COMMENT 'Código original Firebird (CODIGO). Usado para ETL y mapeos.',
ADD COLUMN IF NOT EXISTS cups VARCHAR(20) DEFAULT NULL
COMMENT 'Código CUPS colombiano',
ADD COLUMN IF NOT EXISTS cod_protocolo VARCHAR(20) DEFAULT NULL
COMMENT 'FK lab_protocolos.codigo',
ADD COLUMN IF NOT EXISTS tipo_muestra VARCHAR(20) DEFAULT NULL
COMMENT 'FK lab_tipos_muestra.codigo',
ADD COLUMN IF NOT EXISTS nivel TINYINT DEFAULT NULL
COMMENT 'Nivel de complejidad (1/2/3)',
ADD COLUMN IF NOT EXISTS abreviatura VARCHAR(30) DEFAULT NULL,
ADD COLUMN IF NOT EXISTS seremite TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = se remite a laboratorio externo',
ADD COLUMN IF NOT EXISTS serecibe VARCHAR(50) DEFAULT NULL
COMMENT 'Nombre del laboratorio donde se recibe si seremite=1';
-- Índice único para buscar por código legacy durante el ETL
ALTER TABLE exam_tipos
ADD UNIQUE KEY IF NOT EXISTS uq_codigo_legacy (codigo_legacy);
-- FK suave (no FK real para facilitar carga masiva del ETL)
ALTER TABLE exam_tipos
ADD KEY IF NOT EXISTS idx_cod_protocolo (cod_protocolo),
ADD KEY IF NOT EXISTS idx_tipo_muestra (tipo_muestra);
-- -----------------------------------------------------------
-- lab_pacientes: agregar campos del PACIENTE Firebird
-- -----------------------------------------------------------
ALTER TABLE lab_pacientes
ADD COLUMN IF NOT EXISTS codigo_legacy VARCHAR(20) DEFAULT NULL
COMMENT 'CODPAC Firebird',
ADD COLUMN IF NOT EXISTS codetnia VARCHAR(10) DEFAULT NULL
COMMENT 'Código de etnia (RIPS/SISPRO)',
ADD COLUMN IF NOT EXISTS tipores VARCHAR(10) DEFAULT NULL
COMMENT 'Tipo de residencia (RIPS/SISPRO)',
ADD COLUMN IF NOT EXISTS ocupacion VARCHAR(100) DEFAULT NULL,
ADD COLUMN IF NOT EXISTS es_historico TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = migrado solo de Firebird, sin cuenta en nuevo sistema';
ALTER TABLE lab_pacientes
ADD KEY IF NOT EXISTS idx_codigo_legacy (codigo_legacy);
+97
View File
@@ -0,0 +1,97 @@
-- =============================================================
-- LIS 05 — Motor de precios y convenios
-- Crea: lab_tarifas_id, lab_tarifas, lab_empresas,
-- lab_empresa_subgrupos, lab_examenes_empresa
-- =============================================================
-- -----------------------------------------------------------
-- Catálogo de tarifas
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_tarifas_id (
id INT NOT NULL,
nombre VARCHAR(150) NOT NULL,
tarifa_origen INT DEFAULT NULL
COMMENT 'Si != NULL, esta tarifa = tarifa_origen * (1 + porcentaje/100)',
porcentaje DECIMAL(8,4) NOT NULL DEFAULT 0
COMMENT '0 = precios fijos, >0 = derivada porcentualmente',
PRIMARY KEY (id),
KEY idx_origen (tarifa_origen)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Catálogo de tarifas — replica TARIFAID Firebird';
-- -----------------------------------------------------------
-- Precios por examen y tarifa
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_tarifas (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
cod_examen_legacy VARCHAR(20) NOT NULL COMMENT 'CODIGO original Firebird para trazabilidad',
exam_tipo_id INT UNSIGNED DEFAULT NULL,
tarifa_id INT NOT NULL,
valor DECIMAL(12,2) NOT NULL,
recargo_urg DECIMAL(12,2) NOT NULL DEFAULT 0,
recargo_fes DECIMAL(12,2) NOT NULL DEFAULT 0,
recargo_esp DECIMAL(12,2) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uq_examen_tarifa (exam_tipo_id, tarifa_id),
KEY idx_legacy (cod_examen_legacy),
KEY idx_tarifa (tarifa_id),
CONSTRAINT fk_tar_tarifa FOREIGN KEY (tarifa_id)
REFERENCES lab_tarifas_id (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Precios por examen y tarifa — replica TARIFA Firebird';
-- -----------------------------------------------------------
-- Empresas / convenios
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_empresas (
nit VARCHAR(20) NOT NULL,
nombre VARCHAR(200) NOT NULL,
razon_social VARCHAR(200) DEFAULT NULL,
tarifa_id INT DEFAULT NULL,
descuento_pct DECIMAL(8,4) NOT NULL DEFAULT 0,
codigo_eps VARCHAR(20) DEFAULT NULL,
tipo_usuario VARCHAR(10) DEFAULT NULL COMMENT 'Tipo usuario para facturación',
tipo_usuario_sispro VARCHAR(10) DEFAULT NULL,
cod_contrato VARCHAR(50) DEFAULT NULL,
cod_tercero VARCHAR(50) DEFAULT NULL,
centro_costo VARCHAR(50) DEFAULT NULL,
req_autoriza TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = exige número de autorización en recepción',
activa TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (nit),
KEY idx_tarifa (tarifa_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Empresas y convenios — replica EMPRESA Firebird';
-- -----------------------------------------------------------
-- Subgrupos de empresa
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_empresa_subgrupos (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nit_empresa VARCHAR(20) NOT NULL,
subgrupo VARCHAR(100) NOT NULL,
tarifa_id INT DEFAULT NULL,
ref_subgrupo VARCHAR(50) DEFAULT NULL,
cod_contrato VARCHAR(50) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_empresa_sub (nit_empresa, subgrupo),
KEY idx_tarifa (tarifa_id),
CONSTRAINT fk_esub_empresa FOREIGN KEY (nit_empresa)
REFERENCES lab_empresas (nit) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Subgrupos por empresa — replica EMPRESA_SUB Firebird';
-- -----------------------------------------------------------
-- Códigos de examen alternos por empresa (para interfaces)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_examenes_empresa (
nit_empresa VARCHAR(20) NOT NULL,
cod_examen_legacy VARCHAR(20) NOT NULL,
exam_tipo_id INT UNSIGNED DEFAULT NULL,
codigo_empresa VARCHAR(50) NOT NULL,
PRIMARY KEY (nit_empresa, cod_examen_legacy),
KEY idx_exam (exam_tipo_id),
CONSTRAINT fk_ee_empresa FOREIGN KEY (nit_empresa)
REFERENCES lab_empresas (nit) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Códigos alternos por empresa — replica EXAMEN_EMP Firebird';
+90
View File
@@ -0,0 +1,90 @@
-- =============================================================
-- LIS 06 — Histórico transaccional (solo lectura post-migración)
-- Crea: lab_recepciones, lab_relaciones, lab_pagos, lab_pagos_det
-- Depende de: lab_pacientes, medicos, lab_empresas
-- =============================================================
-- -----------------------------------------------------------
-- Recepciones (equivalente a facturas del legacy)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_recepciones (
id INT NOT NULL COMMENT 'IDRECEPCION original Firebird',
cod_paciente_legacy VARCHAR(20) DEFAULT NULL,
paciente_id INT DEFAULT NULL,
cod_medico_legacy VARCHAR(20) DEFAULT NULL,
medico_id INT UNSIGNED DEFAULT NULL,
nit_empresa VARCHAR(20) DEFAULT NULL,
subgrupo VARCHAR(100) DEFAULT NULL,
fecha_recepcion DATE NOT NULL,
hora_inicio TIME DEFAULT NULL,
prefijo VARCHAR(5) DEFAULT NULL,
num_factura INT DEFAULT NULL,
valor_total DECIMAL(12,2) NOT NULL DEFAULT 0,
valor_desc DECIMAL(12,2) NOT NULL DEFAULT 0,
diag_ppal VARCHAR(10) DEFAULT NULL COMMENT 'Código diagnóstico CIE-10',
tipo_usuario VARCHAR(10) DEFAULT NULL,
autorizacion VARCHAR(50) DEFAULT NULL,
usuario VARCHAR(50) DEFAULT NULL COMMENT 'Login del operador en Firebird',
es_historico TINYINT(1) NOT NULL DEFAULT 1,
migrado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_paciente (paciente_id),
KEY idx_fecha (fecha_recepcion),
KEY idx_empresa (nit_empresa),
KEY idx_factura (prefijo, num_factura)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Recepciones históricas — solo lectura, migrado de Firebird';
-- -----------------------------------------------------------
-- Exámenes por recepción
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_relaciones (
recepcion_id INT NOT NULL,
cod_examen_legacy VARCHAR(20) NOT NULL,
exam_tipo_id INT UNSIGNED DEFAULT NULL,
precio DECIMAL(12,2) NOT NULL DEFAULT 0,
fecha_reportado DATE DEFAULT NULL,
reportado TINYINT(1) NOT NULL DEFAULT 0,
reportado_por VARCHAR(50) DEFAULT NULL,
validado TINYINT(1) NOT NULL DEFAULT 0,
usuario_valida VARCHAR(50) DEFAULT NULL,
fecha_valida DATE DEFAULT NULL,
PRIMARY KEY (recepcion_id, cod_examen_legacy),
KEY idx_exam_tipo (exam_tipo_id),
CONSTRAINT fk_rel_recepcion FOREIGN KEY (recepcion_id)
REFERENCES lab_recepciones (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Exámenes por recepción histórica — replica RELACION Firebird';
-- -----------------------------------------------------------
-- Pagos
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_pagos (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
numcaja_legacy INT NOT NULL UNIQUE COMMENT 'NUMCAJA original Firebird',
recepcion_id INT NOT NULL,
valor DECIMAL(12,2) NOT NULL,
fecha DATE NOT NULL,
usuario VARCHAR(50) DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_recepcion (recepcion_id),
CONSTRAINT fk_pago_recep FOREIGN KEY (recepcion_id)
REFERENCES lab_recepciones (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Pagos históricos — replica PAGOS Firebird';
-- -----------------------------------------------------------
-- Detalle de formas de pago por transacción
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_pagos_det (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
pago_id INT UNSIGNED NOT NULL,
tipo_pago VARCHAR(30) NOT NULL COMMENT 'efectivo, cheque, tarjeta, etc.',
valor DECIMAL(12,2) NOT NULL,
num_doc VARCHAR(50) DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_pago (pago_id),
CONSTRAINT fk_pagdet_pago FOREIGN KEY (pago_id)
REFERENCES lab_pagos (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Detalle de formas de pago — replica PAGOS_DET Firebird';
@@ -0,0 +1,28 @@
-- =============================================================
-- LIS 07 — Tracking de muestras por turnero (solicitud)
-- Crea: turnero_muestras
-- Depende de: turnero_solicitudes (003), lab_tipos_muestra (LIS 01)
-- =============================================================
CREATE TABLE IF NOT EXISTS turnero_muestras (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
solicitud_id INT UNSIGNED NOT NULL
COMMENT 'FK turnero_solicitudes.id',
tipo_muestra VARCHAR(20) NOT NULL
COMMENT 'FK lab_tipos_muestra.codigo (ej: SANGRE_VENOSA, ORINA)',
estado ENUM('pendiente','recibida','rechazada')
NOT NULL DEFAULT 'pendiente',
motivo_rechazo VARCHAR(200) DEFAULT NULL
COMMENT 'Razón de rechazo (hemólisis, coagulado, volumen insuficiente…)',
recibida_por INT DEFAULT NULL
COMMENT 'FK admin_users.id — quién marcó recibida',
recibida_at DATETIME DEFAULT NULL,
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_solicitud_tipo (solicitud_id, tipo_muestra),
KEY idx_estado (estado),
KEY idx_solicitud (solicitud_id),
CONSTRAINT fk_tm_solicitud FOREIGN KEY (solicitud_id)
REFERENCES turnero_solicitudes (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Estado de muestras físicas por turno — solo lugar toma de muestras';
+186
View File
@@ -0,0 +1,186 @@
-- =============================================================
-- LIS 08 — Vistas del sistema
-- Todas usan CREATE OR REPLACE para poder re-ejecutar sin error.
-- =============================================================
-- -----------------------------------------------------------
-- v_muestras_hoy
-- Muestras del día en el turnero (estado en tiempo real).
-- Usada por: dashboard, widget lugar.php
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_muestras_hoy AS
SELECT
tm.id,
tm.solicitud_id,
tm.tipo_muestra,
COALESCE(lt.nombre, tm.tipo_muestra) AS tipo_muestra_label,
lt.color_hex AS tipo_muestra_color,
tm.estado,
tm.motivo_rechazo,
tm.recibida_at,
ts.turno_id,
tt.codigo AS turno_codigo,
ts.lugar_id,
tl.nombre AS lugar_nombre,
ts.paciente_id,
lp.nombre_completo AS paciente_nombre,
lp.numero_documento AS paciente_documento,
DATE(tt.creado_at) AS fecha
FROM turnero_muestras tm
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
JOIN turnero_turnos tt ON tt.id = ts.turno_id
JOIN turnero_lugares tl ON tl.id = ts.lugar_id
LEFT JOIN lab_pacientes lp ON lp.id = ts.paciente_id
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra;
-- -----------------------------------------------------------
-- v_muestras_pendientes_hoy
-- Solo las que faltan recibir hoy.
-- Usada por: contador en dashboard, alerta visual
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_muestras_pendientes_hoy AS
SELECT *
FROM v_muestras_hoy
WHERE estado = 'pendiente'
AND fecha = CURDATE();
-- -----------------------------------------------------------
-- v_recepcion_completa
-- Histórico Firebird con todas las FK resueltas.
-- Usada por: módulo de consulta histórica (solo lectura)
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_recepcion_completa AS
SELECT
r.id,
r.fecha_recepcion,
r.hora_inicio,
r.prefijo,
r.num_factura,
CONCAT(COALESCE(r.prefijo,''), '-',
LPAD(COALESCE(r.num_factura, 0), 6, '0')) AS factura,
r.valor_total,
r.valor_desc,
(r.valor_total - r.valor_desc) AS valor_neto,
r.paciente_id,
r.cod_paciente_legacy,
COALESCE(lp.nombre_completo, r.cod_paciente_legacy) AS paciente_nombre,
lp.numero_documento AS paciente_documento,
lp.tipo_documento AS paciente_tipo_doc,
r.medico_id,
r.cod_medico_legacy,
CONCAT(COALESCE(m.nombres,''), ' ', COALESCE(m.apellidos,'')) AS medico_nombre,
m.cod_especialidad AS medico_especialidad,
r.nit_empresa,
COALESCE(e.nombre, r.nit_empresa) AS empresa_nombre,
r.subgrupo,
r.diag_ppal,
r.tipo_usuario,
r.autorizacion,
r.usuario
FROM lab_recepciones r
LEFT JOIN lab_pacientes lp ON lp.id = r.paciente_id
LEFT JOIN medicos m ON m.id = r.medico_id
LEFT JOIN lab_empresas e ON e.nit = r.nit_empresa;
-- -----------------------------------------------------------
-- v_relacion_completa
-- Exámenes por recepción con nombre resuelto.
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_relacion_completa AS
SELECT
lr.recepcion_id,
r.fecha_recepcion,
r.prefijo,
r.num_factura,
lr.cod_examen_legacy,
lr.exam_tipo_id,
et.nombre AS examen_nombre,
et.categoria AS examen_categoria,
et.tipo_muestra AS tipo_muestra,
lr.precio,
lr.reportado,
lr.fecha_reportado,
lr.reportado_por,
lr.validado,
lr.usuario_valida,
lr.fecha_valida
FROM lab_relaciones lr
JOIN lab_recepciones r ON r.id = lr.recepcion_id
LEFT JOIN exam_tipos et ON et.id = lr.exam_tipo_id;
-- -----------------------------------------------------------
-- v_examen_precio
-- Precio efectivo de cada examen en cada tarifa.
-- Resuelve tarifas derivadas por porcentaje.
-- Usada por: motor de precios en nueva recepción
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_examen_precio AS
SELECT
et.id AS exam_tipo_id,
et.codigo,
et.codigo_legacy,
et.nombre AS examen_nombre,
et.categoria,
et.tipo_muestra,
ti.id AS tarifa_id,
ti.nombre AS tarifa_nombre,
ti.porcentaje,
ti.tarifa_origen,
lt.valor AS valor_almacenado,
CASE
WHEN ti.porcentaje > 0 AND ti.tarifa_origen IS NOT NULL
AND lt_base.valor IS NOT NULL
THEN ROUND(lt_base.valor * (1 + ti.porcentaje / 100), 0)
ELSE lt.valor
END AS valor_efectivo,
lt.recargo_urg,
lt.recargo_fes,
lt.recargo_esp
FROM exam_tipos et
JOIN lab_tarifas lt ON lt.exam_tipo_id = et.id
JOIN lab_tarifas_id ti ON ti.id = lt.tarifa_id
LEFT JOIN lab_tarifas lt_base ON lt_base.exam_tipo_id = et.id
AND lt_base.tarifa_id = ti.tarifa_origen;
-- -----------------------------------------------------------
-- v_paciente_resumen
-- Vista unificada: pacientes del nuevo sistema + migrados.
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_paciente_resumen AS
SELECT
id,
nombre_completo,
tipo_documento,
numero_documento,
telefono,
email,
fecha_nacimiento,
genero,
ciudad,
eps,
es_historico,
codigo_legacy,
created_at
FROM lab_pacientes
WHERE is_active = 1;
-- -----------------------------------------------------------
-- v_turno_muestras_estado
-- Estado agregado de muestras por turno (para cola y dashboard).
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_turno_muestras_estado AS
SELECT
ts.turno_id,
COUNT(*) AS total_muestras,
SUM(tm.estado = 'pendiente') AS pendientes,
SUM(tm.estado = 'recibida') AS recibidas,
SUM(tm.estado = 'rechazada') AS rechazadas,
CASE
WHEN SUM(tm.estado = 'pendiente') = 0 THEN 'completo'
WHEN SUM(tm.estado = 'recibida') = 0 AND SUM(tm.estado = 'rechazada') = 0
THEN 'sin_recibir'
ELSE 'parcial'
END AS estado_global
FROM turnero_muestras tm
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
GROUP BY ts.turno_id;
@@ -0,0 +1,17 @@
-- =============================================================
-- LIS 09 — Registra módulo lab_empresas en system_modules
-- y agrega permiso por defecto para el rol admin.
-- Seguro para re-ejecutar (INSERT IGNORE).
-- =============================================================
INSERT IGNORE INTO system_modules
(slug, name, icon, category, route, is_active, sort_order, oleada, description)
VALUES
('lab_empresas', 'Empresas y Convenios', 'fas fa-building', 'clinico',
'/erp.php?m=lab_empresas&v=index', 1, 65, 2,
'Gestión de empresas, EPS, convenios, subgrupos y catálogo de tarifas');
-- Permiso automático para rol admin (role_id = 1)
INSERT IGNORE INTO role_modules (role_id, module_slug)
SELECT 1, 'lab_empresas'
WHERE EXISTS (SELECT 1 FROM roles WHERE id = 1);
@@ -0,0 +1,16 @@
-- =============================================================
-- LIS 10 — Extiende turnero_solicitudes con campos de empresa/convenio
-- Todos DEFAULT NULL para no romper datos existentes.
-- =============================================================
ALTER TABLE turnero_solicitudes
ADD COLUMN IF NOT EXISTS nit_empresa VARCHAR(20) DEFAULT NULL
COMMENT 'FK lab_empresas.nit — empresa/EPS del paciente',
ADD COLUMN IF NOT EXISTS subgrupo_id INT UNSIGNED DEFAULT NULL
COMMENT 'FK lab_empresa_subgrupos.id',
ADD COLUMN IF NOT EXISTS autorizacion VARCHAR(100) DEFAULT NULL
COMMENT 'Número de autorización EPS',
ADD COLUMN IF NOT EXISTS diag_ppal VARCHAR(20) DEFAULT NULL
COMMENT 'Diagnóstico principal CIE-10',
ADD COLUMN IF NOT EXISTS items_precio JSON DEFAULT NULL
COMMENT 'Snapshot de precios al momento de la recepción';
@@ -0,0 +1,25 @@
-- =============================================================
-- LIS 11 — Habilita lab_recepciones para registros nuevos (no históricos)
--
-- El esquema original fue diseñado SOLO para histórico Firebird:
-- · id INT NOT NULL (no AUTO_INCREMENT) — el ID venía de Firebird
-- · lab_pagos.numcaja_legacy NOT NULL UNIQUE — también era de Firebird
--
-- Aquí los ajustamos para aceptar registros del nuevo sistema
-- sin tocar los datos históricos migrados.
-- =============================================================
-- 1. Hacer id AUTO_INCREMENT (MySQL usará MAX(id)+1 como siguiente valor,
-- así los registros nuevos nunca colisionan con los históricos de Firebird).
ALTER TABLE lab_recepciones
MODIFY COLUMN id INT NOT NULL AUTO_INCREMENT;
-- 2. Enlace de vuelta a la solicitud del turnero
ALTER TABLE lab_recepciones
ADD COLUMN IF NOT EXISTS solicitud_id INT UNSIGNED DEFAULT NULL
COMMENT 'FK turnero_solicitudes.id — NULL para registros históricos Firebird';
-- 3. numcaja_legacy era NOT NULL UNIQUE (campo obligatorio en Firebird).
-- Los registros nuevos no tienen NUMCAJA → lo hacemos nullable.
ALTER TABLE lab_pagos
MODIFY COLUMN numcaja_legacy INT DEFAULT NULL;
@@ -0,0 +1,7 @@
ALTER TABLE lab_domicilios
ADD COLUMN IF NOT EXISTS numero_orden VARCHAR(30) DEFAULT NULL
COMMENT 'Número de orden de domicilio (ej: D-2026-001)';
ALTER TABLE turnero_solicitudes
ADD COLUMN IF NOT EXISTS numero_orden VARCHAR(30) DEFAULT NULL
COMMENT 'Número de orden de la solicitud del turnero';
@@ -0,0 +1,7 @@
-- ============================================================
-- Migration: 20260707_admin_users_firma
-- Firma pre-guardada del profesional para firma en 1 clic
-- ============================================================
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS firma_svg MEDIUMTEXT DEFAULT NULL
COMMENT 'Firma pre-guardada del profesional (data URL PNG)';
@@ -0,0 +1,107 @@
-- ============================================================
-- Migration: 20260707_formulario_vih_turnero
-- Clon de F-LAB-05 VIH para uso en turnero.
--
-- Problema del original (id=1):
-- · Esquema sin campos tipo "firma" ni "firma_profesional"
-- · Paciente firmaba por fallback global (canvas genérico al pie)
-- · Profesional NUNCA podía firmar (requiere $_soloFirmaPro=true
-- o campo firma_profesional en esquema — ninguno se cumplía)
-- · doc_color vacío, requiere_firma=0
--
-- Esta versión corrige el esquema agregando:
-- · Separador + campo texto para responsable (menores/incapaces)
-- · campo tipo="firma" → firma inline del paciente
-- · campo tipo="firma_profesional" → 1-clic para el profesional
-- ============================================================
SET @esquema_vih = '[
{
"id": "_8h4v4jg",
"tipo": "parrafo",
"contenido": "Autorización voluntaria para realizar la prueba presuntiva para VIH (Decreto 1543/97 del Ministerio de Protección Social por el cual se reglamentan los mecanismos de prevención, diagnóstico, manejo y reporte epidemiológico de la infección por VIH)",
"flujoLibre": false
},
{
"id": "_z7pv612",
"tipo": "fecha_hoy",
"label": "Fecha",
"required": false
},
{
"id": "_afsycwa",
"tipo": "parrafo",
"contenido": "Qué es el síndrome de inmunodeficiencia adquirida (SIDA)? \nEs una enfermedad producida por un virus conocido como el virus de inmunodeficiencia Humana (VIH), el cual infecta y destruye las células del sistema inmune, originando una falla progresiva y grave en los sistemas de defensa del organismo el cual queda expuesto a la infección y ciertos tipos de tumores. \n\n¿Cómo se adquiere la enfermedad? \nLa enfermedad se adquiere principalmente por contacto sexual con personas infectadas con el VIH. Por exposición a la sangre y a ciertos productos derivados de la misma contaminados con el virus. Además, durante el embarazo, las madres infectadas con el virus de inmunodeficiencia humana pueden transmitir la infección al feto a través de la placenta. \n\n¿Cómo se hace el diagnóstico de la infección? El diagnóstico se hace mediante una prueba de sangre que busca anticuerpos producidos por el organismo contra el virus. Existen dos clases de pruebas de laboratorio. Presuntivas, que pueden indicar una posible infección, y las pruebas confirmatorias, las cuales se hacen únicamente en caso de que la prueba presuntiva de positiva. \n\n¿Cuál es el procedimiento que el laboratorio debe realizar en el análisis de la prueba? El laboratorio procesa la muestra y en caso de un resultado presuntamente positivo o inconcluyente, realiza un segundo examen con otra muestra. En caso de que la segunda muestra también arroje un resultado presuntamente positivo, el resultado de la prueba se reporta como reactivo. En caso de que la segunda muestra no confirme los resultados de la primera muestra, las dos muestras se envían a un tercer laboratorio, con el fin de que sea procesada en él. Los resultados de este tercer laboratorio se toman como definitivos para decidir el reporte como Reactivo o Negativo. En todo caso, los costos por estas pruebas son asumidos por cuenta del laboratorio. \n\n¿Cómo se debe interpretar el resultado de la prueba? La prueba inicial, como ya se anotó, es apenas una prueba presuntiva, y por lo tanto, el hecho de salir reactiva no implica que usted tenga SIDA, o esté infectado por el virus. Este resultado debe ser confirmado mediante una prueba llamada Western Blot, con el fin de eliminar posibles falsos positivos de la prueba presuntiva. Para este segundo examen es importante tomar una nueva muestra y procesarla asumiendo usted los costos. Aún si esta segunda prueba confirma la presencia de anticuerpos para el VIH, esto no significa que usted tiene SIDA, pues existe un período de la enfermedad, controlable, en el cual los pacientes tienen anticuerpos contra el VIH, pero no tienen síntomas de la enfermedad, y pueden, inclusive, no desarrollar jamás la enfermedad. Lo que es muy urgente, es consultar con un médico para que se determine el estado de su enfermedad, y se inicie el tratamiento apropiado. \n\nEl resultado será entregado personalmente, previa identificación. ",
"flujoLibre": false
},
{
"id": "_wsqqoiu",
"tipo": "parrafo_inline",
"contenido": "Yo, {nombre_completo}, con N.º de identificación {numero_documento}, declaro que fui informado (a) sobre el examen de anticuerpos contra el VIH que me será practicado el día de hoy, he recibido asesoría, me han explicado en que consiste; el procedimiento y sus implicaciones en mi vida, y la confidencialidad con la que se manejara las información que he dado y que se obtendrá. YO COMPRENDO Y AUTORIZO LA REALIZACIÓN DE LA PRUEBA DE FORMA LIBRE Y ESPONTÁNEA, en el Laboratorio Clínico Ximena Caicedo Empresa Unipersonal."
},
{
"id": "_vihsep1",
"tipo": "separador",
"label": "AUTORIZACIÓN Y FIRMA"
},
{
"id": "_vihresp",
"tipo": "texto",
"label": "En caso de menor o incapacitado: nombre y parentesco del responsable",
"required": false
},
{
"id": "_vihfpac",
"tipo": "firma",
"label": "Firma del paciente / responsable",
"required": true
},
{
"id": "_vihsep2",
"tipo": "separador",
"label": "USO EXCLUSIVO DEL PROFESIONAL"
},
{
"id": "_vihfpro",
"tipo": "firma_profesional",
"label": "Firma del profesional de salud"
}
]';
INSERT INTO lab_formularios (
nombre,
descripcion,
categoria,
esquema,
permite_firma,
requiere_firma,
doc_encabezado,
doc_subtitulo,
doc_color,
tipo,
is_principal,
is_active,
version,
creado_por
) VALUES (
'CONSENTIMIENTO INFORMADO VIH F-LAB-05 V.5',
'Autorización voluntaria para prueba presuntiva de VIH — uso turnero',
'consentimiento',
@esquema_vih,
1,
1,
'XIMENA CAICEDO G. E.U',
'Laboratorio Hematológico',
'#a0a59c',
'consentimiento',
0,
1,
5,
1
);
-- Asignar a todos los puestos de toma de muestras activos
INSERT INTO turnero_lugar_consentimientos (lugar_id, formulario_id)
SELECT id, LAST_INSERT_ID()
FROM turnero_lugares
WHERE tipo = 'muestras' AND activo = 1;
@@ -0,0 +1,5 @@
-- Separar consentimientos por examen vs por lugar destino
-- Los creados desde turnero_lugar_consentimientos llevan origen_lugar_id = lugar_id de la estación
-- Los creados desde exam_tipo_consentimientos quedan con origen_lugar_id = NULL
ALTER TABLE turnero_consentimientos
ADD COLUMN IF NOT EXISTS origen_lugar_id INT NULL DEFAULT NULL;
@@ -0,0 +1,28 @@
-- ============================================================
-- Migration: 20260707_turnero_dispositivos
-- Mapeo IP fija → lugar para tablets del turnero.
-- Cada tablet solo puede acceder a su lugar asignado.
-- ============================================================
CREATE TABLE IF NOT EXISTS turnero_dispositivos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(45) NOT NULL UNIQUE,
lugar_id INT NOT NULL,
nombre VARCHAR(100) NOT NULL,
activo TINYINT(1) NOT NULL DEFAULT 1,
INDEX idx_ip (ip),
INDEX idx_lugar (lugar_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO turnero_dispositivos (ip, lugar_id, nombre) VALUES
('192.168.21.61', 1, 'Toma de Muestras 1'),
('192.168.21.62', 2, 'Toma de Muestras 2'),
('192.168.21.63', 13, 'Toma de Muestras 3'),
('192.168.21.64', 14, 'Toma de Muestras 4'),
('192.168.21.65', 15, 'Toma de Muestras 5'),
('192.168.21.66', 16, 'Toma de Muestras 6'),
('192.168.21.67', 17, 'Toma de Muestras 7'),
('192.168.21.68', 18, 'Toma de Muestras 8'),
('192.168.0.221', 10, 'Recepción 1 - Vianny Ortega'),
('192.168.0.29', 11, 'Recepción 2 - Angel Wilches'),
('192.168.0.225', 19, 'Recepción 3 - Gina Gomez')
ON DUPLICATE KEY UPDATE lugar_id = VALUES(lugar_id), nombre = VALUES(nombre);
@@ -0,0 +1,6 @@
-- Restricción de usuario a un lugar específico del turnero.
-- NULL = acceso libre (admin, recepcionista, etc.)
-- Valor = solo puede operar en ese lugar (ej. bacteriólogo asignado a Toma 1)
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS turnero_lugar_id INT UNSIGNED DEFAULT NULL
COMMENT 'FK turnero_lugares.id — si != NULL el usuario solo opera en ese lugar';
@@ -0,0 +1,5 @@
-- Token único por navegador/dispositivo para restricción sin depender de IP pública.
-- NULL = dispositivo registrado solo por IP (compatibilidad hacia atrás).
ALTER TABLE turnero_dispositivos
ADD COLUMN IF NOT EXISTS token VARCHAR(64) DEFAULT NULL,
ADD UNIQUE KEY IF NOT EXISTS uq_dispositivo_token (token);
@@ -0,0 +1,13 @@
-- Migration: 20260709_lab_pacientes_origen
-- Agrega columna 'origen' para identificar pacientes importados desde RIPS
-- vs creados manualmente o desde WhatsApp
ALTER TABLE `lab_pacientes`
ADD COLUMN `origen` VARCHAR(20) NOT NULL DEFAULT 'manual'
COMMENT 'Origen del registro: manual | lab | whatsapp'
AFTER `notas_admin`;
-- Los pacientes con user_id ya vinculado se marcan como whatsapp
UPDATE `lab_pacientes` SET `origen` = 'whatsapp' WHERE `user_id` IS NOT NULL;
CREATE INDEX `idx_origen` ON `lab_pacientes` (`origen`);
+25
View File
@@ -0,0 +1,25 @@
-- lab_eps: catálogo de EPS / aseguradoras
CREATE TABLE IF NOT EXISTS lab_eps (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(120) NOT NULL UNIQUE,
activa TINYINT(1) NOT NULL DEFAULT 1,
orden SMALLINT NOT NULL DEFAULT 0
);
INSERT IGNORE INTO lab_eps (nombre)
SELECT DISTINCT TRIM(eps)
FROM lab_pacientes
WHERE eps IS NOT NULL AND TRIM(eps) != '';
-- lab_ciudades: catálogo de ciudades de pacientes
CREATE TABLE IF NOT EXISTS lab_ciudades (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL UNIQUE,
activa TINYINT(1) NOT NULL DEFAULT 1,
orden SMALLINT NOT NULL DEFAULT 0
);
INSERT IGNORE INTO lab_ciudades (nombre)
SELECT DISTINCT TRIM(ciudad)
FROM lab_pacientes
WHERE ciudad IS NOT NULL AND TRIM(ciudad) != '';
@@ -0,0 +1,2 @@
ALTER TABLE turnero_solicitudes
ADD COLUMN IF NOT EXISTS numero_recibo VARCHAR(40) NULL DEFAULT NULL AFTER metodo_pago;
@@ -0,0 +1,12 @@
-- Cache de exámenes enviados desde RIPS Manager junto con la ingesta de pacientes.
-- Se usa en get_examenes_rips.php para evitar el round-trip a RIPS cuando ya vienen precargados.
CREATE TABLE IF NOT EXISTS rips_examenes_pendientes (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
numero_documento VARCHAR(30) NOT NULL,
datos JSON NOT NULL,
recepcion_id INT DEFAULT NULL,
hora_recepcion VARCHAR(20) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_doc (numero_documento),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,6 @@
-- Migración: tabla CIE-10 diagnósticos desde Firebird
CREATE TABLE IF NOT EXISTS cie10_diagnosticos (
cod_diag VARCHAR(10) NOT NULL,
concepto VARCHAR(500) NOT NULL,
PRIMARY KEY (cod_diag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -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
);
+2
View File
@@ -0,0 +1,2 @@
config.local.php
etl_*.log
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* Configuración de conexiones para el ETL Firebird → MySQL
*
* Antes de ejecutar:
* 1. Copiar este archivo como config.local.php (está en .gitignore)
* 2. Llenar los valores reales
* 3. Ejecutar: php run_etl.php
*/
return [
// ── Firebird (origen) ────────────────────────────────────────────────
'firebird' => [
// DSN para PDO: "firebird:dbname=HOST:RUTA_AL_FDB;charset=WIN1252"
// Si el FDB está en la misma máquina: "firebird:dbname=localhost:/opt/firebird/data/DBLAB.FDB"
// Si es una ruta Windows remota: "firebird:dbname=192.168.1.10:C:/datos/DBLAB_XIMENA_FB25.FDB"
'dsn' => 'firebird:dbname=localhost:/ruta/DBLAB_XIMENA_FB25.FDB;charset=WIN1252',
'user' => 'SYSDBA',
'password' => 'masterkey',
// Encoding declarado en el FDB (para convertir a UTF-8 durante la extracción)
'charset' => 'WIN1252',
],
// ── MySQL (destino) ──────────────────────────────────────────────────
'mysql' => [
'host' => '127.0.0.1',
'port' => 3306,
'dbname' => 'whatsapp', // nombre de la BD del nuevo sistema
'user' => 'root',
'password' => '',
'charset' => 'utf8mb4',
],
// ── Opciones de migración ────────────────────────────────────────────
'options' => [
'batch_size' => 500, // registros por INSERT batch
'dry_run' => false, // true = solo leer, no insertar
'skip_historico' => false, // true = saltar RECEPCION/RELACION/PAGOS
'log_file' => __DIR__ . '/etl_' . date('Ymd_His') . '.log',
],
];
+90
View File
@@ -0,0 +1,90 @@
<?php
/**
* Funciones auxiliares compartidas por el ETL
*/
/**
* Convierte un string de WIN1252 a UTF-8.
* Si el valor ya es UTF-8 válido, lo devuelve sin cambios.
*/
function toUtf8(?string $val, string $srcEncoding = 'WIN1252'): ?string {
if ($val === null) return null;
if (mb_check_encoding($val, 'UTF-8')) return $val;
return iconv($srcEncoding, 'UTF-8//TRANSLIT//IGNORE', $val);
}
/**
* Convierte un array completo de strings (resultado de Firebird) a UTF-8.
*/
function rowToUtf8(array $row, string $srcEncoding = 'WIN1252'): array {
foreach ($row as $k => $v) {
if (is_string($v)) {
$row[$k] = toUtf8($v, $srcEncoding);
}
}
return $row;
}
/**
* Limpia un VARCHAR de Firebird: recorta espacios y convierte encoding.
*/
function cleanStr(?string $val, string $srcEncoding = 'WIN1252'): ?string {
if ($val === null) return null;
$val = trim(toUtf8($val, $srcEncoding) ?? '');
return $val === '' ? null : $val;
}
/**
* Convierte fecha Firebird (puede ser DATE o string 'YYYY-MM-DD') a MySQL DATE.
* Devuelve null si la fecha es inválida o vacía.
*/
function fbDate($val): ?string {
if ($val === null) return null;
if ($val instanceof DateTime) return $val->format('Y-m-d');
$str = trim((string)$val);
if ($str === '' || $str === '0000-00-00') return null;
try {
return (new DateTime($str))->format('Y-m-d');
} catch (\Exception $e) {
return null;
}
}
/**
* INSERT en batch.
* $pdo → conexión MySQL
* $table → nombre de la tabla
* $rows → array de arrays asociativos con los mismos keys
* $ignore → usa INSERT IGNORE para saltar duplicados
*/
function batchInsert(PDO $pdo, string $table, array $rows, bool $ignore = false): int {
if (empty($rows)) return 0;
$cols = array_keys($rows[0]);
$colList = implode(', ', array_map(fn($c) => "`$c`", $cols));
$phRow = '(' . implode(', ', array_fill(0, count($cols), '?')) . ')';
$inserted = 0;
foreach (array_chunk($rows, 500) as $chunk) {
$placeholders = implode(', ', array_fill(0, count($chunk), $phRow));
$keyword = $ignore ? 'INSERT IGNORE' : 'INSERT';
$sql = "$keyword INTO `$table` ($colList) VALUES $placeholders";
$flat = array_merge(...array_map('array_values', $chunk));
$stmt = $pdo->prepare($sql);
$stmt->execute($flat);
$inserted += $stmt->rowCount();
}
return $inserted;
}
/** Escribe al log y a stdout simultáneamente. */
function etlLog(string $msg, $logFp = null): void {
$line = '[' . date('H:i:s') . '] ' . $msg . PHP_EOL;
echo $line;
if ($logFp) fwrite($logFp, $line);
}
/** Devuelve conteo de filas de una tabla Firebird. */
function fbCount(PDO $fb, string $table): int {
return (int) $fb->query("SELECT COUNT(*) FROM $table")->fetchColumn();
}
+715
View File
@@ -0,0 +1,715 @@
#!/usr/bin/env php
<?php
/**
* ETL Firebird 2.5 → MySQL (utf8mb4)
* Sistema: DBLAB_XIMENA_FB25 → nuevo sistema WhatsApp-Lab
*
* Uso:
* php run_etl.php [--dry-run] [--skip-historico] [--only=PASO]
*
* Pasos disponibles (--only):
* secciones | especialidades | tipos_muestra | protocolos | items |
* perfiles | exam_tipos | tarifas | empresas |
* medicos | pacientes | recepciones | relaciones | pagos
*
* Requerimientos:
* - PHP 8.0+ con extensión PDO_Firebird (php-firebird) instalada
* - Archivo config.local.php con credenciales reales
* - Las migraciones LIS 01-07 ya ejecutadas en MySQL
*/
declare(strict_types=1);
set_time_limit(0);
ini_set('memory_limit', '512M');
require_once __DIR__ . '/helpers.php';
// ── Configuración ────────────────────────────────────────────────────────────
$cfgFile = file_exists(__DIR__ . '/config.local.php')
? __DIR__ . '/config.local.php'
: __DIR__ . '/config.php';
$cfg = require $cfgFile;
// Argumentos CLI
$args = array_slice($argv ?? [], 1);
$dryRun = in_array('--dry-run', $args, true) || $cfg['options']['dry_run'];
$skipHist = in_array('--skip-historico', $args, true) || $cfg['options']['skip_historico'];
$onlyPaso = null;
foreach ($args as $arg) {
if (str_starts_with($arg, '--only=')) {
$onlyPaso = strtolower(substr($arg, 7));
}
}
$logFp = fopen($cfg['options']['log_file'], 'w');
etlLog('=== ETL Firebird → MySQL ===', $logFp);
etlLog("DRY_RUN: " . ($dryRun ? 'SÍ' : 'NO'), $logFp);
if ($onlyPaso) etlLog("Solo paso: $onlyPaso", $logFp);
// ── Conexiones ───────────────────────────────────────────────────────────────
try {
$fb = new PDO(
$cfg['firebird']['dsn'],
$cfg['firebird']['user'],
$cfg['firebird']['password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
etlLog('Conexión Firebird OK', $logFp);
} catch (\Throwable $e) {
etlLog('ERROR conectando Firebird: ' . $e->getMessage(), $logFp);
exit(1);
}
try {
$my = new PDO(
"mysql:host={$cfg['mysql']['host']};port={$cfg['mysql']['port']};dbname={$cfg['mysql']['dbname']};charset=utf8mb4",
$cfg['mysql']['user'],
$cfg['mysql']['password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => true]
);
$my->exec("SET NAMES utf8mb4");
$my->exec("SET foreign_key_checks = 0");
etlLog('Conexión MySQL OK', $logFp);
} catch (\Throwable $e) {
etlLog('ERROR conectando MySQL: ' . $e->getMessage(), $logFp);
exit(1);
}
$charset = $cfg['firebird']['charset'];
// ── Función auxiliar: ¿ejecutar este paso? ──────────────────────────────────
function shouldRun(string $paso, ?string $only): bool {
return $only === null || $only === $paso;
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 1 — SECCION → lab_secciones
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('secciones', $onlyPaso)) {
etlLog('--- SECCION → lab_secciones ---', $logFp);
$total = fbCount($fb, 'SECCION');
etlLog(" Origen: $total registros", $logFp);
$rows = [];
foreach ($fb->query('SELECT CODSECCION, NOMBSECCION FROM SECCION') as $r) {
$rows[] = [
'codigo' => cleanStr($r['CODSECCION'], $charset),
'nombre' => cleanStr($r['NOMBSECCION'], $charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_secciones', $rows, true);
etlLog(" Insertados: $n", $logFp);
} else {
etlLog(" [DRY] Se insertarían " . count($rows), $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 2 — ESPECIALIDAD → lab_especialidades
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('especialidades', $onlyPaso)) {
etlLog('--- ESPECIALIDAD → lab_especialidades ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT CODESPECIA, NOMBESPECIA FROM ESPECIALIDAD') as $r) {
$rows[] = [
'codigo' => cleanStr($r['CODESPECIA'], $charset),
'nombre' => cleanStr($r['NOMBESPECIA'], $charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_especialidades', $rows, true);
etlLog(" Insertados: $n / " . count($rows), $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 3 — EXAMEN.TIPOMUESTRA → lab_tipos_muestra
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('tipos_muestra', $onlyPaso)) {
etlLog('--- EXAMEN.TIPOMUESTRA → lab_tipos_muestra ---', $logFp);
$rows = [];
$seen = [];
foreach ($fb->query('SELECT DISTINCT TIPOMUESTRA FROM EXAMEN WHERE TIPOMUESTRA IS NOT NULL') as $r) {
$cod = cleanStr($r['TIPOMUESTRA'], $charset);
if (!$cod || isset($seen[$cod])) continue;
$seen[$cod] = true;
$rows[] = [
'codigo' => $cod,
'nombre' => ucwords(strtolower($cod)), // nombre provisional, editar luego
];
}
etlLog(" Tipos únicos encontrados: " . count($rows), $logFp);
if (!$dryRun) {
$n = batchInsert($my, 'lab_tipos_muestra', $rows, true);
etlLog(" Insertados: $n", $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 4 — TARIFAID → lab_tarifas_id
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('tarifas', $onlyPaso)) {
etlLog('--- TARIFAID → lab_tarifas_id ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT IDTARIFA, NOMTARIFA, IDTARIFABASE, PORCENTAJE FROM TARIFAID') as $r) {
$rows[] = [
'id' => (int)$r['IDTARIFA'],
'nombre' => cleanStr($r['NOMTARIFA'], $charset),
'tarifa_origen' => $r['IDTARIFABASE'] ? (int)$r['IDTARIFABASE'] : null,
'porcentaje' => (float)($r['PORCENTAJE'] ?? 0),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_tarifas_id', $rows, true);
etlLog(" Insertados: $n / " . count($rows), $logFp);
}
// ── TARIFA → lab_tarifas (104k registros, se hace en batches) ────────
etlLog('--- TARIFA → lab_tarifas ---', $logFp);
$total = fbCount($fb, 'TARIFA');
etlLog(" Origen: $total registros", $logFp);
$stmt = $fb->query('SELECT CODIGO, IDTARIFA, VALOR, RECARGO_URG, RECARGO_FES, RECARGO_ESP FROM TARIFA');
$batch = [];
$count = 0;
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$batch[] = [
'cod_examen_legacy' => cleanStr($r['CODIGO'], $charset),
'exam_tipo_id' => null, // se resuelve en paso 6 (exam_tipos)
'tarifa_id' => (int)$r['IDTARIFA'],
'valor' => (float)($r['VALOR'] ?? 0),
'recargo_urg' => (float)($r['RECARGO_URG'] ?? 0),
'recargo_fes' => (float)($r['RECARGO_FES'] ?? 0),
'recargo_esp' => (float)($r['RECARGO_ESP'] ?? 0),
];
if (count($batch) >= 500) {
if (!$dryRun) $count += batchInsert($my, 'lab_tarifas', $batch, true);
$batch = [];
}
}
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_tarifas', $batch, true);
etlLog(" Insertados: $count", $logFp);
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 5 — PROTOCOLO + ITEM → lab_protocolos + lab_items_resultado
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('protocolos', $onlyPaso)) {
etlLog('--- PROTOCOLO → lab_protocolos ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT CODPROTOCOLO, NOMPROTOCOLO, CODSECCION, IDPLANILLA, ONLYITEMS FROM PROTOCOLO') as $r) {
$rows[] = [
'codigo' => cleanStr($r['CODPROTOCOLO'], $charset),
'nombre' => cleanStr($r['NOMPROTOCOLO'], $charset),
'cod_seccion' => cleanStr($r['CODSECCION'], $charset),
'id_planilla' => cleanStr($r['IDPLANILLA'], $charset),
'only_show_items' => $r['ONLYITEMS'] ? 1 : 0,
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_protocolos', $rows, true);
etlLog(" Protocolos insertados: $n / " . count($rows), $logFp);
}
}
if (shouldRun('items', $onlyPaso)) {
etlLog('--- ITEM → lab_items_resultado ---', $logFp);
$total = fbCount($fb, 'ITEM');
etlLog(" Origen: $total registros", $logFp);
$stmt = $fb->query('SELECT CODPROTOCOLO,NOMITEM,TIPOSEXO,TIPO,MEDIDA,ABREVITEM,VMINREF,VMAXREF,ORDEN,FORMULA,CUPS FROM ITEM ORDER BY CODPROTOCOLO, ORDEN');
$batch = [];
$count = 0;
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$batch[] = [
'cod_protocolo' => cleanStr($r['CODPROTOCOLO'], $charset),
'nombre' => cleanStr($r['NOMITEM'], $charset),
'tipo_sexo' => cleanStr($r['TIPOSEXO'], $charset),
'tipo' => cleanStr($r['TIPO'], $charset),
'medida' => cleanStr($r['MEDIDA'], $charset),
'abreviatura' => cleanStr($r['ABREVITEM'], $charset),
'vmin_ref' => is_numeric($r['VMINREF']) ? (float)$r['VMINREF'] : null,
'vmax_ref' => is_numeric($r['VMAXREF']) ? (float)$r['VMAXREF'] : null,
'orden' => (int)($r['ORDEN'] ?? 0),
'formula' => cleanStr($r['FORMULA'], $charset),
'cups_detalle' => cleanStr($r['CUPS'], $charset),
];
if (count($batch) >= 500) {
if (!$dryRun) $count += batchInsert($my, 'lab_items_resultado', $batch, true);
$batch = [];
}
}
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_items_resultado', $batch, true);
etlLog(" Insertados: $count", $logFp);
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 6 — PERFIL + PERFIL_EXA → lab_perfiles + lab_perfil_examenes
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('perfiles', $onlyPaso)) {
etlLog('--- PERFIL → lab_perfiles ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT CODPERFIL, NOMPERFIL FROM PERFIL') as $r) {
$rows[] = [
'nombre' => cleanStr($r['NOMPERFIL'], $charset),
];
}
// Firebird usa VARCHAR código; MySQL usa INT AUTO_INCREMENT
// Guardamos el código viejo → id nuevo en memoria para PERFIL_EXA
if (!$dryRun) {
// Insertar uno a uno para mapear código → id nuevo
$mapaPerfiles = [];
foreach ($fb->query('SELECT CODPERFIL, NOMPERFIL FROM PERFIL') as $r) {
$stmt2 = $my->prepare('INSERT IGNORE INTO lab_perfiles (nombre) VALUES (?)');
$stmt2->execute([cleanStr($r['NOMPERFIL'], $charset)]);
$newId = (int)$my->lastInsertId();
if ($newId) $mapaPerfiles[cleanStr($r['CODPERFIL'], $charset)] = $newId;
}
etlLog(" Perfiles insertados: " . count($mapaPerfiles), $logFp);
// PERFIL_EXA — mapear cod_examen → exam_tipo_id
etlLog('--- PERFIL_EXA → lab_perfil_examenes ---', $logFp);
$examMap = [];
foreach ($my->query('SELECT id, codigo_legacy FROM exam_tipos WHERE codigo_legacy IS NOT NULL') as $r) {
$examMap[$r['codigo_legacy']] = (int)$r['id'];
}
$peBatch = [];
foreach ($fb->query('SELECT CODPERFIL, CODEXAMEN FROM PERFIL_EXA') as $r) {
$pId = $mapaPerfiles[cleanStr($r['CODPERFIL'], $charset)] ?? null;
$eId = $examMap[cleanStr($r['CODEXAMEN'], $charset)] ?? null;
if ($pId && $eId) {
$peBatch[] = ['perfil_id' => $pId, 'exam_tipo_id' => $eId];
}
}
$n = batchInsert($my, 'lab_perfil_examenes', $peBatch, true);
etlLog(" Relaciones perfil-examen: $n", $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 7 — EXAMEN → exam_tipos (ampliar con campos legacy)
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('exam_tipos', $onlyPaso)) {
etlLog('--- EXAMEN → exam_tipos ---', $logFp);
$total = fbCount($fb, 'EXAMEN');
etlLog(" Origen: $total registros", $logFp);
$insertados = 0;
$actualizados = 0;
$stmt = $fb->query(
'SELECT CODIGO, NOMEXAMEN, CODPROT, TIPOMUESTRA, NIVEL, CUPS,
ABREVEXAMEN, SEREMITE, SERECIBE, CODSECCION
FROM EXAMEN'
);
$checkStmt = $my->prepare('SELECT id FROM exam_tipos WHERE codigo_legacy = ?');
$updStmt = $my->prepare(
'UPDATE exam_tipos SET
cod_protocolo = ?, tipo_muestra = ?, nivel = ?, cups = ?,
abreviatura = ?, seremite = ?, serecibe = ?
WHERE codigo_legacy = ?'
);
$insStmt = $my->prepare(
'INSERT IGNORE INTO exam_tipos
(codigo, nombre, categoria, codigo_legacy, cod_protocolo,
tipo_muestra, nivel, cups, abreviatura, seremite, serecibe)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$codLeg = cleanStr($r['CODIGO'], $charset);
$nombre = cleanStr($r['NOMEXAMEN'], $charset);
$proto = cleanStr($r['CODPROT'], $charset);
$tMuest = cleanStr($r['TIPOMUESTRA'], $charset);
$nivel = $r['NIVEL'] ? (int)$r['NIVEL'] : null;
$cups = cleanStr($r['CUPS'], $charset);
$abrev = cleanStr($r['ABREVEXAMEN'], $charset);
$serem = $r['SEREMITE'] ? 1 : 0;
$serec = cleanStr($r['SERECIBE'], $charset);
$seccion = cleanStr($r['CODSECCION'], $charset);
if (!$codLeg || !$nombre) continue;
$checkStmt->execute([$codLeg]);
$existing = $checkStmt->fetchColumn();
if ($dryRun) continue;
if ($existing) {
$updStmt->execute([$proto, $tMuest, $nivel, $cups, $abrev, $serem, $serec, $codLeg]);
$actualizados++;
} else {
// Usar el código Firebird como código del nuevo sistema (si no hay conflicto)
$insStmt->execute([$codLeg, $nombre, $seccion, $codLeg, $proto, $tMuest, $nivel, $cups, $abrev, $serem, $serec]);
$insertados++;
}
}
etlLog(" Nuevos: $insertados | Actualizados: $actualizados", $logFp);
// Resolver exam_tipo_id en lab_tarifas (ahora que los exámenes ya están)
if (!$dryRun) {
etlLog(' Resolviendo exam_tipo_id en lab_tarifas...', $logFp);
$updated = $my->exec(
'UPDATE lab_tarifas t
JOIN exam_tipos e ON e.codigo_legacy = t.cod_examen_legacy
SET t.exam_tipo_id = e.id
WHERE t.exam_tipo_id IS NULL'
);
etlLog(" lab_tarifas actualizadas: $updated filas", $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 8 — EMPRESA + EMPRESA_SUB + EXAMEN_EMP
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('empresas', $onlyPaso)) {
etlLog('--- EMPRESA → lab_empresas ---', $logFp);
$rows = [];
foreach ($fb->query(
'SELECT NIT,NOMBRE,RAZSOCIAL,IDTARIFA,DESCUENTO,CODEEPS,
TIPOUSUARIO,TIPOUSUARIOSISPRO,CODCONTRATO,CODTERCERO,
CENTROCOSTO,REQAUTORIZA,ACTIVA
FROM EMPRESA'
) as $r) {
$rows[] = [
'nit' => cleanStr($r['NIT'], $charset),
'nombre' => cleanStr($r['NOMBRE'], $charset),
'razon_social' => cleanStr($r['RAZSOCIAL'], $charset),
'tarifa_id' => $r['IDTARIFA'] ? (int)$r['IDTARIFA'] : null,
'descuento_pct' => (float)($r['DESCUENTO'] ?? 0),
'codigo_eps' => cleanStr($r['CODEEPS'], $charset),
'tipo_usuario' => cleanStr($r['TIPOUSUARIO'], $charset),
'tipo_usuario_sispro' => cleanStr($r['TIPOUSUARIOSISPRO'],$charset),
'cod_contrato' => cleanStr($r['CODCONTRATO'], $charset),
'cod_tercero' => cleanStr($r['CODTERCERO'], $charset),
'centro_costo' => cleanStr($r['CENTROCOSTO'], $charset),
'req_autoriza' => $r['REQAUTORIZA'] ? 1 : 0,
'activa' => $r['ACTIVA'] ? 1 : 0,
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_empresas', $rows, true);
etlLog(" Empresas: $n / " . count($rows), $logFp);
}
etlLog('--- EMPRESA_SUB → lab_empresa_subgrupos ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT NIT,SUBGRUPO,IDTARIFA,REF_SUBGRUPO,CODCONTRATO FROM EMPRESA_SUB') as $r) {
$rows[] = [
'nit_empresa' => cleanStr($r['NIT'], $charset),
'subgrupo' => cleanStr($r['SUBGRUPO'], $charset),
'tarifa_id' => $r['IDTARIFA'] ? (int)$r['IDTARIFA'] : null,
'ref_subgrupo' => cleanStr($r['REF_SUBGRUPO'],$charset),
'cod_contrato' => cleanStr($r['CODCONTRATO'], $charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_empresa_subgrupos', $rows, true);
etlLog(" Subgrupos: $n", $logFp);
}
etlLog('--- EXAMEN_EMP → lab_examenes_empresa ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT NIT,CODIGO,CODIGOEMP FROM EXAMEN_EMP') as $r) {
$rows[] = [
'nit_empresa' => cleanStr($r['NIT'], $charset),
'cod_examen_legacy' => cleanStr($r['CODIGO'], $charset),
'exam_tipo_id' => null,
'codigo_empresa' => cleanStr($r['CODIGOEMP'],$charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_examenes_empresa', $rows, true);
// Resolver exam_tipo_id
$my->exec(
'UPDATE lab_examenes_empresa ee
JOIN exam_tipos e ON e.codigo_legacy = ee.cod_examen_legacy
SET ee.exam_tipo_id = e.id
WHERE ee.exam_tipo_id IS NULL'
);
etlLog(" Examenes-empresa: $n", $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 9 — MEDICO → medicos
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('medicos', $onlyPaso)) {
etlLog('--- MEDICO → medicos ---', $logFp);
$rows = [];
foreach ($fb->query(
'SELECT CODMEDICO,NOMBRES,APELLIDOS,CODESPECIA,TELEFONO1,EMAIL,DOCIDMEDICO,ACTIVO
FROM MEDICO'
) as $r) {
$rows[] = [
'codigo' => cleanStr($r['CODMEDICO'], $charset),
'nombres' => cleanStr($r['NOMBRES'], $charset),
'apellidos' => cleanStr($r['APELLIDOS'], $charset),
'cod_especialidad'=> cleanStr($r['CODESPECIA'], $charset),
'telefonos' => cleanStr($r['TELEFONO1'], $charset),
'email' => cleanStr($r['EMAIL'], $charset),
'docidmedico' => cleanStr($r['DOCIDMEDICO'], $charset),
'activo' => $r['ACTIVO'] ? 1 : 0,
];
}
if (!$dryRun) {
$n = batchInsert($my, 'medicos', $rows, true);
etlLog(" Médicos: $n / " . count($rows), $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 10 — PACIENTE → lab_pacientes
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('pacientes', $onlyPaso)) {
etlLog('--- PACIENTE → lab_pacientes ---', $logFp);
$total = fbCount($fb, 'PACIENTE');
etlLog(" Origen: $total registros", $logFp);
$stmt = $fb->query(
'SELECT CODPAC,DOCIDENT,TIPOIDENT,NOMBRES,APELLIDOS,
TELEFONO1,EMAIL,FECHANAC,SEXO,CIUDAD,
OCUPACION,CODETNIA,TIPORES
FROM PACIENTE
ORDER BY CODPAC'
);
$checkStmt = $my->prepare('SELECT id FROM lab_pacientes WHERE numero_documento = ?');
$insStmt = $my->prepare(
'INSERT IGNORE INTO lab_pacientes
(numero_documento, tipo_documento, nombre_completo, telefono,
email, fecha_nacimiento, genero, ciudad,
ocupacion, codetnia, tipores, codigo_legacy, es_historico)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1)'
);
$updLegacy = $my->prepare(
'UPDATE lab_pacientes SET codigo_legacy = ? WHERE numero_documento = ? AND codigo_legacy IS NULL'
);
$nuevos = $coincidentes = 0;
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$doc = cleanStr($r['DOCIDENT'], $charset);
if (!$doc) continue;
$nombres = cleanStr($r['NOMBRES'], $charset);
$apellidos = cleanStr($r['APELLIDOS'], $charset);
$nombre = trim("$nombres $apellidos");
if ($dryRun) continue;
$checkStmt->execute([$doc]);
$existeId = $checkStmt->fetchColumn();
if ($existeId) {
// Ya existe → solo actualizar codigo_legacy si falta
$updLegacy->execute([cleanStr($r['CODPAC'], $charset), $doc]);
$coincidentes++;
} else {
$tipoDoc = match(strtoupper(cleanStr($r['TIPOIDENT'], $charset) ?? '')) {
'CC' => 'CC',
'CE' => 'CE',
'TI' => 'TI',
'PA' => 'PA',
'NIT' => 'NIT',
'RC' => 'RC',
'MS' => 'MS',
default => 'CC',
};
$genero = match(strtoupper(cleanStr($r['SEXO'], $charset) ?? '')) {
'M' => 'M', 'F' => 'F', default => null
};
$insStmt->execute([
$doc,
$tipoDoc,
$nombre ?: 'Sin nombre',
cleanStr($r['TELEFONO1'], $charset),
cleanStr($r['EMAIL'], $charset),
fbDate($r['FECHANAC']),
$genero,
cleanStr($r['CIUDAD'], $charset),
cleanStr($r['OCUPACION'], $charset),
cleanStr($r['CODETNIA'], $charset),
cleanStr($r['TIPORES'], $charset),
cleanStr($r['CODPAC'], $charset),
]);
$nuevos++;
}
}
etlLog(" Nuevos: $nuevos | Coincidentes (codigo_legacy actualizado): $coincidentes", $logFp);
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 11-13 — Histórico transaccional (RECEPCION / RELACION / PAGOS)
// ════════════════════════════════════════════════════════════════════════════
if (!$skipHist) {
// ── 11. RECEPCION → lab_recepciones ──────────────────────────────────
if (shouldRun('recepciones', $onlyPaso)) {
etlLog('--- RECEPCION → lab_recepciones ---', $logFp);
$total = fbCount($fb, 'RECEPCION');
etlLog(" Origen: $total registros", $logFp);
// Mapa paciente legacy → id nuevo
$pacMap = [];
foreach ($my->query('SELECT id, codigo_legacy FROM lab_pacientes WHERE codigo_legacy IS NOT NULL') as $r) {
$pacMap[$r['codigo_legacy']] = (int)$r['id'];
}
// Mapa médico legacy → id nuevo
$medMap = [];
foreach ($my->query('SELECT id, codigo FROM medicos WHERE codigo IS NOT NULL') as $r) {
$medMap[$r['codigo']] = (int)$r['id'];
}
$stmt = $fb->query(
'SELECT IDRECEPCION,CODPAC,CODMEDICO,NIT,SUBGRUPO,
FECHA,HORAINICIO,PREFIJO,NUMFACTURA,
VALORTOTAL,VALORDESC,DIAGPPAL,TIPOUSUARIO,AUTORIZACION,USUARIO
FROM RECEPCION
ORDER BY IDRECEPCION'
);
$batch = [];
$count = 0;
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$codPac = cleanStr($r['CODPAC'], $charset);
$codMed = cleanStr($r['CODMEDICO'],$charset);
$batch[] = [
'id' => (int)$r['IDRECEPCION'],
'cod_paciente_legacy' => $codPac,
'paciente_id' => $pacMap[$codPac] ?? null,
'cod_medico_legacy' => $codMed,
'medico_id' => $medMap[$codMed] ?? null,
'nit_empresa' => cleanStr($r['NIT'], $charset),
'subgrupo' => cleanStr($r['SUBGRUPO'], $charset),
'fecha_recepcion' => fbDate($r['FECHA']),
'hora_inicio' => cleanStr($r['HORAINICIO'], $charset),
'prefijo' => cleanStr($r['PREFIJO'], $charset),
'num_factura' => $r['NUMFACTURA'] ? (int)$r['NUMFACTURA'] : null,
'valor_total' => (float)($r['VALORTOTAL'] ?? 0),
'valor_desc' => (float)($r['VALORDESC'] ?? 0),
'diag_ppal' => cleanStr($r['DIAGPPAL'], $charset),
'tipo_usuario' => cleanStr($r['TIPOUSUARIO'], $charset),
'autorizacion' => cleanStr($r['AUTORIZACION'], $charset),
'usuario' => cleanStr($r['USUARIO'], $charset),
];
if (count($batch) >= 500) {
if (!$dryRun) $count += batchInsert($my, 'lab_recepciones', $batch, true);
$batch = [];
if ($count % 5000 === 0) etlLog(" ... $count procesadas", $logFp);
}
}
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_recepciones', $batch, true);
etlLog(" Insertadas: $count", $logFp);
}
// ── 12. RELACION → lab_relaciones ─────────────────────────────────────
if (shouldRun('relaciones', $onlyPaso)) {
etlLog('--- RELACION → lab_relaciones ---', $logFp);
$total = fbCount($fb, 'RELACION');
etlLog(" Origen: $total registros", $logFp);
// Mapa cod_examen_legacy → exam_tipo_id
$examMap = [];
foreach ($my->query('SELECT id, codigo_legacy FROM exam_tipos WHERE codigo_legacy IS NOT NULL') as $r) {
$examMap[$r['codigo_legacy']] = (int)$r['id'];
}
$stmt = $fb->query(
'SELECT IDRECEPCION,CODIGO,PRECIO,FECHAREPORT,REPORTADO,REPORPOR,VALIDADO,USRVALIDA,FECHAVALIDA
FROM RELACION'
);
$batch = [];
$count = 0;
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$codExam = cleanStr($r['CODIGO'], $charset);
$batch[] = [
'recepcion_id' => (int)$r['IDRECEPCION'],
'cod_examen_legacy' => $codExam,
'exam_tipo_id' => $examMap[$codExam] ?? null,
'precio' => (float)($r['PRECIO'] ?? 0),
'fecha_reportado' => fbDate($r['FECHAREPORT']),
'reportado' => $r['REPORTADO'] ? 1 : 0,
'reportado_por' => cleanStr($r['REPORPOR'], $charset),
'validado' => $r['VALIDADO'] ? 1 : 0,
'usuario_valida' => cleanStr($r['USRVALIDA'], $charset),
'fecha_valida' => fbDate($r['FECHAVALIDA']),
];
if (count($batch) >= 500) {
if (!$dryRun) $count += batchInsert($my, 'lab_relaciones', $batch, true);
$batch = [];
if ($count % 10000 === 0) etlLog(" ... $count", $logFp);
}
}
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_relaciones', $batch, true);
etlLog(" Insertadas: $count", $logFp);
}
// ── 13. PAGOS + PAGOS_DET ─────────────────────────────────────────────
if (shouldRun('pagos', $onlyPaso)) {
etlLog('--- PAGOS → lab_pagos ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT NUMCAJA,IDRECEPCION,VALOR,FECHAPAGO,USUARIO FROM PAGOS') as $r) {
$rows[] = [
'numcaja_legacy' => (int)$r['NUMCAJA'],
'recepcion_id' => (int)$r['IDRECEPCION'],
'valor' => (float)($r['VALOR'] ?? 0),
'fecha' => fbDate($r['FECHAPAGO']),
'usuario' => cleanStr($r['USUARIO'], $charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_pagos', $rows, true);
etlLog(" Pagos: $n / " . count($rows), $logFp);
}
etlLog('--- PAGOS_DET → lab_pagos_det ---', $logFp);
// Mapa numcaja → id nuevo
$pagoMap = [];
if (!$dryRun) {
foreach ($my->query('SELECT id, numcaja_legacy FROM lab_pagos') as $r) {
$pagoMap[$r['numcaja_legacy']] = (int)$r['id'];
}
}
$rows = [];
foreach ($fb->query('SELECT NUMCAJA,TIPOPAGO,VALOR,NUMDOC FROM PAGOS_DET') as $r) {
$pagoId = $pagoMap[(int)$r['NUMCAJA']] ?? null;
if (!$pagoId) continue;
$rows[] = [
'pago_id' => $pagoId,
'tipo_pago' => cleanStr($r['TIPOPAGO'], $charset),
'valor' => (float)($r['VALOR'] ?? 0),
'num_doc' => cleanStr($r['NUMDOC'], $charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_pagos_det', $rows, true);
etlLog(" Detalles de pago: $n", $logFp);
}
}
} else {
etlLog('--- Histórico omitido (--skip-historico) ---', $logFp);
}
// ── Restaurar FK checks ──────────────────────────────────────────────────────
if (!$dryRun) {
$my->exec("SET foreign_key_checks = 1");
}
etlLog('=== ETL COMPLETADO ===', $logFp);
fclose($logFp);
echo "Log guardado en: {$cfg['options']['log_file']}\n";
@@ -0,0 +1,37 @@
<?php
/**
* Migración: registra lab_tomas_config en system_modules
* Ejecutar: php migrations/run_20260710_lab_tomas_config_module.php
*/
require_once __DIR__ . '/../config/config.php';
$pdo = Database::getInstance()->getConnection();
// Buscar el sort_order máximo en la categoría 'clinico' para poner al final
$row = $pdo->query("SELECT MAX(sort_order) AS mx FROM system_modules WHERE category = 'clinico'")->fetch(PDO::FETCH_ASSOC);
$nextOrder = (int)($row['mx'] ?? 50) + 10;
$stmt = $pdo->prepare("
INSERT INTO system_modules (slug, name, icon, category, route, is_active, sort_order, description)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
icon = VALUES(icon),
category = VALUES(category),
route = VALUES(route),
is_active = 1,
sort_order = VALUES(sort_order),
description = VALUES(description)
");
$stmt->execute([
'lab_tomas_config',
'Tipos de Examen (Tomas)',
'fas fa-vials',
'clinico',
'/lab_tomas_config.php',
$nextOrder,
'Configurar tipos de examen y ciclos de tomas prolongadas (F-LAB-28)',
]);
echo "✅ Módulo lab_tomas_config registrado en system_modules (sort_order=$nextOrder).\n";
+3
View File
@@ -12,6 +12,9 @@ $pendientes = [
'20260703_medicos.sql', '20260703_medicos.sql',
'20260703_medicos_seed.sql', '20260703_medicos_seed.sql',
'20260703_solicitud_medico.sql', '20260703_solicitud_medico.sql',
'20260707_turnero_consent_origen_lugar.sql',
'20260716_lab_eps_ciudades.sql',
'20260716_numero_recibo_tarjeta.sql',
]; ];
$pdo = Database::getInstance()->getConnection(); $pdo = Database::getInstance()->getConnection();
+12
View File
@@ -0,0 +1,12 @@
<?php return [
'slug' => 'lab_ciudades',
'name' => 'Ciudades',
'icon' => 'fas fa-map-marker-alt',
'category' => 'lab',
'route' => '/lab_ciudades.php',
'is_active' => true,
'sort_order' => 23,
'oleada' => 0,
'description' => 'Gestión de ciudades de pacientes',
'links' => [['name' => 'Ciudades', 'icon' => 'fas fa-map-marker-alt', 'route' => '/lab_ciudades.php']],
];
+149
View File
@@ -0,0 +1,149 @@
<?php
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
requireRole('admin');
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Ciudades — <?= htmlspecialchars($_cfg['empresa_nombre'] ?? 'ERP') ?></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="<?= BASE_URL ?>assets/css/styles.css?v=15" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
.ciu-table { width:100%; border-collapse:collapse; font-size:.9rem; }
.ciu-table th { background:#f8fafc; font-weight:700; font-size:.75rem; text-transform:uppercase;
letter-spacing:.06em; color:#64748b; padding:.6rem 1rem; border-bottom:2px solid #e2e8f0; }
.ciu-table td { padding:.65rem 1rem; border-bottom:1px solid #f1f5f9; vertical-align:middle; }
.ciu-table tr:hover td { background:#f8fafc; }
.badge-activa { background:#dcfce7; color:#166534; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:99px; }
.badge-inactiva { background:#f1f5f9; color:#94a3b8; font-size:.7rem; font-weight:700; padding:2px 8px; border-radius:99px; }
</style>
</head>
<body>
<?php include APP_ROOT . '/partials/navbar.php'; ?>
<div class="container-fluid py-4" style="max-width:720px">
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0"><i class="fas fa-map-marker-alt me-2 text-primary"></i>Ciudades</h5>
<button class="btn btn-primary btn-sm" onclick="abrirModal()">
<i class="fas fa-plus me-1"></i>Nueva ciudad
</button>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="ciu-table">
<thead>
<tr>
<th>Nombre</th>
<th style="width:90px">Estado</th>
<th style="width:110px">Acciones</th>
</tr>
</thead>
<tbody id="tbody-ciu">
<tr><td colspan="3" class="text-muted text-center py-3">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="modal-ciu" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title" id="modal-ciu-titulo">Nueva ciudad</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="ciu-id">
<div class="mb-3">
<label class="form-label small fw-semibold">Nombre <span class="text-danger">*</span></label>
<input type="text" id="ciu-nombre" class="form-control" maxlength="100" placeholder="Ej: Cúcuta, Bogotá…">
</div>
<div id="ciu-error" class="text-danger small d-none"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-primary btn-sm" onclick="guardar()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
</div>
</div>
<script>
const API = '<?= BASE_URL ?>api/lab/ciudades.php';
let _modal;
async function cargar() {
const r = await fetch(API + '?action=list');
const j = await r.json();
const tbody = document.getElementById('tbody-ciu');
if (!j.ok || !j.ciudades.length) {
tbody.innerHTML = '<tr><td colspan="3" class="text-muted text-center py-3">Sin registros</td></tr>';
return;
}
tbody.innerHTML = j.ciudades.map(c => `
<tr>
<td>${escHtml(c.nombre)}</td>
<td><span class="badge-${c.activa == 1 ? 'activa' : 'inactiva'}">${c.activa == 1 ? 'Activa' : 'Inactiva'}</span></td>
<td class="d-flex gap-1">
<button class="btn btn-outline-secondary btn-sm" onclick="abrirModal(${c.id},'${escHtml(c.nombre).replace(/'/g,"\\'")}')"><i class="fas fa-pen"></i></button>
<button class="btn btn-outline-${c.activa == 1 ? 'warning' : 'success'} btn-sm" onclick="toggle(${c.id})"><i class="fas fa-${c.activa == 1 ? 'ban' : 'check'}"></i></button>
<button class="btn btn-outline-danger btn-sm" onclick="eliminar(${c.id},'${escHtml(c.nombre).replace(/'/g,"\\'")}')"><i class="fas fa-trash"></i></button>
</td>
</tr>
`).join('');
}
function abrirModal(id = 0, nombre = '') {
document.getElementById('ciu-id').value = id;
document.getElementById('ciu-nombre').value = nombre;
document.getElementById('ciu-error').classList.add('d-none');
document.getElementById('modal-ciu-titulo').textContent = id ? 'Editar ciudad' : 'Nueva ciudad';
_modal = _modal || new bootstrap.Modal(document.getElementById('modal-ciu'));
_modal.show();
setTimeout(() => document.getElementById('ciu-nombre').focus(), 300);
}
async function guardar() {
const nombre = document.getElementById('ciu-nombre').value.trim();
const id = parseInt(document.getElementById('ciu-id').value) || 0;
const errEl = document.getElementById('ciu-error');
errEl.classList.add('d-none');
if (!nombre) { errEl.textContent = 'El nombre es requerido.'; errEl.classList.remove('d-none'); return; }
const r = await fetch(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'save', id, nombre}) });
const j = await r.json();
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
_modal.hide();
cargar();
}
async function toggle(id) {
await fetch(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'toggle', id}) });
cargar();
}
async function eliminar(id, nombre) {
if (!confirm(`¿Eliminar "${nombre}"?`)) return;
const r = await fetch(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'delete', id}) });
const j = await r.json();
if (!j.ok) { alert(j.error); return; }
cargar();
}
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str)));
return d.innerHTML;
}
document.getElementById('ciu-nombre').addEventListener('keydown', e => { if (e.key === 'Enter') guardar(); });
cargar();
</script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<?php
return [
'slug' => 'lab_empresas',
'name' => 'Empresas y Convenios',
'icon' => 'fas fa-building',
'category' => 'clinico',
'route' => '/erp.php?m=lab_empresas&v=index',
'is_active' => true,
'sort_order' => 65,
'oleada' => 2,
'description' => 'Gestión de empresas, EPS, convenios, subgrupos y tarifas.',
'links' => [
['name' => 'Empresas', 'icon' => 'fas fa-building', 'route' => '/erp.php?m=lab_empresas&v=index'],
['name' => 'Tarifas', 'icon' => 'fas fa-tags', 'route' => '/erp.php?m=lab_empresas&v=tarifas'],
],
];
+556
View File
@@ -0,0 +1,556 @@
<?php
/**
* modules/lab_empresas/views/index.php
* CRUD de empresas/convenios y sus subgrupos.
*/
require_once APP_ROOT . '/config/config.php';
Layout::open('Empresas y Convenios', 'fas fa-building');
?>
<div class="container-fluid py-3">
<!-- Encabezado -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<h4 class="mb-0"><i class="fas fa-building me-2 text-primary"></i>Empresas y Convenios</h4>
<small class="text-muted">EPS, IPS, convenios y particulares con tarifa especial</small>
</div>
<div class="d-flex gap-2">
<a href="<?= BASE_URL ?>/erp.php?m=lab_empresas&v=tarifas"
class="btn btn-outline-secondary btn-sm">
<i class="fas fa-tags me-1"></i>Tarifas
</a>
<button class="btn btn-primary btn-sm" onclick="abrirModal()">
<i class="fas fa-plus me-1"></i>Nueva empresa
</button>
</div>
</div>
<!-- Filtros -->
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-sm-5 col-md-4">
<input type="search" id="filtroSearch" class="form-control form-control-sm"
placeholder="Buscar por nombre, NIT…" oninput="debounceCargar()">
</div>
<div class="col-auto">
<select id="filtroActiva" class="form-select form-select-sm" onchange="cargarEmpresas()">
<option value="">Todas</option>
<option value="1" selected>Activas</option>
<option value="0">Inactivas</option>
</select>
</div>
<div class="col-auto ms-auto">
<span class="text-muted small" id="lblTotal"></span>
</div>
</div>
</div>
</div>
<!-- Tabla -->
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0" id="tablaEmpresas">
<thead class="table-light">
<tr>
<th>NIT</th>
<th>Nombre</th>
<th>Tarifa</th>
<th class="text-end">Dcto%</th>
<th class="text-center">Subgrupos</th>
<th class="text-center">Autoriza</th>
<th class="text-center">Estado</th>
<th></th>
</tr>
</thead>
<tbody id="tbodyEmpresas">
<tr><td colspan="8" class="text-center py-4 text-muted">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Paginación -->
<div class="card-footer d-flex justify-content-between align-items-center py-1">
<button class="btn btn-outline-secondary btn-sm" id="btnPrev" onclick="cambiarPagina(-1)">
<i class="fas fa-chevron-left"></i>
</button>
<span class="small text-muted" id="lblPagina"></span>
<button class="btn btn-outline-secondary btn-sm" id="btnNext" onclick="cambiarPagina(1)">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
Modal empresa (crear / editar)
═══════════════════════════════════════════════════════════ -->
<div class="modal fade" id="modalEmpresa" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalEmpresaTitulo">Nueva empresa</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="formEmpresa" novalidate>
<input type="hidden" id="fNitOrig" value="">
<!-- Fila 1: NIT + Nombre -->
<div class="row g-3 mb-3">
<div class="col-sm-4">
<label class="form-label fw-semibold small">NIT <span class="text-danger">*</span></label>
<input type="text" id="fNit" class="form-control form-control-sm"
placeholder="900123456-7" maxlength="20" required>
</div>
<div class="col-sm-8">
<label class="form-label fw-semibold small">Nombre <span class="text-danger">*</span></label>
<input type="text" id="fNombre" class="form-control form-control-sm"
placeholder="Nombre de la empresa" maxlength="200" required>
</div>
</div>
<!-- Fila 2: Razón social -->
<div class="mb-3">
<label class="form-label fw-semibold small">Razón social</label>
<input type="text" id="fRazonSocial" class="form-control form-control-sm"
placeholder="Razón social completa" maxlength="200">
</div>
<!-- Fila 3: Tarifa + Descuento -->
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label fw-semibold small">Tarifa</label>
<select id="fTarifaId" class="form-select form-select-sm">
<option value="">— Sin tarifa especial —</option>
</select>
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold small">Descuento %</label>
<input type="number" id="fDescuentoPct" class="form-control form-control-sm"
value="0" min="0" max="100" step="0.01">
</div>
<div class="col-sm-3">
<label class="form-label fw-semibold small">Cód. EPS</label>
<input type="text" id="fCodigoEps" class="form-control form-control-sm"
placeholder="EPS001" maxlength="20">
</div>
</div>
<!-- Fila 4: Tipo usuario -->
<div class="row g-3 mb-3">
<div class="col-sm-4">
<label class="form-label fw-semibold small">Tipo usuario</label>
<select id="fTipoUsuario" class="form-select form-select-sm">
<option value="">—</option>
<option value="01">01 - Contributivo</option>
<option value="02">02 - Subsidiado</option>
<option value="03">03 - Vinculado</option>
<option value="04">04 - Particular</option>
<option value="05">05 - ARP</option>
<option value="06">06 - Póliza</option>
<option value="07">07 - Estudiante</option>
<option value="08">08 - Empleado</option>
</select>
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold small">Tipo usuario SISPRO</label>
<input type="text" id="fTipoUsuarioSispro" class="form-control form-control-sm"
placeholder="Código SISPRO" maxlength="10">
</div>
<div class="col-sm-4">
<label class="form-label fw-semibold small">Cod. tercero</label>
<input type="text" id="fCodTercero" class="form-control form-control-sm"
placeholder="Cód. tercero" maxlength="50">
</div>
</div>
<!-- Fila 5: Contrato / Centro costo -->
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label fw-semibold small">Cód. contrato</label>
<input type="text" id="fCodContrato" class="form-control form-control-sm"
placeholder="Número de contrato" maxlength="50">
</div>
<div class="col-sm-6">
<label class="form-label fw-semibold small">Centro de costo</label>
<input type="text" id="fCentroCosto" class="form-control form-control-sm"
placeholder="Centro de costo" maxlength="50">
</div>
</div>
<!-- Fila 6: Checks -->
<div class="row g-3 mb-3">
<div class="col-auto">
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" id="fReqAutoriza">
<label class="form-check-label small" for="fReqAutoriza">
Exige número de autorización
</label>
</div>
</div>
<div class="col-auto">
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" id="fActiva" checked>
<label class="form-check-label small" for="fActiva">Activa</label>
</div>
</div>
</div>
<!-- Mensaje de error -->
<div id="formError" class="alert alert-danger d-none py-2 small"></div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-primary btn-sm" id="btnGuardarEmpresa" onclick="guardarEmpresa()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
Modal subgrupos
═══════════════════════════════════════════════════════════ -->
<div class="modal fade" id="modalSubgrupos" tabindex="-1">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-layer-group me-2 text-secondary"></i>
Subgrupos — <span id="subNitNombre"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="listaSubgrupos" class="mb-3"></div>
<hr>
<h6 class="small fw-semibold text-muted text-uppercase mb-2">Agregar / editar subgrupo</h6>
<input type="hidden" id="subId" value="">
<input type="hidden" id="subNitEmpresa" value="">
<div class="row g-2 mb-2">
<div class="col-12">
<input type="text" id="subNombre" class="form-control form-control-sm"
placeholder="Nombre del subgrupo" maxlength="100">
</div>
<div class="col-sm-6">
<select id="subTarifaId" class="form-select form-select-sm">
<option value="">— Tarifa de la empresa —</option>
</select>
</div>
<div class="col-sm-6">
<input type="text" id="subRefSubgrupo" class="form-control form-control-sm"
placeholder="Ref. subgrupo" maxlength="50">
</div>
<div class="col-12">
<input type="text" id="subCodContrato" class="form-control form-control-sm"
placeholder="Cód. contrato subgrupo" maxlength="50">
</div>
</div>
<div id="subError" class="alert alert-danger d-none py-2 small mb-2"></div>
<button class="btn btn-sm btn-primary w-100" onclick="guardarSubgrupo()">
<i class="fas fa-save me-1"></i>Guardar subgrupo
</button>
</div>
</div>
</div>
</div>
<style>
.badge-tarifa { font-size:.72rem; background:#e0f2fe; color:#0369a1; border-radius:4px; padding:2px 7px; }
.badge-activa { font-size:.72rem; }
.sub-row { background:#f8fafc; border-radius:6px; padding:8px 12px; margin-bottom:6px;
border:1px solid #e2e8f0; display:flex; align-items:center; gap:8px; }
.sub-row .sub-info { flex:1 }
.sub-row .sub-info .sub-nombre { font-weight:600; font-size:.9rem; }
.sub-row .sub-info .sub-meta { font-size:.78rem; color:#64748b; }
</style>
<script>
const BASE = '<?= BASE_URL ?>';
let _pagina = 1;
let _pages = 1;
let _tarifas = [];
let _modalEmpresa, _modalSubgrupos;
// ─── Init ─────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
_modalEmpresa = new bootstrap.Modal(document.getElementById('modalEmpresa'));
_modalSubgrupos = new bootstrap.Modal(document.getElementById('modalSubgrupos'));
cargarTarifas().then(() => cargarEmpresas());
});
// ─── Tarifas ──────────────────────────────────────────────────
async function cargarTarifas() {
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`);
const j = await res.json();
_tarifas = j.tarifas || [];
const opts = _tarifas.map(t =>
`<option value="${t.id}">${escHtml(t.nombre)}${t.porcentaje > 0 ? ` (+${t.porcentaje}%)` : ''}</option>`
).join('');
document.getElementById('fTarifaId').innerHTML = '<option value="">— Sin tarifa especial —</option>' + opts;
document.getElementById('subTarifaId').innerHTML = '<option value="">— Tarifa de la empresa —</option>' + opts;
}
// ─── Lista empresas ───────────────────────────────────────────
let _debTimer;
function debounceCargar() {
clearTimeout(_debTimer);
_debTimer = setTimeout(() => { _pagina = 1; cargarEmpresas(); }, 350);
}
async function cargarEmpresas() {
const search = document.getElementById('filtroSearch').value.trim();
const activa = document.getElementById('filtroActiva').value;
const params = new URLSearchParams({ action:'list', page:_pagina, limit:30 });
if (search) params.append('search', search);
if (activa !== '') params.append('activa', activa);
const tbody = document.getElementById('tbodyEmpresas');
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-3 text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando…</td></tr>';
const res = await fetch(`${BASE}/api/lab/empresas.php?${params}`);
const j = await res.json();
_pages = j.pages || 1;
document.getElementById('lblTotal').textContent = `${j.total} empresa(s)`;
document.getElementById('lblPagina').textContent = `Página ${_pagina} de ${_pages}`;
document.getElementById('btnPrev').disabled = _pagina <= 1;
document.getElementById('btnNext').disabled = _pagina >= _pages;
if (!j.empresas || !j.empresas.length) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-muted">Sin resultados</td></tr>';
return;
}
tbody.innerHTML = j.empresas.map(e => `
<tr>
<td class="text-monospace small fw-semibold">${escHtml(e.nit)}</td>
<td>
<div class="fw-semibold">${escHtml(e.nombre)}</div>
${e.razon_social ? `<div class="text-muted small">${escHtml(e.razon_social)}</div>` : ''}
</td>
<td>${e.tarifa_nombre ? `<span class="badge-tarifa">${escHtml(e.tarifa_nombre)}</span>` : '<span class="text-muted small">—</span>'}</td>
<td class="text-end small">${parseFloat(e.descuento_pct) > 0 ? escHtml(e.descuento_pct)+'%' : '—'}</td>
<td class="text-center">
${parseInt(e.total_subgrupos) > 0
? `<button class="btn btn-link btn-sm p-0 text-primary" onclick="abrirSubgrupos('${escAttr(e.nit)}','${escAttr(e.nombre)}')">${e.total_subgrupos} <i class="fas fa-layer-group ms-1"></i></button>`
: `<button class="btn btn-link btn-sm p-0 text-muted" onclick="abrirSubgrupos('${escAttr(e.nit)}','${escAttr(e.nombre)}')">+ subgrupo</button>`
}
</td>
<td class="text-center">${parseInt(e.req_autoriza) ? '<i class="fas fa-check-circle text-warning" title="Exige autorización"></i>' : '—'}</td>
<td class="text-center">
<span class="badge ${parseInt(e.activa) ? 'bg-success' : 'bg-secondary'} badge-activa">
${parseInt(e.activa) ? 'Activa' : 'Inactiva'}
</span>
</td>
<td class="text-end pe-2">
<button class="btn btn-outline-primary btn-sm py-0 px-2 me-1" onclick="abrirModal('${escAttr(e.nit)}')" title="Editar">
<i class="fas fa-pen"></i>
</button>
<button class="btn btn-outline-${parseInt(e.activa) ? 'warning' : 'success'} btn-sm py-0 px-2" onclick="toggleActiva('${escAttr(e.nit)}')" title="${parseInt(e.activa) ? 'Desactivar' : 'Activar'}">
<i class="fas fa-${parseInt(e.activa) ? 'ban' : 'check'}"></i>
</button>
</td>
</tr>
`).join('');
}
function cambiarPagina(delta) {
_pagina = Math.max(1, Math.min(_pages, _pagina + delta));
cargarEmpresas();
}
// ─── Modal empresa ────────────────────────────────────────────
async function abrirModal(nit = null) {
resetFormError();
document.getElementById('fNitOrig').value = nit || '';
document.getElementById('modalEmpresaTitulo').textContent = nit ? 'Editar empresa' : 'Nueva empresa';
document.getElementById('fNit').readOnly = !!nit;
// Reset
['fNit','fNombre','fRazonSocial','fCodigoEps','fTipoUsuarioSispro',
'fCodTercero','fCodContrato','fCentroCosto'].forEach(id => document.getElementById(id).value = '');
document.getElementById('fTarifaId').value = '';
document.getElementById('fTipoUsuario').value = '';
document.getElementById('fDescuentoPct').value= '0';
document.getElementById('fReqAutoriza').checked = false;
document.getElementById('fActiva').checked = true;
if (nit) {
const res = await fetch(`${BASE}/api/lab/empresas.php?action=get&nit=${encodeURIComponent(nit)}`);
const j = await res.json();
const e = j.empresa;
document.getElementById('fNit').value = e.nit;
document.getElementById('fNombre').value = e.nombre;
document.getElementById('fRazonSocial').value = e.razon_social || '';
document.getElementById('fTarifaId').value = e.tarifa_id || '';
document.getElementById('fDescuentoPct').value = e.descuento_pct || 0;
document.getElementById('fCodigoEps').value = e.codigo_eps || '';
document.getElementById('fTipoUsuario').value = e.tipo_usuario || '';
document.getElementById('fTipoUsuarioSispro').value = e.tipo_usuario_sispro || '';
document.getElementById('fCodTercero').value = e.cod_tercero || '';
document.getElementById('fCodContrato').value = e.cod_contrato || '';
document.getElementById('fCentroCosto').value = e.centro_costo || '';
document.getElementById('fReqAutoriza').checked = !!parseInt(e.req_autoriza);
document.getElementById('fActiva').checked = !!parseInt(e.activa);
}
_modalEmpresa.show();
}
async function guardarEmpresa() {
resetFormError();
const nit = document.getElementById('fNit').value.trim();
if (!nit) return showFormError('El NIT es obligatorio');
if (!document.getElementById('fNombre').value.trim()) return showFormError('El nombre es obligatorio');
const btn = document.getElementById('btnGuardarEmpresa');
btn.disabled = true;
const payload = {
action: 'save',
nit,
nombre: document.getElementById('fNombre').value.trim(),
razon_social: document.getElementById('fRazonSocial').value.trim(),
tarifa_id: document.getElementById('fTarifaId').value,
descuento_pct: document.getElementById('fDescuentoPct').value,
codigo_eps: document.getElementById('fCodigoEps').value.trim(),
tipo_usuario: document.getElementById('fTipoUsuario').value,
tipo_usuario_sispro: document.getElementById('fTipoUsuarioSispro').value.trim(),
cod_tercero: document.getElementById('fCodTercero').value.trim(),
cod_contrato: document.getElementById('fCodContrato').value.trim(),
centro_costo: document.getElementById('fCentroCosto').value.trim(),
req_autoriza: document.getElementById('fReqAutoriza').checked,
activa: document.getElementById('fActiva').checked,
};
try {
const res = await fetch(`${BASE}/api/lab/empresas.php`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
const j = await res.json();
if (!j.ok) return showFormError(j.error || 'Error al guardar');
_modalEmpresa.hide();
cargarEmpresas();
} catch(e) {
showFormError('Error de red: ' + e.message);
} finally {
btn.disabled = false;
}
}
async function toggleActiva(nit) {
await fetch(`${BASE}/api/lab/empresas.php`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({action:'toggle', nit})
});
cargarEmpresas();
}
function showFormError(msg) { const el = document.getElementById('formError'); el.textContent = msg; el.classList.remove('d-none'); }
function resetFormError() { document.getElementById('formError').classList.add('d-none'); }
// ─── Modal subgrupos ─────────────────────────────────────────
async function abrirSubgrupos(nit, nombre) {
document.getElementById('subNitNombre').textContent = nombre;
document.getElementById('subNitEmpresa').value = nit;
document.getElementById('subId').value = '';
['subNombre','subRefSubgrupo','subCodContrato'].forEach(id => document.getElementById(id).value = '');
document.getElementById('subTarifaId').value = '';
document.getElementById('subError').classList.add('d-none');
await recargarSubgrupos(nit);
_modalSubgrupos.show();
}
async function recargarSubgrupos(nit) {
if (!nit) nit = document.getElementById('subNitEmpresa').value;
const res = await fetch(`${BASE}/api/lab/empresa_subgrupos.php?nit_empresa=${encodeURIComponent(nit)}`);
const j = await res.json();
const lista = document.getElementById('listaSubgrupos');
if (!j.subgrupos || !j.subgrupos.length) {
lista.innerHTML = '<p class="text-muted small text-center">Sin subgrupos</p>';
return;
}
lista.innerHTML = j.subgrupos.map(s => `
<div class="sub-row">
<div class="sub-info">
<div class="sub-nombre">${escHtml(s.subgrupo)}</div>
<div class="sub-meta">
${s.tarifa_nombre ? `<span class="badge-tarifa me-2">${escHtml(s.tarifa_nombre)}</span>` : ''}
${s.ref_subgrupo ? `Ref: ${escHtml(s.ref_subgrupo)}` : ''}
${s.cod_contrato ? ` · Cto: ${escHtml(s.cod_contrato)}` : ''}
</div>
</div>
<button class="btn btn-outline-primary btn-sm py-0 px-2" onclick="editarSubgrupo(${s.id},'${escAttr(s.subgrupo)}','${escAttr(s.tarifa_id||'')}','${escAttr(s.ref_subgrupo||'')}','${escAttr(s.cod_contrato||'')}')" title="Editar">
<i class="fas fa-pen"></i>
</button>
<button class="btn btn-outline-danger btn-sm py-0 px-2" onclick="eliminarSubgrupo(${s.id})" title="Eliminar">
<i class="fas fa-trash"></i>
</button>
</div>
`).join('');
}
function editarSubgrupo(id, nombre, tarifaId, ref, codCon) {
document.getElementById('subId').value = id;
document.getElementById('subNombre').value = nombre;
document.getElementById('subTarifaId').value = tarifaId;
document.getElementById('subRefSubgrupo').value = ref;
document.getElementById('subCodContrato').value = codCon;
document.getElementById('subNombre').focus();
}
async function guardarSubgrupo() {
const errEl = document.getElementById('subError');
errEl.classList.add('d-none');
const nit = document.getElementById('subNitEmpresa').value;
const nombre = document.getElementById('subNombre').value.trim();
if (!nombre) { errEl.textContent='El nombre es obligatorio'; errEl.classList.remove('d-none'); return; }
const payload = {
action: 'save',
id: document.getElementById('subId').value || null,
nit_empresa: nit,
subgrupo: nombre,
tarifa_id: document.getElementById('subTarifaId').value,
ref_subgrupo: document.getElementById('subRefSubgrupo').value.trim(),
cod_contrato: document.getElementById('subCodContrato').value.trim(),
};
const res = await fetch(`${BASE}/api/lab/empresa_subgrupos.php`, {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
});
const j = await res.json();
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
// Reset form
document.getElementById('subId').value = '';
['subNombre','subRefSubgrupo','subCodContrato'].forEach(id => document.getElementById(id).value='');
document.getElementById('subTarifaId').value = '';
recargarSubgrupos(nit);
cargarEmpresas();
}
async function eliminarSubgrupo(id) {
if (!confirm('¿Eliminar este subgrupo?')) return;
const nit = document.getElementById('subNitEmpresa').value;
await fetch(`${BASE}/api/lab/empresa_subgrupos.php`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({action:'delete', id})
});
recargarSubgrupos(nit);
cargarEmpresas();
}
// ─── Helpers ─────────────────────────────────────────────────
function escHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escAttr(s) { return escHtml(s).replace(/'/g,'&#39;'); }
</script>
<?php Layout::close(); ?>
+212
View File
@@ -0,0 +1,212 @@
<?php
/**
* modules/lab_empresas/views/tarifas.php
* CRUD del catálogo de tarifas (lab_tarifas_id).
*/
require_once APP_ROOT . '/config/config.php';
Layout::open('Catálogo de Tarifas', 'fas fa-tags');
?>
<div class="container-fluid py-3">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<h4 class="mb-0"><i class="fas fa-tags me-2 text-primary"></i>Catálogo de Tarifas</h4>
<small class="text-muted">IDs de tarifa usados en el motor de precios</small>
</div>
<div class="d-flex gap-2">
<a href="<?= BASE_URL ?>/erp.php?m=lab_empresas&v=index"
class="btn btn-outline-secondary btn-sm">
<i class="fas fa-building me-1"></i>Empresas
</a>
<button class="btn btn-primary btn-sm" onclick="abrirForm()">
<i class="fas fa-plus me-1"></i>Nueva tarifa
</button>
</div>
</div>
<div class="row g-3">
<!-- Tabla tarifas -->
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="table table-hover table-sm mb-0" id="tablaTarifas">
<thead class="table-light">
<tr>
<th style="width:60px">ID</th>
<th>Nombre</th>
<th class="text-end">%</th>
<th>Origen</th>
<th></th>
</tr>
</thead>
<tbody id="tbodyTarifas">
<tr><td colspan="5" class="text-center py-4 text-muted">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
<p class="text-muted small mt-2">
<i class="fas fa-info-circle me-1"></i>
Tarifas con <strong>% > 0</strong> y <strong>Origen</strong> se calculan automáticamente
como: precio_origen × (1 + %/100).
</p>
</div>
<!-- Formulario -->
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header py-2">
<h6 class="mb-0 small fw-semibold" id="formTitulo">Nueva tarifa</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-semibold small">ID numérico</label>
<input type="number" id="tId" class="form-control form-control-sm"
placeholder="Dejar vacío para auto-asignar" min="1">
<div class="form-text">El ID debe coincidir con el ID Firebird si se va a migrar.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small">Nombre <span class="text-danger">*</span></label>
<input type="text" id="tNombre" class="form-control form-control-sm"
placeholder="Ej: PARTICULAR, EPS SURA" maxlength="150">
</div>
<div class="mb-3">
<label class="form-label fw-semibold small">Porcentaje sobre tarifa origen</label>
<div class="input-group input-group-sm">
<input type="number" id="tPorcentaje" class="form-control"
value="0" min="0" step="0.01" max="999">
<span class="input-group-text">%</span>
</div>
<div class="form-text">0 = precios fijos. >0 = derivada de otra tarifa.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small">Tarifa origen</label>
<select id="tOrigen" class="form-select form-select-sm">
<option value="">— Precios directos —</option>
</select>
<div class="form-text">Solo si esta tarifa deriva de otra por porcentaje.</div>
</div>
<div id="tError" class="alert alert-danger d-none py-2 small mb-2"></div>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-sm flex-fill" onclick="guardarTarifa()">
<i class="fas fa-save me-1"></i>Guardar
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="resetForm()">Cancelar</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const BASE = '<?= BASE_URL ?>';
let _tarifas = [];
let _editId = null;
document.addEventListener('DOMContentLoaded', () => cargarTarifas());
async function cargarTarifas() {
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`);
const j = await res.json();
_tarifas = j.tarifas || [];
// Poblar select origen (excluyendo la propia tarifa en edición)
const optsOrigen = _tarifas
.filter(t => _editId === null || t.id != _editId)
.map(t => `<option value="${t.id}">${escHtml(t.nombre)} (ID ${t.id})</option>`)
.join('');
document.getElementById('tOrigen').innerHTML = '<option value="">— Precios directos —</option>' + optsOrigen;
const tbody = document.getElementById('tbodyTarifas');
if (!_tarifas.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-4 text-muted">Sin tarifas registradas</td></tr>';
return;
}
tbody.innerHTML = _tarifas.map(t => `
<tr>
<td class="text-monospace fw-semibold text-primary">${t.id}</td>
<td>${escHtml(t.nombre)}</td>
<td class="text-end">${parseFloat(t.porcentaje) > 0 ? `<span class="badge bg-info text-dark">+${t.porcentaje}%</span>` : '—'}</td>
<td>${t.tarifa_origen_nombre ? `<span class="small text-muted">${escHtml(t.tarifa_origen_nombre)}</span>` : '—'}</td>
<td class="text-end pe-2">
<button class="btn btn-outline-primary btn-sm py-0 px-2 me-1"
onclick="editarTarifa(${t.id})" title="Editar">
<i class="fas fa-pen"></i>
</button>
<button class="btn btn-outline-danger btn-sm py-0 px-2"
onclick="eliminarTarifa(${t.id})" title="Eliminar">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('');
}
function editarTarifa(id) {
const t = _tarifas.find(x => x.id == id);
if (!t) return;
_editId = id;
document.getElementById('formTitulo').textContent = 'Editar tarifa';
document.getElementById('tId').value = t.id;
document.getElementById('tId').readOnly = true;
document.getElementById('tNombre').value = t.nombre;
document.getElementById('tPorcentaje').value = t.porcentaje;
document.getElementById('tOrigen').value = t.tarifa_origen || '';
document.getElementById('tError').classList.add('d-none');
}
function resetForm() {
_editId = null;
document.getElementById('formTitulo').textContent = 'Nueva tarifa';
document.getElementById('tId').value = '';
document.getElementById('tId').readOnly = false;
document.getElementById('tNombre').value = '';
document.getElementById('tPorcentaje').value = '0';
document.getElementById('tOrigen').value = '';
document.getElementById('tError').classList.add('d-none');
}
function abrirForm() { resetForm(); document.getElementById('tNombre').focus(); }
async function guardarTarifa() {
const errEl = document.getElementById('tError');
errEl.classList.add('d-none');
const nombre = document.getElementById('tNombre').value.trim();
if (!nombre) { errEl.textContent='El nombre es obligatorio'; errEl.classList.remove('d-none'); return; }
const payload = {
action: 'save',
nombre,
porcentaje: document.getElementById('tPorcentaje').value,
tarifa_origen:document.getElementById('tOrigen').value,
};
const idVal = document.getElementById('tId').value.trim();
if (idVal) payload.id = parseInt(idVal);
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`, {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload)
});
const j = await res.json();
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
resetForm();
cargarTarifas();
}
async function eliminarTarifa(id) {
if (!confirm('¿Eliminar esta tarifa? Solo es posible si no tiene precios de exámenes asociados.')) return;
const res = await fetch(`${BASE}/api/lab/tarifas_id.php`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({action:'delete', id})
});
const j = await res.json();
if (!j.ok) { alert(j.error || 'Error al eliminar'); return; }
cargarTarifas();
}
function escHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
<?php Layout::close(); ?>
+12
View File
@@ -0,0 +1,12 @@
<?php return [
'slug' => 'lab_eps',
'name' => 'EPS',
'icon' => 'fas fa-hospital',
'category' => 'lab',
'route' => '/lab_eps.php',
'is_active' => true,
'sort_order' => 22,
'oleada' => 0,
'description' => 'Gestión de EPS y aseguradoras',
'links' => [['name' => 'EPS', 'icon' => 'fas fa-hospital', 'route' => '/lab_eps.php']],
];
+178
View File
@@ -0,0 +1,178 @@
<?php
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
requireRole('admin');
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>EPS — <?= htmlspecialchars($_cfg['empresa_nombre'] ?? 'ERP') ?></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="<?= BASE_URL ?>assets/css/styles.css?v=15" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
.eps-table { width:100%; border-collapse:collapse; font-size:.9rem; }
.eps-table th { background:#f8fafc; font-weight:700; font-size:.75rem; text-transform:uppercase;
letter-spacing:.06em; color:#64748b; padding:.6rem 1rem; border-bottom:2px solid #e2e8f0; }
.eps-table td { padding:.65rem 1rem; border-bottom:1px solid #f1f5f9; vertical-align:middle; }
.eps-table tr:hover td { background:#f8fafc; }
.badge-activa { background:#dcfce7; color:#166534; font-size:.7rem; font-weight:700;
padding:2px 8px; border-radius:99px; }
.badge-inactiva { background:#f1f5f9; color:#94a3b8; font-size:.7rem; font-weight:700;
padding:2px 8px; border-radius:99px; }
</style>
</head>
<body>
<?php include APP_ROOT . '/partials/navbar.php'; ?>
<div class="container-fluid py-4" style="max-width:720px">
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0"><i class="fas fa-hospital me-2 text-primary"></i>EPS / Aseguradoras</h5>
<button class="btn btn-primary btn-sm" onclick="abrirModal()">
<i class="fas fa-plus me-1"></i>Nueva EPS
</button>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="eps-table" id="tbl-eps">
<thead>
<tr>
<th>Nombre</th>
<th style="width:90px">Estado</th>
<th style="width:110px">Acciones</th>
</tr>
</thead>
<tbody id="tbody-eps">
<tr><td colspan="3" class="text-muted text-center py-3">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="modal-eps" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title" id="modal-eps-titulo">Nueva EPS</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="eps-id">
<div class="mb-3">
<label class="form-label small fw-semibold">Nombre <span class="text-danger">*</span></label>
<input type="text" id="eps-nombre" class="form-control" maxlength="120"
placeholder="Ej: Nueva EPS, Sanitas, Sura…">
</div>
<div id="eps-error" class="text-danger small d-none"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-primary btn-sm" onclick="guardarEps()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
</div>
</div>
<script>
const API_EPS = '<?= BASE_URL ?>api/lab/eps.php';
let _modal;
async function cargarEps() {
const r = await fetch(API_EPS + '?action=list');
const j = await r.json();
const tbody = document.getElementById('tbody-eps');
if (!j.ok || !j.eps.length) {
tbody.innerHTML = '<tr><td colspan="3" class="text-muted text-center py-3">Sin registros</td></tr>';
return;
}
tbody.innerHTML = j.eps.map(e => `
<tr id="row-${e.id}">
<td>${escHtml(e.nombre)}</td>
<td>
<span class="badge-${e.activa == 1 ? 'activa' : 'inactiva'}">
${e.activa == 1 ? 'Activa' : 'Inactiva'}
</span>
</td>
<td class="d-flex gap-1">
<button class="btn btn-outline-secondary btn-sm" title="Editar" onclick="abrirModal(${e.id},'${escHtml(e.nombre).replace(/'/g,"\\'")}')">
<i class="fas fa-pen"></i>
</button>
<button class="btn btn-outline-${e.activa == 1 ? 'warning' : 'success'} btn-sm" title="${e.activa == 1 ? 'Desactivar' : 'Activar'}" onclick="toggleEps(${e.id})">
<i class="fas fa-${e.activa == 1 ? 'ban' : 'check'}"></i>
</button>
<button class="btn btn-outline-danger btn-sm" title="Eliminar" onclick="eliminarEps(${e.id},'${escHtml(e.nombre).replace(/'/g,"\\'")}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('');
}
function abrirModal(id = 0, nombre = '') {
document.getElementById('eps-id').value = id;
document.getElementById('eps-nombre').value = nombre;
document.getElementById('eps-error').classList.add('d-none');
document.getElementById('modal-eps-titulo').textContent = id ? 'Editar EPS' : 'Nueva EPS';
_modal = _modal || new bootstrap.Modal(document.getElementById('modal-eps'));
_modal.show();
setTimeout(() => document.getElementById('eps-nombre').focus(), 300);
}
async function guardarEps() {
const nombre = document.getElementById('eps-nombre').value.trim();
const id = parseInt(document.getElementById('eps-id').value) || 0;
const errEl = document.getElementById('eps-error');
errEl.classList.add('d-none');
if (!nombre) { errEl.textContent = 'El nombre es requerido.'; errEl.classList.remove('d-none'); return; }
const r = await fetch(API_EPS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save', id, nombre }),
});
const j = await r.json();
if (!j.ok) { errEl.textContent = j.error || 'Error'; errEl.classList.remove('d-none'); return; }
_modal.hide();
cargarEps();
}
async function toggleEps(id) {
await fetch(API_EPS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'toggle', id }),
});
cargarEps();
}
async function eliminarEps(id, nombre) {
if (!confirm(`¿Eliminar "${nombre}"?`)) return;
const r = await fetch(API_EPS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', id }),
});
const j = await r.json();
if (!j.ok) { alert(j.error); return; }
cargarEps();
}
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str)));
return d.innerHTML;
}
document.getElementById('eps-nombre').addEventListener('keydown', e => {
if (e.key === 'Enter') guardarEps();
});
cargarEps();
</script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
<?php
ob_start();
require_once __DIR__ . '/../../../config/config.php';
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', '0');
ini_set('html_errors', '0');
register_shutdown_function(function () {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
ob_clean();
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Error interno: ' . $err['message']]);
}
});
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
function jsonOk(array $data = [], string $msg = ''): never {
ob_clean();
$r = ['ok' => true];
if ($msg) $r['message'] = $msg;
if ($data) $r = array_merge($r, $data);
echo json_encode($r);
exit;
}
function jsonError(string $msg, int $code = 400): never {
ob_clean();
http_response_code($code);
echo json_encode(['ok' => false, 'error' => $msg]);
exit;
}
function requireAdmin(): void {
if (!isUserLoggedIn()) jsonError('No autorizado', 401);
$role = $_SESSION['admin_user']['role'] ?? '';
if (!in_array($role, ['superadmin', 'admin', 'supervisor'], true)) jsonError('Sin permiso', 403);
}
function requireLogin(): void {
if (!isUserLoggedIn()) jsonError('No autorizado', 401);
}
function inputJson(): array {
return json_decode(file_get_contents('php://input'), true) ?? [];
}
function db(): PDO {
$pdo = Database::getInstance()->getConnection();
$pdo->exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
return $pdo;
}
+23
View File
@@ -0,0 +1,23 @@
<?php
/**
* GET ?id=N — Detalle de un examen del catálogo con sus ítems.
*/
require_once __DIR__ . '/_helpers.php';
requireLogin();
$id = (int)($_GET['id'] ?? 0);
if (!$id) jsonError('ID requerido');
$pdo = db();
$s = $pdo->prepare('SELECT * FROM exam_tipos WHERE id = ?');
$s->execute([$id]);
$exam = $s->fetch(PDO::FETCH_ASSOC);
if (!$exam) jsonError('Examen no encontrado', 404);
$items = $pdo->prepare(
'SELECT * FROM lab_items_resultado WHERE cod_protocolo = ? ORDER BY orden, id'
);
$items->execute([$exam['cod_protocolo'] ?? $exam['codigo']]);
jsonOk(['data' => $exam, 'items' => $items->fetchAll(PDO::FETCH_ASSOC)]);
+26
View File
@@ -0,0 +1,26 @@
<?php
/**
* GET ?exam_id=N — Tarifas de un examen por empresa o convenio.
*/
require_once __DIR__ . '/_helpers.php';
requireLogin();
$examId = (int)($_GET['exam_id'] ?? 0);
if (!$examId) jsonError('exam_id requerido');
$pdo = db();
$s = $pdo->prepare('SELECT 1 FROM exam_tipos WHERE id = ?');
$s->execute([$examId]);
if (!$s->fetchColumn()) jsonError('Examen no encontrado', 404);
$s = $pdo->prepare(
"SELECT ti.id AS tarifa_id, ti.nombre AS tarifa_nombre,
lt.id AS precio_id, lt.valor, lt.recargo_urg, lt.recargo_fes, lt.recargo_esp
FROM lab_tarifas_id ti
LEFT JOIN lab_tarifas lt ON lt.tarifa_id = ti.id AND lt.exam_tipo_id = ?
ORDER BY ti.id"
);
$s->execute([$examId]);
jsonOk(['data' => $s->fetchAll(PDO::FETCH_ASSOC)]);
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* GET ?q=&categoria=&page=&limit= — Catálogo de exámenes, paginado y filtrable.
*/
require_once __DIR__ . '/_helpers.php';
requireLogin();
$q = trim($_GET['q'] ?? '');
$cat = trim($_GET['categoria'] ?? '');
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = max(1, min(200, (int)($_GET['limit'] ?? 50)));
$offset = ($page - 1) * $limit;
$where = [];
$params = [];
if ($q !== '') {
$like = '%' . $q . '%';
$where[] = '(nombre LIKE ? OR codigo LIKE ? OR cups LIKE ?)';
$params[] = $like; $params[] = $like; $params[] = $like;
}
if ($cat !== '') {
$where[] = 'categoria = ?';
$params[] = $cat;
}
$wSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$pdo = db();
$s = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos $wSql");
$s->execute($params);
$total = (int)$s->fetchColumn();
$s = $pdo->prepare(
"SELECT id, codigo, nombre, categoria, activo, cups, seremite, cod_protocolo, tipo_muestra
FROM exam_tipos $wSql ORDER BY categoria, nombre LIMIT $limit OFFSET $offset"
);
$s->execute($params);
jsonOk(['data' => $s->fetchAll(PDO::FETCH_ASSOC), 'total' => $total, 'page' => $page, 'limit' => $limit]);

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