237 Commits
Author SHA1 Message Date
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
137 changed files with 16022 additions and 1151 deletions
+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.');
+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.');
+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.last_login,
u.created_at,
(u.firma_svg IS NOT NULL) AS tiene_firma,
r.name AS role_name,
r.color AS role_color,
r.slug AS role_slug,
@@ -33,6 +34,7 @@ $users = $db->fetchAll("
foreach ($users as &$u) {
$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_color'] = $u['role_color'] ?? '#0d6efd';
}
+7 -1
View File
@@ -13,11 +13,17 @@ try {
jsonOk(['data' => [$row]]);
}
if (isset($_GET['stats'])) {
jsonOk(['stats' => $pac->stats()]);
}
$busqueda = trim($_GET['busqueda'] ?? $_GET['search'] ?? '');
$pagina = max(1, (int)($_GET['page'] ?? $_GET['pagina'] ?? 1));
$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) {
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]);
+13
View File
@@ -91,6 +91,19 @@ try {
$eid = enfermeraId();
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);
// 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);
+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);
+117 -122
View File
@@ -935,32 +935,6 @@ body {
#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 */
.text-center { text-align: center; }
@@ -1483,104 +1457,125 @@ body {
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) {
:root {
--light-bg: #071322;
--text-primary: #e6eef1;
--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;
--text-primary: #e6eef1;
--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 {
$permitidos = [
'orden_id', 'paciente_id', 'direccion', 'ciudad', 'barrio',
'orden_id', 'paciente_id', 'numero_orden', 'direccion', 'ciudad', 'barrio',
'indicaciones_dir', 'fecha_programada', 'hora_programada',
'tipo_servicio', 'tipo_cliente', 'examenes_solicitados',
'estado', 'motivo_cancelacion',
+7 -5
View File
@@ -23,8 +23,10 @@ class Formulario {
$w = $soloActivos ? 'WHERE f.is_active = 1' : '';
return $this->db->fetchAll("
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 AND e.estado IN ('completado','firmado')) AS total_completados
(SELECT COUNT(*) FROM lab_form_envios e WHERE e.formulario_id = f.id) +
(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
LEFT JOIN admin_users u ON u.id = f.creado_por
$w
@@ -214,13 +216,13 @@ class Formulario {
$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_hasta'])) { $whereB[] = 'DATE(tc.enviado_at) <= ?'; $paramsB[] = $filtros['fecha_hasta']; }
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(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['enviado_por'])) { $whereB[] = '1=0'; } // turnero no tiene enviado_por
$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,
p.nombre_completo AS paciente_nombre,
'Turnero' AS enviado_por_nombre,
+30 -12
View File
@@ -27,20 +27,25 @@ class Paciente {
public function listar(
string $busqueda = '',
int $pagina = 1,
int $porPagina = 30
int $porPagina = 30,
string $origen = ''
): array {
$offset = ($pagina - 1) * $porPagina;
$like = "%$busqueda%";
$params = $busqueda
? [$like, $like, $like, $like]
: [];
$where = $busqueda
? "WHERE p.nombre_completo LIKE ?
OR p.numero_documento LIKE ?
OR p.telefono LIKE ?
OR p.email LIKE ?"
: '';
$conditions = [];
$params = [];
if ($busqueda) {
$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(
"SELECT COUNT(*) AS n FROM lab_pacientes p $where",
@@ -56,7 +61,7 @@ class Paciente {
FROM lab_pacientes p
LEFT JOIN users u ON u.id = p.user_id
$where
ORDER BY p.nombre_completo ASC
ORDER BY p.created_at DESC, p.nombre_completo ASC
LIMIT ? OFFSET ?
", array_merge($params, [$porPagina, $offset]));
@@ -69,6 +74,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).
*/
@@ -238,7 +256,7 @@ class Paciente {
'user_id', 'numero_documento', 'tipo_documento',
'nombre_completo', 'telefono', 'email',
'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));
if (!empty($campos['telefono'])) {
+9
View File
@@ -402,6 +402,14 @@ if (!defined('BASE_URL')) {
if (!defined('MIGRATION_TOKEN')) {
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');
// Información del desarrollador
@@ -544,6 +552,7 @@ function authenticateUser($username, $password) {
'role_id' => $admin['role_id'] ?? null,
'home_page' => $homePage,
'enfermera_id' => $admin['enfermera_id'] ?? null,
'turnero_lugar_id' => $admin['turnero_lugar_id'] ?? null,
'modules' => $modules,
'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-----
+85
View File
@@ -44,6 +44,30 @@ class App
header('Location: ' . APP_ROOT . '/../enfermero_portal.php');
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);
@@ -99,6 +123,67 @@ class App
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();
$ip = self::clientIp();
// ¿IP registrada?
$dev = $pdo->prepare(
"SELECT lugar_id FROM turnero_dispositivos WHERE ip = ? AND activo = 1 LIMIT 1"
);
$dev->execute([$ip]);
$row = $dev->fetch(PDO::FETCH_ASSOC);
if ($row) {
return $base . (int)$row['lugar_id'];
}
// 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 $_) {}
// Fallback: turnero sin vista específica
return '/erp.php?m=turnero';
}
// ─── Páginas de error ────────────────────────────────────────────────────
private static function render404(string $module, string $view): void
+5
View File
@@ -67,6 +67,11 @@ class Auth
return self::role() === 'enfermero';
}
public static function isBacteriologo(): bool
{
return self::role() === 'bacteriologo';
}
public static function isAdmin(): bool
{
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">
<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 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome 6 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- 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>
:root {
@@ -77,7 +80,7 @@ class Layout
.main-content {
margin-left: var(--sidebar-width);
min-height: 100vh;
background: #f8f9fa;
background: var(--bs-body-bg, #f8f9fa);
}
@media (max-width: 991.98px) {
.main-content { margin-left: 0; }
@@ -108,7 +111,7 @@ class Layout
<!-- Contenido principal -->
<main class="main-content">
<!-- 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">
<i class="<?= $safeIcon ?> me-2 text-primary"></i><?= $safeTitle ?>
</h5>
+195 -2
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 { min-height:50vh; display:flex; flex-direction:column;
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>
</head>
<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">
<p class="text-muted small mb-2">✍️ Dibuja tu firma con el dedo o el mouse.</p>
<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"
onclick="firma.limpiarCanvas()">
<i class="fas fa-eraser me-1"></i>Limpiar firma
</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>
</div>
</div>
@@ -294,6 +321,36 @@ 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>
<!-- ── 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">
<i class="fas fa-signature fa-2x" style="color:#adb5bd" id="topaz-pad-icon"></i>
<div id="topaz-status-msg" style="font-size:.9rem;color:#64748b;font-weight:600">
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>
<script>
@@ -648,11 +705,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>
<canvas id="fw-${fid}-canvas" class="fw-canvas empty"
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"
onclick="firmaWidgetLimpiar('${fid}')">
<i class="fas fa-eraser me-1"></i>Limpiar
</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>
</div>
</div>`;
@@ -1205,6 +1266,138 @@ function iniciarCondiciones(esquema) {
evaluar();
}
// ══════════════════════════════════════════════════════════════════════
// TOPAZ SIGWEB
// ══════════════════════════════════════════════════════════════════════
const topaz = (() => {
const SIGWEB_URL = 'http://localhost:47289/SigWeb/SigWebTablet.js';
let _loaded = false, _ctx = null, _fid = null, _poll = null;
function _call(fn, ...args) {
// SigWeb v1: global functions with ctx as last param
// SigWeb v2: methods on ctx object
if (_ctx && typeof _ctx[fn] === 'function') return _ctx[fn](...args);
const g = window[fn];
if (typeof g === 'function') return _ctx ? g(...args, _ctx) : g(...args);
throw new Error('SigWeb: ' + fn + ' no encontrado');
}
function _loadScript() {
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 {
_ctx = typeof SigWebTablet !== 'undefined' ? new SigWebTablet() : null;
_call('SetImageXSize', 500);
_call('SetImageYSize', 150);
_call('SetImagePenWidth', 3);
_call('SetTabletState', 1);
_call('ClearTablet');
} 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);
_poll = setInterval(() => {
try {
const pts = _call('GetSigTotalPoints');
$('topaz-pts').textContent = pts;
const hasSig = pts > 0;
$('topaz-btn-aceptar').disabled = !hasSig;
if (hasSig) _setStatus('✅ Firma detectada — presione Aceptar', true);
} catch(e) { _stopPoll(); }
}, 400);
}
function _stopPoll() { if (_poll) { clearInterval(_poll); _poll = null; } }
function _cerrarOverlay() {
_stopPoll();
try { if (_ctx) _call('SetTabletState', 0); } catch(e) {}
_ctx = 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() {
let b64;
try { b64 = _call('GetSigImageB64'); } catch(e) {
alert('Error al capturar la firma: ' + e.message);
_cerrarOverlay(); return;
}
if (!b64) { alert('No se capturó ninguna firma.'); return; }
const img = new Image();
img.onload = () => {
const fid = _fid;
_cerrarOverlay();
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;
}
return { activar, cancelar, limpiarPad, aceptar };
})();
// ══════════════════════════════════════════════════════════════════════
// INIT
// ══════════════════════════════════════════════════════════════════════
+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
+8
View File
@@ -681,6 +681,7 @@ async function cargarLista(pag = 1) {
<td>
<div class="fw-semibold">${esc(dom.paciente_nombre)}</div>
<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>
${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>
</div>
<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">
<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>
+142 -26
View File
@@ -56,7 +56,28 @@ $brandDark = sprintf('#%02x%02x%02x',
color: #64748b;
}
.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-brand {
@@ -125,9 +146,10 @@ $brandDark = sprintf('#%02x%02x%02x',
/* ── Panel detalle ─────────────────────────────── */
.detail-header-band {
background: linear-gradient(135deg, var(--brand-dark) 0%, var(--brand) 100%);
color: #fff; border-radius: .5rem .5rem 0 0;
color: #fff;
padding: .75rem 1rem;
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0;
}
.detail-header-band .btn-close { filter: invert(1) brightness(2); }
@@ -155,8 +177,37 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</header>
<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="input-group">
<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)()">
</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 class="row g-3">
<!-- 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-body p-0">
<div class="table-responsive">
@@ -179,13 +243,17 @@ require_once __DIR__ . '/shared/components/sidebar.php';
<th class="ps-3">Paciente</th>
<th>Documento</th>
<th>Teléfono</th>
<th>Email</th>
<th>Ciudad</th>
<th>EPS</th>
<th>Origen</th>
<th>Registro</th>
<th>Órd.</th>
<th></th>
</tr>
</thead>
<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...
</td></tr>
</tbody>
@@ -195,21 +263,22 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</div>
</div>
</div>
<!-- Panel de detalle -->
<div class="col-lg-5" id="detail-panel">
<div class="card border-0 shadow-sm rounded-3 overflow-hidden">
<div class="detail-header-band">
<span class="fw-semibold" id="detail-nombre">Paciente</span>
<button class="btn-close" onclick="cerrarDetalle()"></button>
</div>
<div class="card-body" id="detail-body"></div>
</div>
</div>
</div>
</div>
</main>
<!-- Backdrop para el drawer de detalle -->
<div id="detail-backdrop" onclick="cerrarDetalle()"></div>
<!-- Drawer de detalle (fixed overlay) -->
<div id="detail-panel">
<div class="detail-header-band">
<span class="fw-semibold" id="detail-nombre">Paciente</span>
<button class="btn-close" onclick="cerrarDetalle()"></button>
</div>
<div id="detail-body"></div>
</div>
<!-- Modal Formulario Paciente -->
<div class="modal fade" id="modalPaciente" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
@@ -367,16 +436,54 @@ require_once __DIR__ . '/shared/components/sidebar.php';
let paginaActual = 1;
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 ──────────────────────────────────────────────────────────────────
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('/');
}
async function cargarLista(pag = 1) {
paginaActual = pag;
const busq = document.getElementById('buscador').value.trim();
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(busq)}&page=${pag}&limit=25`);
const busq = document.getElementById('buscador').value.trim();
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 tbody = document.getElementById('tabla-body');
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 = '';
return;
}
@@ -385,11 +492,15 @@ async function cargarLista(pag = 1) {
<tr style="cursor:pointer" onclick="verDetalle(${p.id})">
<td class="ps-3">
<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 class="small">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</td>
<td class="small">${esc(p.telefono||'—')}</td>
<td class="small">${esc(p.eps||'—')}</td>
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
<td class="small">${esc(p.telefono||'—')}${p.phone_number ? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>` : ''}</td>
<td class="small 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>
<button class="btn btn-sm btn-outline-secondary py-1 px-2"
@@ -417,7 +528,8 @@ async function cargarLista(pag = 1) {
// ── Detalle ────────────────────────────────────────────────────────────────
async function verDetalle(id) {
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>';
const r = await fetch(`api/lab/get_pacientes.php?id=${id}`);
@@ -436,6 +548,8 @@ async function verDetalle(id) {
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
<dt class="col-5 text-muted">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.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>` : ''}
@@ -465,7 +579,8 @@ async function verDetalle(id) {
}
function cerrarDetalle() {
document.getElementById('detail-panel').style.display = 'none';
document.getElementById('detail-panel').classList.remove('open');
document.getElementById('detail-backdrop').style.display = 'none';
}
// ── Formulario ─────────────────────────────────────────────────────────────
@@ -628,6 +743,7 @@ function mostrarToast(msg, tipo = 'success') {
}
cargarLista();
cargarStats();
</script>
<script src="assets/js/lab-sidebar.js"></script>
</body>
+191
View File
@@ -210,6 +210,56 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</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
══════════════════════════════════════════════════════════ -->
@@ -460,6 +510,11 @@ const usuarios = {
<td class="text-muted small">${lastLogin}</td>
<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 ${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>
</td>
</tr>`;
@@ -596,6 +651,142 @@ document.getElementById('r_slug').addEventListener('input', function() {
}
})();
</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>
</body>
</html>
+35
View File
@@ -41,6 +41,25 @@ if ($_POST && !$loginBlocked) {
$_SESSION['login_ip'] = $clientIp;
$_SESSION['login_time'] = time();
// Tablet fija: redirigir al lugar asignado por IP y bloquear el resto
try {
$_dispStmt = Database::getInstance()->getConnection()->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"
);
$_dispStmt->execute([$clientIp]);
$_disp = $_dispStmt->fetch(PDO::FETCH_ASSOC);
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';
$modules = $adminUser['modules'] ?? [];
@@ -51,6 +70,22 @@ if ($_POST && !$loginBlocked) {
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') {
header('Location: enfermero_portal.php');
} elseif (in_array('turnero', $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;
+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_seed.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();
+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;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
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)]);
+23
View File
@@ -0,0 +1,23 @@
<?php
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)]);
+37
View File
@@ -0,0 +1,37 @@
<?php
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]);
+71
View File
@@ -0,0 +1,71 @@
<?php
require_once __DIR__ . '/_helpers.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
$d = inputJson();
$id = (int)($d['id'] ?? 0);
if (!empty($d['_delete'])) {
if (!$id) jsonError('ID requerido');
$pdo = db();
$s = $pdo->prepare('SELECT COUNT(*) FROM turnero_examen_items WHERE exam_tipo_id = ?');
$s->execute([$id]);
if ($s->fetchColumn() > 0) jsonError('Hay solicitudes con este examen. Desactívelo en su lugar.');
$pdo->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM exam_tipos WHERE id = ?')->execute([$id]);
jsonOk([], 'Examen eliminado');
}
$codigo = strtoupper(trim($d['codigo'] ?? ''));
$nombre = trim($d['nombre'] ?? '');
$categoria = trim($d['categoria'] ?? '') ?: null;
$cups = trim($d['cups'] ?? '') ?: null;
$cod_prot = trim($d['cod_protocolo'] ?? '') ?: null;
$tipo_m = trim($d['tipo_muestra'] ?? '') ?: null;
$nivel = isset($d['nivel']) && $d['nivel'] !== '' ? (int)$d['nivel'] : null;
$abrev = trim($d['abreviatura'] ?? '') ?: null;
$seremite = (int)($d['seremite'] ?? 0);
$serecibe = trim($d['serecibe'] ?? '') ?: null;
$activo = (int)($d['activo'] ?? 1);
$req_ayuno = (int)($d['requiere_ayuno'] ?? 0);
$horas_ayuno = isset($d['horas_ayuno']) && $d['horas_ayuno'] !== '' ? (int)$d['horas_ayuno'] : null;
$instruc = trim($d['instrucciones'] ?? '') ?: null;
$formIds = array_values(array_unique(array_filter(array_map('intval', (array)($d['form_ids'] ?? [])))));
if ($codigo === '') jsonError('El código es requerido');
if ($nombre === '') jsonError('El nombre es requerido');
if (strlen($codigo) > 20) jsonError('Código máximo 20 caracteres');
$pdo = db();
$pdo->beginTransaction();
try {
if ($id) {
$pdo->prepare(
'UPDATE exam_tipos SET codigo=?,nombre=?,categoria=?,cups=?,cod_protocolo=?,tipo_muestra=?,
nivel=?,abreviatura=?,seremite=?,serecibe=?,activo=?,requiere_ayuno=?,horas_ayuno=?,instrucciones=?
WHERE id=?'
)->execute([$codigo,$nombre,$categoria,$cups,$cod_prot,$tipo_m,
$nivel,$abrev,$seremite,$serecibe,$activo,$req_ayuno,$horas_ayuno,$instruc,$id]);
$examId = $id;
} else {
$pdo->prepare(
'INSERT INTO exam_tipos (codigo,nombre,categoria,cups,cod_protocolo,tipo_muestra,
nivel,abreviatura,seremite,serecibe,activo,codigo_legacy,requiere_ayuno,horas_ayuno,instrucciones)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([$codigo,$nombre,$categoria,$cups,$cod_prot,$tipo_m,
$nivel,$abrev,$seremite,$serecibe,$activo,$codigo,$req_ayuno,$horas_ayuno,$instruc]);
$examId = (int)$pdo->lastInsertId();
}
$pdo->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$examId]);
$ins = $pdo->prepare('INSERT INTO exam_tipo_consentimientos (exam_tipo_id, formulario_id) VALUES (?,?)');
foreach ($formIds as $fid) { $ins->execute([$examId, $fid]); }
$pdo->commit();
jsonOk(['id' => $examId], $id ? 'Examen actualizado' : 'Examen creado');
} catch (\Throwable $e) {
$pdo->rollBack();
if ($e->getCode() === '23000') jsonError('Ya existe un examen con ese código');
jsonError('Error: ' . $e->getMessage(), 500);
}
+44
View File
@@ -0,0 +1,44 @@
<?php
require_once __DIR__ . '/_helpers.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
$d = inputJson();
$id = (int)($d['id'] ?? 0);
if (!empty($d['_delete'])) {
if (!$id) jsonError('ID requerido');
db()->prepare('DELETE FROM lab_items_resultado WHERE id = ?')->execute([$id]);
jsonOk([], 'Item eliminado');
}
$codProt = trim($d['cod_protocolo'] ?? '');
$nombre = trim($d['nombre'] ?? '');
if (!$codProt) jsonError('cod_protocolo requerido');
if (!$nombre) jsonError('Nombre requerido');
$tipoSexo = in_array($d['tipo_sexo'] ?? '', ['M','F']) ? $d['tipo_sexo'] : null;
$tipo = in_array($d['tipo'] ?? '', ['N','T']) ? $d['tipo'] : 'T';
$medida = trim($d['medida'] ?? '') ?: null;
$abrev = trim($d['abreviatura'] ?? '') ?: null;
$vmin = $d['vmin_ref'] !== '' && $d['vmin_ref'] !== null ? (float)$d['vmin_ref'] : null;
$vmax = $d['vmax_ref'] !== '' && $d['vmax_ref'] !== null ? (float)$d['vmax_ref'] : null;
$orden = (int)($d['orden'] ?? 0);
$formula = trim($d['formula'] ?? '') ?: null;
$cups_d = trim($d['cups_detalle'] ?? '') ?: null;
$pdo = db();
if ($id) {
$pdo->prepare(
'UPDATE lab_items_resultado SET cod_protocolo=?,nombre=?,tipo_sexo=?,tipo=?,medida=?,
abreviatura=?,vmin_ref=?,vmax_ref=?,orden=?,formula=?,cups_detalle=? WHERE id=?'
)->execute([$codProt,$nombre,$tipoSexo,$tipo,$medida,$abrev,$vmin,$vmax,$orden,$formula,$cups_d,$id]);
jsonOk(['id' => $id], 'Item actualizado');
} else {
$pdo->prepare(
'INSERT INTO lab_items_resultado
(cod_protocolo,nombre,tipo_sexo,tipo,medida,abreviatura,vmin_ref,vmax_ref,orden,formula,cups_detalle)
VALUES (?,?,?,?,?,?,?,?,?,?,?)'
)->execute([$codProt,$nombre,$tipoSexo,$tipo,$medida,$abrev,$vmin,$vmax,$orden,$formula,$cups_d]);
jsonOk(['id' => (int)$pdo->lastInsertId()], 'Item creado');
}
+45
View File
@@ -0,0 +1,45 @@
<?php
require_once __DIR__ . '/_helpers.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
$d = inputJson();
$examId = (int)($d['exam_tipo_id'] ?? 0);
$tarifaId = (int)($d['tarifa_id'] ?? 0);
if (!$examId || !$tarifaId) jsonError('exam_tipo_id y tarifa_id son requeridos');
$pdo = db();
if (!empty($d['_delete'])) {
$pdo->prepare('DELETE FROM lab_tarifas WHERE exam_tipo_id = ? AND tarifa_id = ?')
->execute([$examId, $tarifaId]);
jsonOk([], 'Precio eliminado');
}
$valor = (float)($d['valor'] ?? 0);
$rec_urg = (float)($d['recargo_urg'] ?? 0);
$rec_fes = (float)($d['recargo_fes'] ?? 0);
$rec_esp = (float)($d['recargo_esp'] ?? 0);
$s = $pdo->prepare('SELECT COALESCE(codigo_legacy, codigo) FROM exam_tipos WHERE id = ?');
$s->execute([$examId]);
$leg = $s->fetchColumn();
if (!$leg) jsonError('Examen no encontrado', 404);
// Check if price row exists (no unique key in schema, so do it manually)
$s = $pdo->prepare('SELECT id FROM lab_tarifas WHERE exam_tipo_id = ? AND tarifa_id = ?');
$s->execute([$examId, $tarifaId]);
$precioId = $s->fetchColumn();
if ($precioId) {
$pdo->prepare(
'UPDATE lab_tarifas SET valor=?,recargo_urg=?,recargo_fes=?,recargo_esp=?,cod_examen_legacy=? WHERE id=?'
)->execute([$valor, $rec_urg, $rec_fes, $rec_esp, $leg, $precioId]);
} else {
$pdo->prepare(
'INSERT INTO lab_tarifas (cod_examen_legacy,exam_tipo_id,tarifa_id,valor,recargo_urg,recargo_fes,recargo_esp)
VALUES (?,?,?,?,?,?,?)'
)->execute([$leg, $examId, $tarifaId, $valor, $rec_urg, $rec_fes, $rec_esp]);
}
jsonOk([], 'Precio guardado');
+15
View File
@@ -0,0 +1,15 @@
<?php
return [
'slug' => 'lab_examenes',
'name' => 'Catálogo de Exámenes',
'icon' => 'fas fa-flask',
'category' => 'clinico',
'route' => '/erp.php?m=lab_examenes&v=index',
'is_active' => true,
'sort_order' => 60,
'oleada' => 2,
'description' => 'Gestión del catálogo de exámenes, valores de referencia y tarifas.',
'links' => [
['name' => 'Exámenes', 'icon' => 'fas fa-flask', 'route' => '/erp.php?m=lab_examenes&v=index'],
],
];
+507
View File
@@ -0,0 +1,507 @@
<?php
require_once APP_ROOT . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
$id = (int)($_GET['id'] ?? 0);
$pdo = Database::getInstance()->getConnection();
$exam = null;
$items = [];
if ($id) {
$s = $pdo->prepare('SELECT * FROM exam_tipos WHERE id=?');
$s->execute([$id]);
$exam = $s->fetch(PDO::FETCH_ASSOC);
if (!$exam) { header('Location: ' . BASE_URL . 'erp.php?m=lab_examenes&v=index'); exit; }
$s = $pdo->prepare('SELECT * FROM lab_items_resultado WHERE cod_protocolo=? ORDER BY orden,id');
$s->execute([$exam['cod_protocolo'] ?? $exam['codigo']]);
$items = $s->fetchAll(PDO::FETCH_ASSOC);
}
$protocolos = $pdo->query('SELECT codigo, nombre FROM lab_protocolos ORDER BY nombre')->fetchAll(PDO::FETCH_ASSOC);
$muestras = $pdo->query('SELECT codigo, nombre FROM lab_tipos_muestra ORDER BY nombre')->fetchAll(PDO::FETCH_ASSOC);
$formularios = $pdo->query('SELECT id, nombre FROM lab_formularios ORDER BY nombre')->fetchAll(PDO::FETCH_ASSOC);
$formsActuales = [];
if ($id) {
$s = $pdo->prepare('SELECT formulario_id FROM exam_tipo_consentimientos WHERE exam_tipo_id=?');
$s->execute([$id]);
$formsActuales = $s->fetchAll(PDO::FETCH_COLUMN);
}
$titulo = $exam ? 'Editar: ' . htmlspecialchars($exam['nombre']) : 'Nuevo Examen';
Layout::open($titulo, 'fas fa-flask');
$API = BASE_URL . 'modules/lab_examenes/api/';
?>
<style>
body { background:#f1f5f9 }
.page-header { background:#fff; border-bottom:1px solid #e2e8f0; padding:12px 24px;
display:flex; align-items:center; gap:10px; flex-wrap:wrap }
.breadcrumb-lab { font-size:.82rem; color:#64748b }
.breadcrumb-lab a { color:#2563eb; text-decoration:none }
.content-wrap { max-width:1100px; margin:20px auto; padding:0 16px 60px }
.panel { background:#fff; border:1px solid #e2e8f0; border-radius:12px; margin-bottom:20px }
.panel-hdr { padding:14px 20px; border-bottom:1px solid #e2e8f0; display:flex; align-items:center; gap:8px }
.panel-hdr h2 { font-size:1rem; font-weight:700; color:#1e293b; margin:0; flex:1 }
.panel-body { padding:20px }
.form-label { font-size:.8rem; font-weight:600; color:#374151 }
/* items table */
.items-table { width:100%; border-collapse:collapse; font-size:.83rem }
.items-table th { background:#f8fafc; padding:7px 10px; font-size:.7rem; text-transform:uppercase;
letter-spacing:.06em; color:#64748b; border-bottom:1px solid #e2e8f0; white-space:nowrap }
.items-table td { padding:6px 8px; border-bottom:1px solid #f1f5f9; vertical-align:middle }
.items-table tr:last-child td { border-bottom:none }
.items-table input, .items-table select { font-size:.8rem; padding:3px 6px; border:1px solid #d1d5db;
border-radius:5px; width:100% }
.items-table input:focus, .items-table select:focus { outline:none; border-color:#6366f1 }
/* tarifas table */
.tar-table { width:100%; border-collapse:collapse; font-size:.83rem }
.tar-table th { background:#f8fafc; padding:7px 14px; font-size:.7rem; text-transform:uppercase;
letter-spacing:.06em; color:#64748b; border-bottom:1px solid #e2e8f0 }
.tar-table td { padding:7px 14px; border-bottom:1px solid #f1f5f9; vertical-align:middle }
.tar-table tr:last-child td { border-bottom:none }
.tar-table tr:hover td { background:#f8fafc }
.tar-val { font-family:monospace; font-size:.88rem }
.edit-inline { display:none; gap:4px; align-items:center }
.tr-editing .edit-inline { display:flex }
.tr-editing .val-display { display:none }
.inp-val { width:90px; padding:3px 6px; font-size:.83rem; border:1px solid #d1d5db; border-radius:5px }
.inp-val:focus { border-color:#6366f1; outline:none }
</style>
<div class="page-header">
<div class="breadcrumb-lab">
<a href="<?= BASE_URL ?>erp.php?m=lab_examenes&v=index"><i class="fas fa-flask me-1"></i>Exámenes</a>
<i class="fas fa-chevron-right mx-1" style="font-size:.7rem"></i>
<?= $exam ? htmlspecialchars($exam['nombre']) : 'Nuevo examen' ?>
</div>
<?php if ($exam): ?>
<button class="btn btn-outline-danger btn-sm ms-auto" onclick="eliminar(<?=$exam['id']?>, '<?= addslashes(htmlspecialchars($exam['nombre'])) ?>')">
<i class="fas fa-trash me-1"></i>Eliminar
</button>
<?php endif; ?>
</div>
<div class="content-wrap">
<div id="alerta-global" class="alert d-none mb-3 py-2"></div>
<!-- ── Datos generales ── -->
<div class="panel">
<div class="panel-hdr">
<i class="fas fa-info-circle text-primary"></i>
<h2>Datos generales</h2>
<button class="btn btn-primary btn-sm" onclick="guardarExamen()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
<div class="panel-body">
<input type="hidden" id="exam-id" value="<?= $exam['id'] ?? '' ?>">
<div class="row g-3">
<div class="col-sm-3">
<label class="form-label">Código <span class="text-danger">*</span></label>
<input type="text" id="f-codigo" class="form-control form-control-sm"
value="<?= htmlspecialchars($exam['codigo'] ?? '') ?>" maxlength="20" style="text-transform:uppercase">
</div>
<div class="col-sm-3">
<label class="form-label">Código CUPS</label>
<input type="text" id="f-cups" class="form-control form-control-sm"
value="<?= htmlspecialchars($exam['cups'] ?? '') ?>" maxlength="20">
</div>
<div class="col-sm-3">
<label class="form-label">Abreviatura</label>
<input type="text" id="f-abreviatura" class="form-control form-control-sm"
value="<?= htmlspecialchars($exam['abreviatura'] ?? '') ?>" maxlength="30">
</div>
<div class="col-sm-3">
<label class="form-label">Nivel</label>
<input type="number" id="f-nivel" class="form-control form-control-sm"
value="<?= $exam['nivel'] ?? '' ?>" min="0" max="9">
</div>
<div class="col-12">
<label class="form-label">Nombre <span class="text-danger">*</span></label>
<input type="text" id="f-nombre" class="form-control form-control-sm"
value="<?= htmlspecialchars($exam['nombre'] ?? '') ?>" maxlength="150">
</div>
<div class="col-sm-4">
<label class="form-label">Protocolo</label>
<select id="f-protocolo" class="form-select form-select-sm">
<option value="">— ninguno —</option>
<?php foreach ($protocolos as $p): ?>
<option value="<?= htmlspecialchars($p['codigo']) ?>"
<?= ($exam['cod_protocolo'] ?? '') === $p['codigo'] ? 'selected' : '' ?>>
<?= htmlspecialchars($p['codigo'] . ' ' . $p['nombre']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-sm-4">
<label class="form-label">Tipo de muestra</label>
<select id="f-muestra" class="form-select form-select-sm">
<option value="">— ninguna —</option>
<?php foreach ($muestras as $m): ?>
<option value="<?= htmlspecialchars($m['codigo']) ?>"
<?= ($exam['tipo_muestra'] ?? '') === $m['codigo'] ? 'selected' : '' ?>>
<?= htmlspecialchars($m['nombre']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-sm-4">
<label class="form-label">Formularios consentimiento</label>
<div id="f-formularios-wrap" class="border rounded p-2" style="max-height:120px;overflow-y:auto;background:#fffbeb">
<?php foreach ($formularios as $f): ?>
<div class="form-check mb-1">
<input class="form-check-input f-formulario-chk" type="checkbox"
value="<?= $f['id'] ?>" id="f-fc-<?= $f['id'] ?>"
<?= in_array((int)$f['id'], array_map('intval', $formsActuales)) ? 'checked' : '' ?>>
<label class="form-check-label small" for="f-fc-<?= $f['id'] ?>">
<?= htmlspecialchars($f['nombre']) ?>
</label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="col-sm-6">
<label class="form-label">Se recibe de (lab externo)</label>
<input type="text" id="f-serecibe" class="form-control form-control-sm"
value="<?= htmlspecialchars($exam['serecibe'] ?? '') ?>" maxlength="50">
</div>
<div class="col-sm-6">
<label class="form-label">Instrucciones para el paciente</label>
<input type="text" id="f-instrucciones" class="form-control form-control-sm"
value="<?= htmlspecialchars($exam['instrucciones'] ?? '') ?>">
</div>
<div class="col-12">
<div class="d-flex gap-4 flex-wrap">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="f-seremite"
<?= ($exam['seremite'] ?? 0) ? 'checked' : '' ?>>
<label class="form-check-label small" for="f-seremite">Se remite a laboratorio externo</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="f-ayuno"
<?= ($exam['requiere_ayuno'] ?? 0) ? 'checked' : '' ?>>
<label class="form-check-label small" for="f-ayuno">Requiere ayuno</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="f-activo"
<?= ($exam['activo'] ?? 1) ? 'checked' : '' ?>>
<label class="form-check-label small" for="f-activo">Activo</label>
</div>
</div>
</div>
<div class="col-sm-3" id="blq-ayuno" style="display:<?= ($exam['requiere_ayuno'] ?? 0) ? 'block' : 'none' ?>">
<label class="form-label">Horas de ayuno</label>
<input type="number" id="f-horas-ayuno" class="form-control form-control-sm"
value="<?= $exam['horas_ayuno'] ?? '' ?>" min="1" max="72">
</div>
</div>
</div>
</div>
<?php if ($exam): ?>
<!-- ── Items de resultado ── -->
<div class="panel">
<div class="panel-hdr">
<i class="fas fa-list text-success"></i>
<h2>Items / Valores de referencia</h2>
<button class="btn btn-outline-success btn-sm" onclick="addItem()">
<i class="fas fa-plus me-1"></i>Agregar item
</button>
</div>
<div style="overflow-x:auto">
<table class="items-table">
<thead>
<tr>
<th style="width:30px">#</th>
<th>Nombre item</th>
<th style="width:70px">Sexo</th>
<th style="width:70px">Tipo</th>
<th style="width:90px">Medida</th>
<th style="width:80px">V.Min</th>
<th style="width:80px">V.Max</th>
<th style="width:50px">Ord.</th>
<th>Fórmula</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody id="items-body">
<?php foreach ($items as $it): ?>
<tr data-id="<?=$it['id']?>" data-cod="<?=htmlspecialchars($exam['cod_protocolo']??$exam['codigo'])?>">
<?= _itemRow($it) ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- ── Tarifas ── -->
<div class="panel">
<div class="panel-hdr">
<i class="fas fa-tags text-warning"></i>
<h2>Precios por tarifa</h2>
<small class="text-muted">Clic en el valor para editar</small>
</div>
<div style="overflow-x:auto">
<table class="tar-table" id="tar-table">
<thead>
<tr>
<th style="width:30px">#</th>
<th>Tarifa</th>
<th style="width:120px">Valor</th>
<th style="width:100px">R. Urgencia</th>
<th style="width:100px">R. Festivo</th>
<th style="width:100px">R. Especial</th>
<th style="width:80px"></th>
</tr>
</thead>
<tbody id="tar-body">
<tr><td colspan="7" class="text-center text-muted py-3">Cargando tarifas…</td></tr>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
<?php
function _itemRow(array $it): string {
$sexos = [''=>'Ambos','M'=>'M','F'=>'F'];
$tipos = ['T'=>'Texto','N'=>'Numérico'];
$sOpts = ''; foreach($sexos as $v=>$l) $sOpts .= "<option value='$v'".($it['tipo_sexo']===$v?' selected':'').">$l</option>";
$tOpts = ''; foreach($tipos as $v=>$l) $tOpts .= "<option value='$v'".($it['tipo']===$v?' selected':'').">$l</option>";
return "
<td><input type='number' name='orden' value='{$it['orden']}' style='width:45px'></td>
<td><input type='text' name='nombre' value='".htmlspecialchars($it['nombre'])."' style='min-width:160px'></td>
<td><select name='tipo_sexo'>$sOpts</select></td>
<td><select name='tipo'>$tOpts</select></td>
<td><input type='text' name='medida' value='".htmlspecialchars($it['medida']??'')."'></td>
<td><input type='number' name='vmin_ref' value='".($it['vmin_ref']??'')."' step='any'></td>
<td><input type='number' name='vmax_ref' value='".($it['vmax_ref']??'')."' step='any'></td>
<td><input type='number' name='orden2' value='{$it['orden']}' style='width:45px' disabled></td>
<td><input type='text' name='formula' value='".htmlspecialchars($it['formula']??'')."'></td>
<td><button type='button' class='btn btn-sm btn-outline-success py-0 px-1 me-1' onclick='saveItem(this)' title='Guardar'><i class='fas fa-check'></i></button>
<button type='button' class='btn btn-sm btn-outline-danger py-0 px-1' onclick='delItem(this)' title='Eliminar'><i class='fas fa-trash'></i></button></td>
";
}
?>
<script>
const API = '<?= $API ?>';
const EXAM_ID = <?= $id ?: 'null' ?>;
const COD_PROT = '<?= addslashes($exam['cod_protocolo'] ?? ($exam['codigo'] ?? '')) ?>';
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('f-ayuno')?.addEventListener('change', e => {
document.getElementById('blq-ayuno').style.display = e.target.checked ? 'block' : 'none';
});
if (EXAM_ID) cargarTarifas();
});
// ── Guardar examen ─────────────────────────────────
async function guardarExamen() {
const payload = {
id: document.getElementById('exam-id').value || null,
codigo: document.getElementById('f-codigo').value.trim().toUpperCase(),
nombre: document.getElementById('f-nombre').value.trim(),
cups: document.getElementById('f-cups').value.trim(),
abreviatura: document.getElementById('f-abreviatura').value.trim(),
nivel: document.getElementById('f-nivel').value,
cod_protocolo: document.getElementById('f-protocolo').value,
tipo_muestra: document.getElementById('f-muestra').value,
seremite: document.getElementById('f-seremite').checked ? 1 : 0,
requiere_ayuno: document.getElementById('f-ayuno').checked ? 1 : 0,
horas_ayuno: document.getElementById('f-horas-ayuno')?.value || null,
instrucciones: document.getElementById('f-instrucciones').value.trim(),
serecibe: document.getElementById('f-serecibe').value.trim(),
activo: document.getElementById('f-activo').checked ? 1 : 0,
form_ids: [...document.querySelectorAll('.f-formulario-chk:checked')].map(c => parseInt(c.value)),
};
const r = await fetch(API+'save.php', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json());
if (r.ok) {
mostrarAlerta('success', r.message || 'Guardado');
if (!payload.id && r.id) setTimeout(() => location.href = location.href.split('?')[0] + '?m=lab_examenes&v=examen&id='+r.id, 800);
} else {
mostrarAlerta('danger', r.error || 'Error');
}
}
async function eliminar(id, nombre) {
if (!confirm(`¿Eliminar "${nombre}"? No se puede deshacer.`)) return;
const r = await fetch(API+'save.php', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,_delete:true})}).then(r=>r.json());
if (r.ok) location.href = '<?= BASE_URL ?>erp.php?m=lab_examenes&v=index';
else mostrarAlerta('danger', r.error);
}
// ── Items ─────────────────────────────────────────
function addItem() {
const tr = document.createElement('tr');
tr.dataset.id = '0';
tr.dataset.cod = COD_PROT;
tr.innerHTML = `
<td><input type='number' name='orden' value='0' style='width:45px'></td>
<td><input type='text' name='nombre' placeholder='Nombre del item' style='min-width:160px'></td>
<td><select name='tipo_sexo'><option value=''>Ambos</option><option value='M'>M</option><option value='F'>F</option></select></td>
<td><select name='tipo'><option value='T'>Texto</option><option value='N'>Numérico</option></select></td>
<td><input type='text' name='medida'></td>
<td><input type='number' name='vmin_ref' step='any'></td>
<td><input type='number' name='vmax_ref' step='any'></td>
<td><input type='number' name='orden2' style='width:45px' disabled></td>
<td><input type='text' name='formula'></td>
<td><button type='button' class='btn btn-sm btn-outline-success py-0 px-1 me-1' onclick='saveItem(this)'><i class='fas fa-check'></i></button>
<button type='button' class='btn btn-sm btn-outline-danger py-0 px-1' onclick='this.closest("tr").remove()'><i class='fas fa-times'></i></button></td>`;
document.getElementById('items-body').appendChild(tr);
tr.querySelector('[name=nombre]').focus();
}
async function saveItem(btn) {
const tr = btn.closest('tr');
const g = n => tr.querySelector(`[name=${n}]`)?.value ?? '';
const payload = {
id: +tr.dataset.id || null,
cod_protocolo: tr.dataset.cod || COD_PROT,
nombre: g('nombre').trim(),
tipo_sexo: g('tipo_sexo') || null,
tipo: g('tipo') || 'T',
medida: g('medida'),
vmin_ref: g('vmin_ref'),
vmax_ref: g('vmax_ref'),
orden: +g('orden') || 0,
formula: g('formula'),
};
if (!payload.nombre) { alert('Nombre requerido'); return; }
const r = await fetch(API+'save_item.php',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json());
if (r.ok) { tr.dataset.id = r.id || tr.dataset.id; mostrarAlerta('success','Item guardado'); }
else mostrarAlerta('danger', r.error);
}
async function delItem(btn) {
const tr = btn.closest('tr');
const id = +tr.dataset.id;
if (id && !confirm('¿Eliminar este item?')) return;
if (id) {
const r = await fetch(API+'save_item.php',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,_delete:true})}).then(r=>r.json());
if (!r.ok) { mostrarAlerta('danger', r.error); return; }
}
tr.remove();
}
// ── Tarifas ──────────────────────────────────────
let _tarifas = [];
async function cargarTarifas() {
const r = await fetch(API+'get_tarifas.php?exam_id='+EXAM_ID).then(r=>r.json());
if (!r.ok) return;
_tarifas = r.data;
renderTarifas();
}
function renderTarifas() {
const body = document.getElementById('tar-body');
if (!_tarifas.length) { body.innerHTML='<tr><td colspan="7" class="text-center text-muted py-3">Sin tarifas.</td></tr>'; return; }
body.innerHTML = _tarifas.map((t,i) => {
const v = t.valor !== null ? parseFloat(t.valor).toLocaleString('es-CO') : '—';
return `<tr data-tidx="${i}">
<td class="text-muted" style="font-size:.75rem">${t.tarifa_id}</td>
<td>${esc(t.tarifa_nombre)}</td>
<td class="tar-val val-display" onclick="editTarifa(this)">${v}</td>
<td class="text-muted tar-val" style="font-size:.8rem">${t.recargo_urg>0?parseFloat(t.recargo_urg).toLocaleString('es-CO'):'—'}</td>
<td class="text-muted tar-val" style="font-size:.8rem">${t.recargo_fes>0?parseFloat(t.recargo_fes).toLocaleString('es-CO'):'—'}</td>
<td class="text-muted tar-val" style="font-size:.8rem">${t.recargo_esp>0?parseFloat(t.recargo_esp).toLocaleString('es-CO'):'—'}</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-2" onclick="abrirEditorTarifa(${i})" title="Editar precios">
<i class="fas fa-pencil-alt"></i>
</button>
</td>
</tr>`;
}).join('');
}
function abrirEditorTarifa(idx) {
const t = _tarifas[idx];
const modal = new bootstrap.Modal(document.getElementById('modalTarifa'));
document.getElementById('mt-nombre').textContent = t.tarifa_nombre;
document.getElementById('mt-idx').value = idx;
document.getElementById('mt-valor').value = t.valor ?? '';
document.getElementById('mt-urg').value = t.recargo_urg ?? 0;
document.getElementById('mt-fes').value = t.recargo_fes ?? 0;
document.getElementById('mt-esp').value = t.recargo_esp ?? 0;
modal.show();
document.getElementById('mt-valor').focus();
}
async function guardarTarifa() {
const idx = +document.getElementById('mt-idx').value;
const t = _tarifas[idx];
const payload = {
exam_tipo_id: EXAM_ID,
tarifa_id: t.tarifa_id,
valor: parseFloat(document.getElementById('mt-valor').value) || 0,
recargo_urg: parseFloat(document.getElementById('mt-urg').value) || 0,
recargo_fes: parseFloat(document.getElementById('mt-fes').value) || 0,
recargo_esp: parseFloat(document.getElementById('mt-esp').value) || 0,
};
const btn = document.getElementById('btn-guardar-tarifa');
btn.disabled = true;
const r = await fetch(API+'save_tarifa.php',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json());
btn.disabled = false;
if (r.ok) {
bootstrap.Modal.getInstance(document.getElementById('modalTarifa')).hide();
cargarTarifas();
mostrarAlerta('success','Precio guardado');
} else {
mostrarAlerta('danger', r.error);
}
}
function mostrarAlerta(tipo, msg) {
const el = document.getElementById('alerta-global');
el.className = `alert alert-${tipo} py-2`;
el.textContent = msg;
el.classList.remove('d-none');
setTimeout(()=>el.classList.add('d-none'), 3500);
el.scrollIntoView({behavior:'smooth',block:'nearest'});
}
function esc(s){if(!s)return'';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
</script>
<!-- Modal editar tarifa -->
<div class="modal fade" id="modalTarifa" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="fas fa-tag me-1"></i><span id="mt-nombre"></span></h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="mt-idx">
<div class="row g-2">
<div class="col-6">
<label class="form-label" style="font-size:.78rem;font-weight:600">Valor base</label>
<input type="number" id="mt-valor" class="form-control form-control-sm" step="1" min="0">
</div>
<div class="col-6">
<label class="form-label" style="font-size:.78rem;font-weight:600">R. Urgencia</label>
<input type="number" id="mt-urg" class="form-control form-control-sm" step="1" min="0">
</div>
<div class="col-6">
<label class="form-label" style="font-size:.78rem;font-weight:600">R. Festivo</label>
<input type="number" id="mt-fes" class="form-control form-control-sm" step="1" min="0">
</div>
<div class="col-6">
<label class="form-label" style="font-size:.78rem;font-weight:600">R. Especial</label>
<input type="number" id="mt-esp" class="form-control form-control-sm" step="1" min="0">
</div>
</div>
</div>
<div class="modal-footer py-2">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-primary btn-sm" id="btn-guardar-tarifa" onclick="guardarTarifa()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
</div>
</div>
<?php Layout::close(); ?>
+167
View File
@@ -0,0 +1,167 @@
<?php
require_once APP_ROOT . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
Layout::open('Catálogo de Exámenes', 'fas fa-flask');
$pdo = Database::getInstance()->getConnection();
$categorias = $pdo->query(
"SELECT DISTINCT categoria FROM exam_tipos WHERE categoria IS NOT NULL ORDER BY categoria"
)->fetchAll(PDO::FETCH_COLUMN);
$API = BASE_URL . 'modules/lab_examenes/api/';
$DETAIL = BASE_URL . 'erp.php?m=lab_examenes&v=examen&id=';
?>
<style>
body { background:#f1f5f9 }
.page-header { background:#fff; border-bottom:1px solid #e2e8f0; padding:14px 24px;
display:flex; align-items:center; gap:12px; flex-wrap:wrap }
.page-header h1 { font-size:1.18rem; font-weight:700; color:#1e293b; margin:0; flex:1 }
.content-wrap { max-width:1200px; margin:24px auto; padding:0 16px 48px }
.card-box { background:#fff; border:1px solid #e2e8f0; border-radius:12px; overflow:hidden }
.toolbar { padding:12px 16px; display:flex; gap:10px; align-items:center;
border-bottom:1px solid #e2e8f0; flex-wrap:wrap }
.toolbar input, .toolbar select { font-size:.85rem }
table { width:100%; border-collapse:collapse; font-size:.88rem }
thead th { background:#f8fafc; padding:9px 14px; font-size:.7rem; text-transform:uppercase;
letter-spacing:.07em; color:#64748b; border-bottom:1px solid #e2e8f0; white-space:nowrap }
tbody td { padding:9px 14px; border-bottom:1px solid #f1f5f9; color:#1e293b; vertical-align:middle }
tbody tr:last-child td { border-bottom:none }
tbody tr:hover td { background:#f8fafc }
.badge-cat { background:#eff6ff; color:#1d4ed8; border-radius:20px; padding:2px 9px;
font-size:.72rem; font-weight:600; white-space:nowrap }
.badge-remite { background:#dcfce7; color:#166534; border-radius:20px; padding:2px 9px; font-size:.72rem }
.badge-off { background:#f1f5f9; color:#94a3b8; border-radius:20px; padding:2px 9px; font-size:.72rem }
.pag-bar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.5rem;
padding:10px 16px; border-top:1px solid #e2e8f0; background:#f8fafc }
.pag-info { font-size:.8rem; color:#64748b }
.pag-btn { min-width:32px; height:32px; padding:0 8px; border:1px solid #e2e8f0; border-radius:7px;
background:#fff; font-size:.82rem; color:#374151; cursor:pointer;
display:inline-flex; align-items:center; justify-content:center }
.pag-btn:hover:not(:disabled) { background:#eff6ff; border-color:#bfdbfe; color:#1d4ed8 }
.pag-btn.active { background:#2563eb; color:#fff; border-color:#2563eb }
.pag-btn:disabled { opacity:.4; cursor:not-allowed }
.toggle-activo { cursor:pointer; font-size:1.05rem }
</style>
<div class="page-header">
<i class="fas fa-flask" style="font-size:1.3rem;color:#6366f1"></i>
<h1>Catálogo de Exámenes</h1>
<a href="<?= BASE_URL ?>erp.php?m=lab_examenes&v=examen" class="btn btn-primary btn-sm ms-auto">
<i class="fas fa-plus me-1"></i>Nuevo examen
</a>
</div>
<div class="content-wrap">
<div class="card-box">
<div class="toolbar">
<input type="search" id="inp-q" class="form-control form-control-sm"
placeholder="Buscar por nombre, código o CUPS…" style="max-width:280px" oninput="buscar()">
<select id="sel-cat" class="form-select form-select-sm" style="max-width:220px" onchange="cargar(1)">
<option value="">Todas las categorías</option>
<?php foreach ($categorias as $c): ?>
<option value="<?= htmlspecialchars($c) ?>"><?= htmlspecialchars($c) ?></option>
<?php endforeach; ?>
</select>
<span class="text-muted small ms-auto" id="lbl-total"></span>
</div>
<div style="overflow-x:auto">
<table>
<thead>
<tr>
<th>Código</th>
<th>Nombre</th>
<th>Categoría</th>
<th>CUPS</th>
<th>Protocolo</th>
<th>Se remite</th>
<th>Activo</th>
<th></th>
</tr>
</thead>
<tbody id="tbl-body">
<tr><td colspan="8" class="text-center text-muted py-4">Cargando…</td></tr>
</tbody>
</table>
</div>
<div class="pag-bar" id="pag-bar" style="display:none">
<span class="pag-info" id="pag-info"></span>
<div id="pag-ctrl" style="display:flex;gap:4px"></div>
</div>
</div>
</div>
<script>
const API = '<?= $API ?>';
const DETAIL = '<?= $DETAIL ?>';
let _page=1, _limit=50, _total=0, _q='', _timer;
document.addEventListener('DOMContentLoaded', () => cargar(1));
function buscar() {
clearTimeout(_timer);
_timer = setTimeout(() => cargar(1), 260);
}
async function cargar(page=_page) {
_page = page;
_q = document.getElementById('inp-q').value.trim();
const cat = document.getElementById('sel-cat').value;
const p = new URLSearchParams({page, limit:_limit});
if (_q) p.set('q', _q);
if (cat) p.set('categoria', cat);
const r = await fetch(API+'list.php?'+p).then(r=>r.json());
if (!r.ok) return;
_total = r.total;
renderTabla(r.data);
renderPag(Math.ceil(_total/_limit));
const d=(_page-1)*_limit+1, h=Math.min(_page*_limit,_total);
document.getElementById('lbl-total').textContent = _total ? `${d}${h} de ${_total}` : '0 exámenes';
}
function renderTabla(rows) {
const b = document.getElementById('tbl-body');
if (!rows.length) { b.innerHTML='<tr><td colspan="8" class="text-center text-muted py-4">Sin resultados.</td></tr>'; return; }
b.innerHTML = rows.map(r => `
<tr>
<td><code class="text-primary">${esc(r.codigo)}</code></td>
<td><a href="${DETAIL+r.id}" class="text-decoration-none fw-semibold text-dark">${esc(r.nombre)}</a></td>
<td>${r.categoria ? `<span class="badge-cat">${esc(r.categoria)}</span>` : '<span class="text-muted">—</span>'}</td>
<td><small class="text-muted">${esc(r.cups||'—')}</small></td>
<td><small class="text-muted">${esc(r.cod_protocolo||'—')}</small></td>
<td>${+r.seremite ? '<span class="badge-remite">Sí</span>' : '<span class="badge-off">No</span>'}</td>
<td><span class="toggle-activo" title="Clic para cambiar" onclick="toggleActivo(${r.id},${r.activo})">${+r.activo?'✅':'⬜'}</span></td>
<td><a href="${DETAIL+r.id}" class="btn btn-outline-primary btn-sm py-0 px-2"><i class="fas fa-pencil-alt"></i></a></td>
</tr>`).join('');
}
async function toggleActivo(id, actual) {
const r = await fetch(API+'save.php', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({id, activo: actual=='1'||actual===1 ? 0 : 1})
}).then(r=>r.json());
if (r.ok) cargar(_page);
}
function renderPag(total) {
const bar = document.getElementById('pag-bar');
const ctrl = document.getElementById('pag-ctrl');
if (total<=1) { bar.style.display='none'; return; }
bar.style.display='flex';
document.getElementById('pag-info').textContent = `Página ${_page} de ${total}`;
const pages = new Set([1,total,_page]);
for(let i=_page-2;i<=_page+2;i++) if(i>0&&i<=total) pages.add(i);
const sorted=[...pages].sort((a,b)=>a-b);
let h=`<button class="pag-btn" onclick="cargar(${_page-1})" ${_page===1?'disabled':''}><i class="fas fa-chevron-left"></i></button>`;
let prev=0;
for(const p of sorted){
if(prev&&p-prev>1) h+=`<span style="padding:0 4px;line-height:32px;font-size:.82rem;color:#94a3b8">…</span>`;
h+=`<button class="pag-btn ${p===_page?'active':''}" onclick="cargar(${p})">${p}</button>`;
prev=p;
}
h+=`<button class="pag-btn" onclick="cargar(${_page+1})" ${_page===total?'disabled':''}><i class="fas fa-chevron-right"></i></button>`;
ctrl.innerHTML=h;
}
function esc(s){if(!s)return'';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
</script>
<?php Layout::close(); ?>
+28 -14
View File
@@ -6,21 +6,35 @@ header('Content-Type: application/json');
$pdo = Database::getInstance()->getConnection();
$buscar = trim($_GET['q'] ?? '');
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = max(1, min(200, (int)($_GET['limit'] ?? 25)));
$offset = ($page - 1) * $limit;
$where = '';
$params = [];
if ($buscar !== '') {
$like = '%' . $buscar . '%';
$stmt = $pdo->prepare(
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
FROM medicos
WHERE nombres LIKE ? OR apellidos LIKE ? OR codigo LIKE ? OR docidmedico LIKE ?
ORDER BY apellidos, nombres"
);
$stmt->execute([$like, $like, $like, $like]);
} else {
$stmt = $pdo->query(
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
FROM medicos ORDER BY apellidos, nombres"
);
$like = '%' . $buscar . '%';
$where = "WHERE nombres LIKE ? OR apellidos LIKE ? OR codigo LIKE ? OR docidmedico LIKE ?";
$params = [$like, $like, $like, $like];
}
echo json_encode(['ok' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM medicos $where");
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();
$dataStmt = $pdo->prepare(
"SELECT id, codigo, nombres, apellidos, telefonos, email, cod_especialidad, docidmedico, activo
FROM medicos $where
ORDER BY apellidos, nombres
LIMIT $limit OFFSET $offset"
);
$dataStmt->execute($params);
echo json_encode([
'ok' => true,
'data' => $dataStmt->fetchAll(PDO::FETCH_ASSOC),
'total' => $total,
'page' => $page,
'limit' => $limit,
]);
+71 -9
View File
@@ -29,6 +29,23 @@ $API = BASE_URL . 'modules/medicos/api/';
font-size: .75rem; font-weight: 600; }
.acciones { display: flex; gap: 6px; }
#tbl-empty { text-align: center; padding: 3rem; color: #94a3b8; }
.pag-bar {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .5rem;
padding: 10px 16px; border-top: 1px solid #e2e8f0; background: #f8fafc;
}
.pag-bar .pag-info { font-size: .8rem; color: #64748b; }
.pag-controls { display: flex; gap: 4px; align-items: center; }
.pag-btn {
min-width: 32px; height: 32px; padding: 0 8px;
border: 1px solid #e2e8f0; border-radius: 7px; background: #fff;
font-size: .82rem; color: #374151; cursor: pointer; font-weight: 500;
display: flex; align-items: center; justify-content: center;
transition: background .12s, border-color .12s;
}
.pag-btn:hover:not(:disabled) { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
.pag-btn.active { background: #2563eb; color: #fff; border-color: #2563eb; }
.pag-btn:disabled { opacity: .4; cursor: not-allowed; }
.pag-ellipsis { font-size: .82rem; color: #94a3b8; padding: 0 4px; line-height: 32px; }
</style>
<div class="page-header">
@@ -60,10 +77,14 @@ $API = BASE_URL . 'modules/medicos/api/';
</tr>
</thead>
<tbody id="tbl-body">
<tr id="tbl-empty"><td colspan="6">Cargando…</td></tr>
<tr id="tbl-empty"><td colspan="7">Cargando…</td></tr>
</tbody>
</table>
</div>
<div class="pag-bar" id="pag-bar" style="display:none">
<span class="pag-info" id="pag-info"></span>
<div class="pag-controls" id="pag-controls"></div>
</div>
</div>
</div>
@@ -150,6 +171,7 @@ $API = BASE_URL . 'modules/medicos/api/';
<script>
const API = '<?= $API ?>';
let _modal, _modalDel, _pendingDelId, _buscarTimer;
let _page = 1, _limit = 25, _total = 0, _q = '';
document.addEventListener('DOMContentLoaded', () => {
_modal = new bootstrap.Modal(document.getElementById('modalMedico'));
@@ -157,24 +179,33 @@ document.addEventListener('DOMContentLoaded', () => {
cargar();
});
async function cargar(q = '') {
const url = API + 'list.php' + (q ? '?q=' + encodeURIComponent(q) : '');
const res = await fetch(url);
async function cargar(q = _q, page = _page) {
_q = q;
_page = page;
const params = new URLSearchParams({ page, limit: _limit });
if (q) params.set('q', q);
const res = await fetch(API + 'list.php?' + params);
const json = await res.json();
if (!json.ok) return;
_total = json.total;
_page = json.page;
renderTabla(json.data);
renderPaginacion();
}
function buscar() {
clearTimeout(_buscarTimer);
_buscarTimer = setTimeout(() => cargar(document.getElementById('inp-buscar').value.trim()), 280);
_buscarTimer = setTimeout(() => cargar(document.getElementById('inp-buscar').value.trim(), 1), 280);
}
function renderTabla(rows) {
const body = document.getElementById('tbl-body');
document.getElementById('lbl-total').textContent = rows.length + ' médico(s)';
const desde = (_page - 1) * _limit + 1;
const hasta = Math.min(_page * _limit, _total);
document.getElementById('lbl-total').textContent =
_total ? `${desde}${hasta} de ${_total} médico(s)` : '0 médicos';
if (!rows.length) {
body.innerHTML = '<tr id="tbl-empty"><td colspan="6">Sin resultados.</td></tr>';
body.innerHTML = '<tr id="tbl-empty"><td colspan="7">Sin resultados.</td></tr>';
return;
}
body.innerHTML = rows.map(m => `
@@ -198,6 +229,37 @@ function renderTabla(rows) {
</tr>`).join('');
}
function renderPaginacion() {
const totalPages = Math.ceil(_total / _limit);
const bar = document.getElementById('pag-bar');
const info = document.getElementById('pag-info');
const ctrl = document.getElementById('pag-controls');
if (totalPages <= 1) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
info.textContent = `Página ${_page} de ${totalPages}`;
// Compute page window: always show first, last, current ±2
const pages = new Set([1, totalPages, _page]);
for (let i = _page - 2; i <= _page + 2; i++) if (i > 0 && i <= totalPages) pages.add(i);
const sorted = [...pages].sort((a, b) => a - b);
let html = '';
html += `<button class="pag-btn" onclick="cargar(_q,${_page-1})" ${_page===1?'disabled':''}>
<i class="fas fa-chevron-left"></i>
</button>`;
let prev = 0;
for (const p of sorted) {
if (prev && p - prev > 1) html += `<span class="pag-ellipsis">…</span>`;
html += `<button class="pag-btn ${p===_page?'active':''}" onclick="cargar(_q,${p})">${p}</button>`;
prev = p;
}
html += `<button class="pag-btn" onclick="cargar(_q,${_page+1})" ${_page===totalPages?'disabled':''}>
<i class="fas fa-chevron-right"></i>
</button>`;
ctrl.innerHTML = html;
}
function abrirModal(m = null) {
document.getElementById('modal-titulo').textContent = m ? 'Editar médico' : 'Nuevo médico';
document.getElementById('modal-alert').classList.add('d-none');
@@ -247,7 +309,7 @@ async function guardar() {
return;
}
_modal.hide();
cargar(document.getElementById('inp-buscar').value.trim());
cargar(_q, _page);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar';
@@ -272,7 +334,7 @@ async function confirmarEliminar() {
const json = await res.json();
if (json.ok) {
_modalDel.hide();
cargar(document.getElementById('inp-buscar').value.trim());
cargar(_q, _page);
}
} finally {
btn.disabled = false;
+13 -7
View File
@@ -198,7 +198,7 @@ function siguienteNumeroPorPrioridad(int $sesionId, int $prioridadId): int
* @param array|null $lugarIds Lista explícita de lugar_ids (grupo completo)
* @return array|null Fila de turnero_turnos o null si cola vacía
*/
function siguienteTurnoEnCola(string $estado, ?int $lugarId = null, ?array $lugarIds = null): ?array
function siguienteTurnoEnCola(string $estado, ?int $lugarId = null, ?array $lugarIds = null, ?int $sesionId = null): ?array
{
$pdo = db();
@@ -207,6 +207,8 @@ function siguienteTurnoEnCola(string $estado, ?int $lugarId = null, ?array $luga
$lugarIds = lugarIdsDeGrupo($lugarId);
}
$sesionCond = $sesionId !== null ? 'AND t.sesion_id = ?' : '';
if (!empty($lugarIds)) {
$in = implode(',', array_map('intval', $lugarIds));
$sql = "
@@ -214,25 +216,29 @@ function siguienteTurnoEnCola(string $estado, ?int $lugarId = null, ?array $luga
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.estado = ?
$sesionCond
AND t.lugar_destino_id IN ($in)
ORDER BY p.orden_peso ASC, t.creado_at ASC
LIMIT 1
FOR UPDATE
";
$stmt = $pdo->prepare($sql);
$stmt->execute([$estado]);
$binds = $sesionId !== null ? [$estado, $sesionId] : [$estado];
$stmt = $pdo->prepare($sql);
$stmt->execute($binds);
} else {
$sql = '
$sql = "
SELECT t.*, p.orden_peso, p.codigo AS prioridad_codigo, p.color AS prioridad_color
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.estado = ?
$sesionCond
ORDER BY p.orden_peso ASC, t.creado_at ASC
LIMIT 1
FOR UPDATE
';
$stmt = $pdo->prepare($sql);
$stmt->execute([$estado]);
";
$binds = $sesionId !== null ? [$estado, $sesionId] : [$estado];
$stmt = $pdo->prepare($sql);
$stmt->execute($binds);
}
$row = $stmt->fetch(PDO::FETCH_ASSOC);
+22
View File
@@ -0,0 +1,22 @@
<?php
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
requireTurnero();
$q = trim($_GET['q'] ?? '');
if (strlen($q) < 2) { jsonOk(['items' => []]); }
$pdo = db();
$like = '%' . $q . '%';
// Código exacto primero, luego coincidencias por descripción
$stmt = $pdo->prepare(
"SELECT cod_diag, concepto
FROM cie10_diagnosticos
WHERE cod_diag LIKE ? OR concepto LIKE ?
ORDER BY CASE WHEN cod_diag LIKE ? THEN 0 ELSE 1 END, cod_diag
LIMIT 12"
);
$stmt->execute([$like, $like, $like]);
jsonOk(['items' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
+6 -1
View File
@@ -29,6 +29,7 @@ $datos = inputJson();
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
$nuevoEstado = isset($datos['nuevo_estado']) ? trim((string) $datos['nuevo_estado']) : '';
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
$deskId = isset($datos['desk_id']) ? (int) $datos['desk_id'] : null;
// ── Validación básica ─────────────────────────────────────────
if ($turnoId <= 0) jsonError('turno_id inválido.');
@@ -107,10 +108,14 @@ try {
switch ($nuevoEstado) {
case 'en_recepcion':
$sets[] = 'llamado_recepcion_at = COALESCE(llamado_recepcion_at, NOW())';
$sets[] = 'llamado_recepcion_at = NOW()';
$sets[] = 'inicio_recepcion_at = COALESCE(inicio_recepcion_at, NOW())';
$sets[] = 'atendido_recepcion_por = COALESCE(atendido_recepcion_por, ?)';
$binds[] = adminId();
if ($deskId) {
$sets[] = 'recepcion_desk_id = ?';
$binds[] = $deskId;
}
break;
case 'en_espera_lugar':
@@ -0,0 +1,33 @@
<?php
/**
* POST /modules/turnero/api/cancelar_toma_pendiente.php
* Marca una toma progresiva en_progreso como rechazada (paciente no quiere continuar).
* Body: { consentimiento_id }
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$body = inputJson();
$consentId = (int)($body['consentimiento_id'] ?? 0);
$turnoId = (int)($body['turno_id'] ?? 0);
if (!$consentId) jsonError('consentimiento_id requerido.');
if (!$turnoId) jsonError('turno_id requerido.');
$pdo = db();
// Verificar que el consentimiento pertenece al mismo paciente que el turno activo.
// Previene que un turnero cancele tomas de pacientes que no tiene abiertos (IDOR).
$stmt = $pdo->prepare(
"UPDATE turnero_consentimientos tc
JOIN turnero_turnos t_old ON t_old.id = tc.turno_id
JOIN turnero_turnos t_cur ON t_cur.id = ? AND t_cur.paciente_id IS NOT NULL
AND t_cur.paciente_id = t_old.paciente_id
SET tc.estado = 'rechazado'
WHERE tc.id = ? AND tc.estado = 'en_progreso'"
);
$stmt->execute([$turnoId, $consentId]);
if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404);
jsonOk(['cancelado' => true]);
@@ -0,0 +1,29 @@
<?php
/**
* GET ?q= — Busca pacientes en lab_pacientes por nombre o documento.
* Usado por el modal "Nuevo mensaje" del chat turnero.
*/
require_once __DIR__ . '/../../../config/config.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
$q = trim($_GET['q'] ?? '');
if (strlen($q) < 2) { echo json_encode(['ok' => true, 'data' => []]); exit; }
try {
$pdo = Database::getInstance()->getConnection();
$like = '%' . $q . '%';
$rows = $pdo->prepare(
"SELECT id, nombre_completo, telefono, numero_documento
FROM lab_pacientes
WHERE is_active = 1
AND (nombre_completo LIKE ? OR numero_documento LIKE ? OR telefono LIKE ?)
ORDER BY nombre_completo ASC
LIMIT 20"
);
$rows->execute([$like, $like, $like]);
echo json_encode(['ok' => true, 'data' => $rows->fetchAll(PDO::FETCH_ASSOC)]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
+7 -5
View File
@@ -75,13 +75,15 @@ try {
$earliestId = $rows[0]['id'] ?? null;
$data = array_map(function($m) {
$mediaUrl = $m['media_url'] ?? null;
$external = null;
if ($mediaUrl) {
$mediaUrl = $m['media_url'] ?? null;
$localFile = $m['local_file'] ?? null;
$external = null;
if ($mediaUrl && !$localFile) {
// WhatsApp CDN URLs require Bearer token — always proxy through media-url.php
if (preg_match('#^https?://#i', $mediaUrl)) {
$external = $mediaUrl;
$external = '/api/version/media-url.php?url=' . urlencode($mediaUrl);
} else {
$external = '../../../api/get_media.php?id=' . urlencode($mediaUrl);
$external = '/api/version/media-url.php?id=' . urlencode($mediaUrl);
}
}
return [
@@ -0,0 +1,30 @@
<?php
/**
* GET — Plantillas habilitadas para el chat turnero.
* Crea la tabla si no existe aún.
*/
require_once __DIR__ . '/../../../config/config.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
try {
$pdo = Database::getInstance()->getConnection();
$pdo->exec("CREATE TABLE IF NOT EXISTS turnero_chat_plantillas (
template_id INT NOT NULL,
PRIMARY KEY (template_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$rows = $pdo->query(
"SELECT t.id, t.name, t.template_name, t.language_code, t.body_text,
t.header_text, t.header_type, t.footer_text, t.example_parameters, t.status
FROM turnero_chat_plantillas p
JOIN message_templates t ON t.id = p.template_id
ORDER BY t.name ASC"
)->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['ok' => true, 'data' => $rows]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
+1 -1
View File
@@ -34,7 +34,7 @@ try {
// Guardar reacción en el mensaje
$db->query(
"UPDATE messages SET reaction_emoji = ?, reaction_to_message_id = ? WHERE whatsapp_message_id = ?",
"UPDATE conversations SET reaction_emoji = ?, reaction_to_message_id = ? WHERE message_id = ?",
[$emoji, $messageId, $messageId]
);
@@ -0,0 +1,42 @@
<?php
/**
* POST — Guarda la lista de plantillas habilitadas para el chat turnero.
* Body: { ids: [1, 2, 3] }
*/
require_once __DIR__ . '/../../../config/config.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'Método no permitido']);
exit;
}
try {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$ids = array_map('intval', $input['ids'] ?? []);
$pdo = Database::getInstance()->getConnection();
$pdo->exec("CREATE TABLE IF NOT EXISTS turnero_chat_plantillas (
template_id INT NOT NULL,
PRIMARY KEY (template_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$pdo->beginTransaction();
$pdo->exec("DELETE FROM turnero_chat_plantillas");
if (!empty($ids)) {
$ph = implode(',', array_fill(0, count($ids), '(?)'));
$stmt = $pdo->prepare("INSERT IGNORE INTO turnero_chat_plantillas (template_id) VALUES $ph");
$stmt->execute($ids);
}
$pdo->commit();
echo json_encode(['ok' => true]);
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
+4 -1
View File
@@ -35,7 +35,10 @@ try {
$wa = new WhatsAppService('turnero');
if ($type === 'template') {
$response = $wa->sendTemplateMessage($user['phone_number'], $template, $lang, $params);
error_log("chat_send_message template: name={$template} lang={$lang} params=" . json_encode($params));
$meta = ['canal' => 'turnero'];
if ($operatorId) $meta['operator_id'] = $operatorId;
$response = $wa->sendTemplateMessage($user['phone_number'], $template, $lang, $params, [], null, $meta);
} else {
$extra = ['canal' => 'turnero'];
if ($operatorId) $extra['operator_id'] = $operatorId;

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