From 318a1742cc32a6da92f93bc11bc95da9f6b448c3 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:41:01 -0500 Subject: [PATCH] up --- PLAN_ERP_MULTIMODULO.txt | 656 ++++++++++++-- migrations/003_turnero.sql | 7 + migrations/005_registro_exams.sql | 125 +++ modules/registro_exams/api/_helpers.php | 159 ++++ .../api/cambiar_estado_item.php | 143 +++ modules/registro_exams/api/get_orden.php | 111 +++ modules/registro_exams/api/get_ordenes.php | 102 +++ modules/registro_exams/api/save_orden.php | 148 +++ modules/registro_exams/api/save_resultado.php | 121 +++ modules/registro_exams/module.php | 21 + modules/registro_exams/views/etiqueta.php | 325 +++++++ modules/registro_exams/views/lista.php | 257 ++++++ modules/registro_exams/views/nueva_orden.php | 419 +++++++++ modules/registro_exams/views/orden.php | 536 +++++++++++ modules/turnero/api/_helpers.php | 219 +++++ modules/turnero/api/cambiar_estado.php | 174 ++++ modules/turnero/api/create_solicitud.php | 171 ++++ modules/turnero/api/create_turno.php | 113 +++ modules/turnero/api/export_csv.php | 125 +++ modules/turnero/api/get_cola.php | 125 +++ modules/turnero/api/get_consentimientos.php | 98 ++ modules/turnero/api/get_dashboard.php | 161 ++++ modules/turnero/api/llamar_turno.php | 106 +++ modules/turnero/api/save_ex_tipo.php | 100 ++ modules/turnero/api/save_lugar.php | 65 ++ modules/turnero/api/save_prioridad.php | 34 + modules/turnero/api/send_consentimiento.php | 236 +++++ modules/turnero/api/sesion_turno.php | 103 +++ modules/turnero/api/sse_turno.php | 153 ++++ modules/turnero/views/configuracion.php | 857 ++++++++++++++++++ modules/turnero/views/dashboard.php | 374 ++++++++ modules/turnero/views/display.php | 425 +++++++++ modules/turnero/views/kiosko.php | 394 ++++++++ modules/turnero/views/lugar.php | 760 ++++++++++++++++ modules/turnero/views/recepcion.php | 706 +++++++++++++++ run_005_registro_exams.php | 53 ++ ver_formulario_enviado.php | 245 ++++- 37 files changed, 8838 insertions(+), 89 deletions(-) create mode 100644 migrations/005_registro_exams.sql create mode 100644 modules/registro_exams/api/_helpers.php create mode 100644 modules/registro_exams/api/cambiar_estado_item.php create mode 100644 modules/registro_exams/api/get_orden.php create mode 100644 modules/registro_exams/api/get_ordenes.php create mode 100644 modules/registro_exams/api/save_orden.php create mode 100644 modules/registro_exams/api/save_resultado.php create mode 100644 modules/registro_exams/module.php create mode 100644 modules/registro_exams/views/etiqueta.php create mode 100644 modules/registro_exams/views/lista.php create mode 100644 modules/registro_exams/views/nueva_orden.php create mode 100644 modules/registro_exams/views/orden.php create mode 100644 modules/turnero/api/_helpers.php create mode 100644 modules/turnero/api/cambiar_estado.php create mode 100644 modules/turnero/api/create_solicitud.php create mode 100644 modules/turnero/api/create_turno.php create mode 100644 modules/turnero/api/export_csv.php create mode 100644 modules/turnero/api/get_cola.php create mode 100644 modules/turnero/api/get_consentimientos.php create mode 100644 modules/turnero/api/get_dashboard.php create mode 100644 modules/turnero/api/llamar_turno.php create mode 100644 modules/turnero/api/save_ex_tipo.php create mode 100644 modules/turnero/api/save_lugar.php create mode 100644 modules/turnero/api/save_prioridad.php create mode 100644 modules/turnero/api/send_consentimiento.php create mode 100644 modules/turnero/api/sesion_turno.php create mode 100644 modules/turnero/api/sse_turno.php create mode 100644 modules/turnero/views/configuracion.php create mode 100644 modules/turnero/views/dashboard.php create mode 100644 modules/turnero/views/display.php create mode 100644 modules/turnero/views/kiosko.php create mode 100644 modules/turnero/views/lugar.php create mode 100644 modules/turnero/views/recepcion.php create mode 100644 run_005_registro_exams.php diff --git a/PLAN_ERP_MULTIMODULO.txt b/PLAN_ERP_MULTIMODULO.txt index 1f7ca46..e0c2f42 100644 --- a/PLAN_ERP_MULTIMODULO.txt +++ b/PLAN_ERP_MULTIMODULO.txt @@ -101,14 +101,21 @@ MÓDULOS PLANIFICADOS (oleadas): │ ├── turnero/ ← NUEVO ★ (Oleada 1) │ │ ├── module.php │ │ ├── views/ -│ │ │ ├── dashboard.php ← Pantalla administrador -│ │ │ ├── display.php ← Pantalla TV/recepción (sin login) -│ │ │ └── kiosko.php ← Pantalla táctil toma de turno +│ │ │ ├── dashboard.php ← Panel administrador (resumen del día) +│ │ │ ├── kiosko.php ← Pantalla táctil tomar turno (sin login) +│ │ │ ├── display.php ← Pantalla TV cola general (sin login) +│ │ │ ├── recepcion.php ← Puesto recepcionista (llamar, solicitud, lugar) +│ │ │ ├── lugar.php ← Puesto de servicio: llama turno, firma, atiende +│ │ │ └── configuracion.php ← Lugares, exámenes, prioridades, sesiones │ │ └── api/ -│ │ ├── create_turno.php -│ │ ├── llamar_turno.php -│ │ ├── get_estado.php -│ │ └── sse_turno.php ← Server-Sent Events para la pantalla TV +│ │ ├── create_turno.php ← Genera nuevo turno (kiosko) +│ │ ├── llamar_turno.php ← Llama siguiente de una cola +│ │ ├── create_solicitud.php ← Crea solicitud con exámenes, pago y lugar destino +│ │ ├── cambiar_estado.php ← Avanza estado del turno +│ │ ├── get_cola.php ← Cola de un lugar o recepción +│ │ ├── sse_turno.php ← Server-Sent Events para pantallas TV +│ │ ├── send_consentimiento.php ← Envía enlace de consentimiento por WhatsApp +│ │ └── get_consentimientos.php ← Estado de firmas del turno │ │ │ ├── registro_exams/ ← PENDIENTE — Oleada 2 │ │ ├── module.php @@ -215,54 +222,285 @@ HELPER PROPUESTO (core/Rbac.php): 5. MÓDULO TURNERO — DISEÑO DETALLADO ──────────────────────────────────────────────────────────────────────────────── -CONCEPTO: - Sistema de turnos para pacientes que llegan presencialmente a la sede. - Tres pantallas distintas: - A) Kiosko (tablet/touch en recepción) → el paciente toma su turno - B) TV/Display (pantalla grande) → muestra turno actual + cola - C) Dashboard admin → el operador llama turnos, gestiona +PROPÓSITO: + Diseñar, desarrollar e implementar un Sistema de Turnero Inteligente + integrado con gestión de pacientes, clasificación por prioridades, + flujo de atención por áreas (Recepción y Toma de Muestras) y envío + automatizado de consentimientos informados mediante WhatsApp. -TABLAS BD NUEVAS: + El sistema optimiza la atención, mejora la trazabilidad del paciente + y garantiza el cumplimiento legal mediante la gestión digital de + consentimientos. - turnero_servicios - id, nombre, prefijo (ej. "A"), color, tiempo_estimado_min, activo +──── 5.1 PANTALLAS / INTERFACES ──────────────────────────────────────────── + + A) Kiosko (tablet/touch, sin login) + → el paciente selecciona su tipo de prioridad y obtiene su turno + + B) Pantalla TV / Display (pantalla grande, sin login, una por lugar) + → muestra la cola del lugar y el turno siendo llamado en tiempo real + → param ?lugar_id=X para que cada sala tenga su propia pantalla + + C) Puesto Recepción (login requerido) + → llama turnos de la cola general + → vincula paciente, registra exámenes, recibe pago + → crea la Solicitud Interna y asigna el turno a un Lugar + + D) Puesto Lugar / Estación de Servicio (login requerido, genérico) + → llama turnos de su propia cola (los asignados a él por recepción) + → muestra exámenes requeridos y estado de consentimientos + • el paciente firma directamente en la pantalla del puesto (o en su celular) + → realiza el servicio y finaliza el turno + → La misma vista sirve para: Toma de Muestras 1, Toma de Muestras 2, + Rayos X, Ultrasonido, o cualquier área que se configure + + E) Panel Admin Turnero (login requerido) + → configura Lugares, exámenes, prioridades, sesiones + +──── 5.2 SISTEMA DE PRIORIDADES ──────────────────────────────────────────── + + Código Tipo de paciente Orden motor de cola + ────────────────────────────────────────────────────── + A Niños 1 (máxima prioridad) + B Embarazadas 2 + C Adulto mayor 3 + D Discapacidad 4 + E Paciente general 5 + F Muestras pendientes 6 (menor prioridad) + + Regla: dentro del mismo código se respeta el orden de llegada (FIFO). + El motor de cola mezcla todas las filas usando el peso de prioridad + timestamp. + +──── 5.3 FLUJO POR ÁREAS ──────────────────────────────────────────────────── + + ÁREA 1 — RECEPCIÓN + ═══════════════════ + Estados: espera → en_recepcion → finalizado_recepcion + + 1. Kiosko/recepcionista genera turno (código + prioridad) + 2. Pantalla TV muestra la cola + 3. Recepcionista llama siguiente turno → estado "en_recepcion" + → (opcional) Mensaje WhatsApp al paciente con su código de turno + 4. Se vincula el turno con un número de ventanilla + 5. Se registran: + • Datos personales del paciente (buscar/crear en lab_pacientes) + • Exámenes requeridos (lista de exam_tipos) + 6. Sistema consulta si los exámenes requieren consentimientos + 7. Si hay consentimientos pendientes: + → Se genera enlace único y seguro por paciente + → Se envía por WhatsApp usando WhatsAppService + plantilla aprobada Meta + Si no se requieren → pasa directamente a área de muestras + 8. Turno avanza → "esperando_consentimiento" o "en_espera_muestra" + + ÁREA 2 — TOMA DE MUESTRAS + ══════════════════════════ + Estados: en_espera_muestra → en_muestra → finalizado + + 1. Bacteriólogo/auxiliar llama siguiente de la cola de muestras + 2. Sistema BLOQUEA la atención si no existen consentimientos firmados + → muestra alerta visual; no permite avanzar el estado + 3. Si todos los consentimientos están firmados: + → Estado "en_muestra" + → Se validan los exámenes registrados en recepción + 4. Al terminar la toma: estado "finalizado" + +──── 5.4 CONSENTIMIENTOS INFORMADOS ───────────────────────────────────────── + + INTEGRACIÓN CON MÓDULO FORMULARIOS (REUTILIZAR lo que ya existe): + • Los consentimientos son Formularios del módulo lab_formularios/ + • Se marca en la configuración del formulario si es de tipo "consentimiento" + usando un campo nuevo: tipo ENUM(formulario, consentimiento) + • Enlace público ya existe en el módulo → se reutiliza tal cual + • Firma digital ya implementada → se reutiliza (canvas + checkbox legal) + + CATÁLOGO DE TIPOS DE EXAMEN (exam_tipos): + • Listado maestro de exámenes que ofrece el laboratorio (código + nombre) + • Se crea en FASE 3 (Oleada 1) para dar soporte al turnero + • La misma tabla se usa en Oleada 2 (registro_exams) sin cambios + • Administrado desde la vista Configuración > Exámenes y Consentimientos + + RELACIÓN EXAMEN ↔ CONSENTIMIENTO (exam_tipo_consentimientos): + • Un consentimiento (formulario) puede cubrir VARIOS tipos de examen + • Un examen puede estar en un solo consentimiento o en ninguno + • Se configura UNA VEZ en el panel de administración, no en cada turno + • Ejemplo: + "Toma de muestra de sangre" → consentimiento id=3 ("Consentimiento hemograma") + "Glucosa en ayunas" → consentimiento id=3 + "Cultivo de orina" → consentimiento id=5 ("Consentimiento urocultivo") + "Presión arterial" → (sin consentimiento) + + FLUJO DE CONSENTIMIENTOS: + 1. Recepción selecciona exámenes del turno (de la lista exam_tipos) + 2. Sistema busca en exam_tipo_consentimientos qué formularios aplican + para los exámenes seleccionados → deduplica si varios exámenes + comparten el mismo consentimiento → lista mínima de consentimientos + 3. Por cada consentimiento pendiente: + • Se genera un token único (UUID) → link = /ver_formulario.php?token=XXX + • Se registra en turnero_consentimientos con estado "pendiente" + 4. (Opcional) Recepción envía enlace por WhatsApp para pre-firma + mientras el paciente espera en cola del Lugar + 5. En el Lugar (segunda llamada), el paciente puede firmar: + OPCIÓN A: Hace clic en el enlace de WhatsApp en su celular + OPCIÓN B: El auxiliar muestra la pantalla del puesto al paciente + y firma directamente ahí (mismo ver_formulario_enviado.php) + 6. Al firmar → estado "firmado" en turnero_consentimientos + 7. Vista del Lugar muestra ✓ verde / ✗ rojo en tiempo real (polling 5s) + BLOQUEO total si alguno pendiente → no se puede iniciar el servicio + + MOMENTO DE LA FIRMA: + • Preferiblemente antes de la segunda llamada (via WhatsApp, mientras espera) + • Alternativamente EN la estación del Lugar (tablet del puesto) + • NUNCA después de iniciado el servicio + + EVIDENCIA REGISTRADA POR FIRMA: + • Fecha y hora exacta + • IP del firmante + • User-Agent (dispositivo) + • Versión del formulario/consentimiento al momento de firmar + +──── 5.5 TABLAS BD NUEVAS ──────────────────────────────────────────────────── + + exam_tipos ← catálogo maestro de exámenes del laboratorio + id, codigo VARCHAR(20), ← ej. "HEM", "GLU", "URIN" + nombre VARCHAR(150), ← ej. "Hemograma completo" + categoria VARCHAR(80), ← ej. "Hematología", "Química" + activo TINYINT(1) + NOTA: Esta tabla es compartida con Oleada 2 (registro_exams). + En Oleada 2 se le agregan columnas precio_base, requiere_ayunas, etc. + + exam_tipo_consentimientos ← relación M:N examen ↔ consentimiento + id, + exam_tipo_id INT FK exam_tipos, + formulario_id INT FK lab_formularios, ← debe tener tipo='consentimiento' + UNIQUE KEY (exam_tipo_id, formulario_id) + NOTA: Si un examen no tiene registro aquí, no se exige consentimiento. + Si varios exámenes del mismo turno apuntan al mismo formulario_id, + se genera UN SOLO consentimiento (deduplicado en la lógica PHP). + + turnero_lugares ← sub-módulo: estaciones/lugares de servicio + id, nombre VARCHAR(100), ← ej. "Toma de Muestras 1", "Rayos X" + descripcion TEXT, + activo TINYINT(1), + sort_order INT ← orden en la pantalla TV general + NOTA: Recepción NO es un lugar; es el primer paso implícito del flujo. + Cada lugar tiene su propia cola y su propia pantalla TV. + + turnero_prioridades ← catálogo editable de códigos A-F + id, codigo CHAR(1) UNIQUE, nombre VARCHAR(100), + orden_peso INT, ← número menor = mayor prioridad + color VARCHAR(7), + activo TINYINT(1) turnero_sesiones - id, fecha, abierto_por INT FK admin_users, cerrado_por, inicio_at, fin_at - (una sesión = un día de atención) + id, fecha DATE, + abierto_por INT FK admin_users, cerrado_por INT FK admin_users, + inicio_at DATETIME, fin_at DATETIME turnero_turnos - id, sesion_id, - numero INT, ← número correlativo de la sesión - codigo VARCHAR(10), ← "A001", "B012" - servicio_id, - paciente_nombre VARCHAR(150), + id, sesion_id FK turnero_sesiones, + numero INT, ← correlativo dentro de la sesión + codigo VARCHAR(10), ← "A001", "B012" + prioridad_id INT FK turnero_prioridades, + paciente_nombre VARCHAR(150), ← capturado en kiosko (opcional) paciente_cel VARCHAR(20), - estado ENUM(espera, llamado, en_atencion, atendido, ausente, cancelado), - modulo_atencion INT, ← puesto/ventanilla que atiende - llamado_at, inicio_at, fin_at, creado_at + estado ENUM( + espera, ← código asignado, en cola general + en_recepcion, ← recepcionista lo está procesando + en_espera_lugar, ← solicitud creada, en cola del Lugar + en_servicio, ← siendo atendido en el Lugar + finalizado, ← proceso completo + ausente, ← no se presentó + cancelado + ), + lugar_destino_id INT FK turnero_lugares NULL, ← asignado por recepción + creado_at DATETIME, + llamado_recepcion_at DATETIME, + inicio_recepcion_at DATETIME, + fin_recepcion_at DATETIME, + llamado_lugar_at DATETIME, + inicio_lugar_at DATETIME, + fin_lugar_at DATETIME, + atendido_recepcion_por INT FK admin_users NULL, + atendido_lugar_por INT FK admin_users NULL - turnero_modulos - id, nombre (ej. "Ventanilla 1"), activo, usuario_actual INT FK admin_users + turnero_solicitudes ← solicitud interna de agendamiento (crea recepción) + id, + turno_id INT FK turnero_turnos UNIQUE, ← 1 solicitud por turno + paciente_id INT FK lab_pacientes, ← vinculado en recepción + lugar_id INT FK turnero_lugares, ← lugar de destino + total_cobrado DECIMAL(10,2), + metodo_pago ENUM(efectivo, transferencia, tarjeta, eps, cortesia), + observaciones TEXT, + creado_por INT FK admin_users, + creado_at DATETIME -FLUJO: - 1. Recepcionista / kiosko crea turno → estado "espera" - 2. Operador en dashboard hace clic "Llamar siguiente" - → estado "llamado", pantalla TV muestra "A-001 → Ventanilla 2" - → (opcional) SMS/WhatsApp al paciente con su turno - 3. El paciente llega → operador pasa a "en_atención" - 4. Al terminar → "atendido" - 5. Si no aparece → "ausente" (puede regresar al final de la cola) + turnero_examen_items ← exámenes de la solicitud + id, solicitud_id FK turnero_solicitudes, + exam_tipo_id INT FK exam_tipos, + creado_at DATETIME -PANTALLA TV (display.php): - • Sin login - • Se actualiza por SSE (Server-Sent Events) cada vez que se llama un turno - • Muestra: turno actual por cada ventanilla + próximos 5 en espera - • Diseño visual grande, colores por servicio + turnero_consentimientos ← consentimientos del turno (deduplicados) + id, turno_id FK turnero_turnos, + formulario_id INT FK lab_formularios, + token VARCHAR(64) UNIQUE, ← enlace único de firma + estado ENUM(pendiente, enviado, visto, firmado, rechazado), + enviado_at DATETIME, + firmado_at DATETIME, + ip_firma VARCHAR(45), + ua_firma VARCHAR(500), + version_formulario INT ← snapshot de la versión al momento de firma -INTEGRACIÓN CON WHATSAPP (OPCIONAL): - Cuando se llama el turno → enviar mensaje al paciente si dejó celular - Usar WhatsAppService ya existente. + formularios.tipo ← columna nueva en tabla existente lab_formularios + ALTER TABLE lab_formularios ADD COLUMN tipo + ENUM('formulario','consentimiento') DEFAULT 'formulario'; + + DIAGRAMA DE RELACIONES CLAVE: + + lab_formularios (tipo='consentimiento') + │ 1 + │ + exam_tipo_consentimientos ─────── exam_tipos + (M:N) │ 1 + │ N + turnero_examen_items + │ N + │ 1 + turnero_solicitudes ───── turnero_lugares + │ 1 + │ 1 + turnero_turnos + │ 1 + │ N + turnero_consentimientos + (un registro por formulario distinto requerido por el turno) + +──── 5.6 INTEGRACIONES CON MÓDULOS EXISTENTES ────────────────────────────── + + • lab_pacientes → buscar/crear paciente al crear la solicitud en recepción + • lab_formularios → los consentimientos SON formularios del sistema + • ver_formulario_enviado.php → página pública de firma (ya funcional) + se abre también desde la pantalla del Lugar + • WhatsAppService → envío del enlace de consentimiento (opcional, entre llamadas) + • Plantillas Meta → crear plantilla aprobada "consentimiento_turno" + • exam_tipos → catálogo compartido con Oleada 2 (registro_exams) + +──── 5.7 PANTALLA TV / DISPLAY ───────────────────────────────────────────── + + Hay VARIOS tipos de pantalla TV, todas sin login: + + TV RECEPCIÓN (?display=recepcion) + • Cola general (turnos en estado 'espera' ordenados por prioridad) + • Turno actualmente en recepción (código grande + "DIRÍJASE A RECEPCIÓN") + + TV POR LUGAR (?display=lugar&lugar_id=X) + • Cola del Lugar X (turnos en 'en_espera_lugar' con ese lugar_destino_id) + • Turno en servicio activo del Lugar X (código grande) + • Cadaestación tiene su propia TV configurada con su lugar_id + + COMPORTAMIENTO COMÚN: + • Actualización por SSE en tiempo real sin reload + • Colores grandes por código de prioridad + • Animación + sonido corto al llamar un turno ──────────────────────────────────────────────────────────────────────────────── @@ -350,10 +588,16 @@ Migración 002 – Nuevos roles ('Operador Bot', 'operador_bot', 'Gestión de conversaciones', '#6f42c1', 0); Migración 003 – Turnero [Oleada 1] - CREATE TABLE turnero_servicios (...) - CREATE TABLE turnero_sesiones (...) - CREATE TABLE turnero_turnos (...) - CREATE TABLE turnero_modulos (...) + CREATE TABLE exam_tipos (...) ← catálogo maestro compartido con Oleada 2 + CREATE TABLE exam_tipo_consentimientos(...) ← M:N: examen ↔ formulario consentimiento + CREATE TABLE turnero_lugares (...) ← sub-módulo: estaciones configurables + CREATE TABLE turnero_prioridades (...) ← catálogo A-F configurable + CREATE TABLE turnero_sesiones (...) ← sesión diaria de atención + CREATE TABLE turnero_turnos (...) ← turno con lugar_destino_id + CREATE TABLE turnero_solicitudes (...) ← solicitud interna de agendamiento + CREATE TABLE turnero_examen_items (...) ← exámenes de la solicitud + CREATE TABLE turnero_consentimientos (...) ← firma digital por consentimiento + ALTER TABLE lab_formularios ADD COLUMN tipo ENUM('formulario','consentimiento') DEFAULT 'formulario' Migración 004 – SYSTEM_MODULES dinámica (mover de config.php a BD) [Oleada 1] CREATE TABLE system_modules ( @@ -372,7 +616,10 @@ Migración 004 – SYSTEM_MODULES dinámica (mover de config.php a BD) [Oleada config.php mantiene el array como fallback hasta que la BD esté lista. Migración 005 – Registro de exámenes [Oleada 2] - CREATE TABLE exam_tipos (...) + -- exam_tipos ya existe desde Migración 003; solo agregar columnas: + ALTER TABLE exam_tipos ADD COLUMN precio_base DECIMAL(10,2) NULL; + ALTER TABLE exam_tipos ADD COLUMN requiere_ayunas TINYINT(1) DEFAULT 0; + ALTER TABLE exam_tipos ADD COLUMN instrucciones TEXT NULL; CREATE TABLE exam_ordenes (...) CREATE TABLE exam_items (...) CREATE TABLE exam_etiquetas(...) @@ -431,7 +678,7 @@ GRUPOS DEL MENÚ LATERAL: 🧪 Clínico (Oleada 2) Registro Exámenes | Resultados 🎟️ Turnero (Oleada 1) - Dashboard | Configurar Servicios + Dashboard | Recepción | Toma de Muestras | Kiosko | Configuración 📊 Reportes Ingresos | Rendimiento | Exportar ⚙️ Sistema @@ -467,21 +714,242 @@ FASE 2 – SYSTEM_MODULES dinámica (2 días) ✅ COMPLETADA ── MÓDULOS NUEVOS ──────────────────────────────────────────────────────────── -FASE 3 – Turnero MVP — Oleada 1 (3-5 días) - □ Migración 003: tablas turnero_* - □ modules/turnero/module.php + views/ + api/ - □ Pantalla kiosko (toma turno, sin login) - □ Pantalla display TV (SSE, sin login) - □ Dashboard admin (llamar, gestionar) - □ Registrar slug turnero en system_modules +FASE 3 – Turnero MVP — Oleada 1 + + FASE 3.1 – Base de datos y configuración (1 día) ✅ COMPLETADA + ────────────────────────────────────────────────── + ✓ Migración 003: crear tablas + CREATE exam_tipos ← catálogo maestro (compartido con Oleada 2) + CREATE exam_tipo_consentimientos ← M:N: examen↔formulario + CREATE turnero_lugares ← estaciones de servicio configurables + CREATE turnero_prioridades (insertar datos A-F por defecto) + CREATE turnero_sesiones + CREATE turnero_turnos ← con lugar_destino_id + CREATE turnero_solicitudes ← solicitud interna de agendamiento + CREATE turnero_examen_items ← FK solicitud_id + CREATE turnero_consentimientos + ALTER lab_formularios ADD tipo ENUM(formulario, consentimiento) + ✓ modules/turnero/module.php (descriptor) + ✓ Registrar slug 'turnero' en system_modules + ✓ Asignar módulo turnero a roles recepcionista y bacteriologo + + ✅ FASE 3.2 – Motor de cola y API core (1 día) [COMPLETADA] + ────────────────────────────────────────────── + ✓ api/create_turno.php + • Crea/abre sesión del día si no existe + • Genera número correlativo + código (ej. "A001") con FOR UPDATE + • Admite paciente_nombre + paciente_cel (opcional) + • Retorna código, número y posición en cola + ✓ api/llamar_turno.php + • Recibe area (recepcion|lugar) + lugar_id + • Motor de prioridades: SELECT menor orden_peso → menor creado_at + • Área lugar: solo turnos en estado en_espera_lugar para ese lugar + • Retorna turno llamado o null si cola vacía + ✓ api/cambiar_estado.php + • Avanza estado con mapa de transiciones explícito + • Bloquea en_espera_lugar→en_servicio si hay consentimientos pendientes (422) + • Registra timestamps llamado_at / inicio_at / fin_at por área + ✓ api/get_cola.php + • Cola actual por área (recepcion | lugar + lugar_id) + • Turno activo + listado + estadísticas de sesión + ✓ api/sse_turno.php + • SSE: emite 'cola_update' cuando cambia sse_ping_at en la sesión + • ALTER TABLE IF NOT EXISTS para sse_ping_at (idempotente) + • Keep-alive ": ping" cada 15 s, forzar refresh cada 30 s + • Compatible con pantalla TV sin necesidad de polling activo + + ✅ FASE 3.3 – Pantallas sin login (1 día) [COMPLETADA] + ──────────────────────────────────────── + ✓ views/kiosko.php + • Pantalla táctil fullscreen 3 pasos: prioridad → datos → ticket + • Botones A-F cargados de BD (icono + nombre + descripción + color) + • Campo opcional: nombre y celular con validación de formato + • Muestra código + posición en cola; auto-reinicio tras 30 s + • Sin login; fetch POST a create_turno.php + ✓ views/display.php + • Pantalla TV fullscreen, sin login (HTML puro, sin false, 'error' => 'Error interno: ' . $err['message']]); + } +}); + +header('Content-Type: application/json; charset=utf-8'); +header('Access-Control-Allow-Origin: *'); +header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); +header('Access-Control-Allow-Headers: Content-Type'); + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + +// ── Respuestas JSON ────────────────────────────────────────── + +function jsonOk(array $payload = [], string $mensaje = ''): void +{ + ob_clean(); + $resp = ['ok' => true, 'success' => true]; + if ($mensaje) { + $resp['message'] = $mensaje; + } + echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE); + exit; +} + +function jsonError(string $mensaje, int $code = 400): void +{ + ob_clean(); + http_response_code($code); + echo json_encode(['ok' => false, 'success' => false, 'error' => $mensaje], JSON_UNESCAPED_UNICODE); + exit; +} + +function requireMethod(string $method): void +{ + if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) { + jsonError('Método no permitido', 405); + } +} + +function inputJson(): array +{ + static $parsed = null; + if ($parsed === null) { + $raw = file_get_contents('php://input'); + $parsed = json_decode($raw, true) ?? []; + } + return $parsed; +} + +// ── Autenticación ──────────────────────────────────────────── + +function requireAuth(): void +{ + if (empty($_SESSION['admin_user']['id'])) { + jsonError('No autenticado', 401); + } +} + +/** + * Exige sesión + acceso al módulo registro_exams. + */ +function requireExams(): void +{ + requireAuth(); + $modules = $_SESSION['admin_user']['modules'] ?? []; + $roleId = $_SESSION['admin_user']['role_id'] ?? null; + + // Superadmin / admin global = acceso total + if (empty($roleId)) { + return; + } + + if (!in_array('registro_exams', (array) $modules, true)) { + jsonError('Sin acceso al módulo Registro Exámenes', 403); + } +} + +function adminId(): ?int +{ + return isset($_SESSION['admin_user']['id']) + ? (int) $_SESSION['admin_user']['id'] + : null; +} + +// ── Helper BD ──────────────────────────────────────────────── + +function db(): PDO +{ + return Database::getInstance()->getConnection(); +} + +// ── Código de orden ────────────────────────────────────────── + +/** + * Genera el siguiente código de orden legible: EX-YYYYMMDD-NNNN + * Usa SELECT FOR UPDATE para evitar duplicados en concurrencia. + */ +function generarCodigoOrden(): string +{ + $pdo = db(); + $hoy = date('Ymd'); + $prefijo = "EX-{$hoy}-"; + + $stmt = $pdo->prepare( + "SELECT COALESCE(MAX(CAST(SUBSTRING(codigo, -4) AS UNSIGNED)), 0) + 1 AS siguiente + FROM exam_ordenes + WHERE codigo LIKE ? + FOR UPDATE" + ); + $stmt->execute(["{$prefijo}%"]); + $n = (int) $stmt->fetchColumn(); + + return $prefijo . str_pad($n, 4, '0', STR_PAD_LEFT); +} + +// ── Etiqueta de prioridad ──────────────────────────────────── + +function badgePrioridad(string $p): string +{ + return match ($p) { + 'urgente' => 'Urgente', + 'stat' => 'STAT', + default => 'Normal', + }; +} + +function badgeEstadoOrden(string $e): string +{ + return match ($e) { + 'en_proceso' => 'En proceso', + 'completa' => 'Completa', + 'entregada' => 'Entregada', + 'cancelada' => 'Cancelada', + default => 'Pendiente', + }; +} diff --git a/modules/registro_exams/api/cambiar_estado_item.php b/modules/registro_exams/api/cambiar_estado_item.php new file mode 100644 index 0000000..58f0363 --- /dev/null +++ b/modules/registro_exams/api/cambiar_estado_item.php @@ -0,0 +1,143 @@ +prepare('SELECT orden_id FROM exam_items WHERE id = ?'); + $stmtG->execute([$id]); + $item = $stmtG->fetch(PDO::FETCH_ASSOC); + if (!$item) { + jsonError('Ítem no encontrado', 404); + } + + $pdo->prepare( + 'UPDATE exam_items SET estado = ?, notas_tecnico = ?, creado_at = creado_at WHERE id = ?' + )->execute([$estado, $notas, $id]); + + // Si todos los ítems de la orden están listos → marcar orden como completa + if ($estado === 'resultado_listo' || $estado === 'entregado') { + $stmtC = $pdo->prepare( + "SELECT COUNT(*) FROM exam_items + WHERE orden_id = ? + AND estado NOT IN ('resultado_listo','entregado')" + ); + $stmtC->execute([$item['orden_id']]); + if ((int) $stmtC->fetchColumn() === 0) { + $pdo->prepare( + "UPDATE exam_ordenes SET estado = 'completa', updated_at = NOW() WHERE id = ?" + )->execute([$item['orden_id']]); + } + } + + jsonOk([], 'Estado actualizado'); + } + + // ── Crear o actualizar una muestra ────────────────────────── + case 'muestra': { + $id = isset($body['id']) && $body['id'] !== null ? (int) $body['id'] : null; + $estado = $body['estado'] ?? 'tomada'; + $ordenId = isset($body['orden_id']) ? (int) $body['orden_id'] : 0; + $tipoMuest = $body['tipo_muestra'] ?? 'sangre_venosa'; + $barcode = trim($body['codigo_barras'] ?? ''); + + if (!in_array($estado, $estadosMuestra, true)) { + jsonError('Estado de muestra inválido'); + } + + if ($id === null) { + // Crear nueva muestra + if ($ordenId <= 0) { + jsonError('orden_id requerido para crear muestra'); + } + $pdo->prepare(' + INSERT INTO exam_muestras + (orden_id, tipo_muestra, codigo_barras, tomada_por, tomada_at, estado) + VALUES (?, ?, ?, ?, NOW(), ?) + ')->execute([$ordenId, $tipoMuest, $barcode ?: null, adminId(), $estado]); + + $newId = (int) $pdo->lastInsertId(); + + // Marcar ítems como muestra_tomada si están pendientes + $pdo->prepare( + "UPDATE exam_items SET estado = 'muestra_tomada' + WHERE orden_id = ? AND estado = 'pendiente'" + )->execute([$ordenId]); + + // Actualizar orden a en_proceso si aún está pendiente + $pdo->prepare( + "UPDATE exam_ordenes SET estado = 'en_proceso', updated_at = NOW() + WHERE id = ? AND estado = 'pendiente'" + )->execute([$ordenId]); + + jsonOk(['muestra_id' => $newId], 'Muestra registrada'); + } else { + // Actualizar muestra existente + $pdo->prepare( + 'UPDATE exam_muestras SET estado = ?, codigo_barras = ? WHERE id = ?' + )->execute([$estado, $barcode ?: null, $id]); + + jsonOk([], 'Muestra actualizada'); + } + } + + // ── Cambiar estado de la orden completa ───────────────────── + case 'orden': { + $id = isset($body['id']) ? (int) $body['id'] : 0; + $estado = $body['estado'] ?? ''; + if ($id <= 0 || !in_array($estado, $estadosOrden, true)) { + jsonError('Parámetros inválidos para orden'); + } + $pdo->prepare( + 'UPDATE exam_ordenes SET estado = ?, updated_at = NOW() WHERE id = ?' + )->execute([$estado, $id]); + + jsonOk([], 'Estado de orden actualizado'); + } + + default: + jsonError('Tipo de operación desconocido: ' . $tipo); +} diff --git a/modules/registro_exams/api/get_orden.php b/modules/registro_exams/api/get_orden.php new file mode 100644 index 0000000..f6c8ece --- /dev/null +++ b/modules/registro_exams/api/get_orden.php @@ -0,0 +1,111 @@ + + * Devuelve la orden completa: datos, paciente, ítems, muestras, resultados. + */ + +require_once __DIR__ . '/_helpers.php'; +requireMethod('GET'); +requireExams(); + +$id = isset($_GET['id']) ? (int) $_GET['id'] : 0; +if ($id <= 0) { + jsonError('ID de orden inválido'); +} + +$pdo = db(); + +// Orden + paciente +$stmt = $pdo->prepare(' + SELECT + o.id, o.codigo, o.prioridad, o.estado, + o.requiere_ayuno, o.horas_ayuno, + o.medico_nombre, o.medico_registro, + o.diagnostico, o.notas, + o.solicitud_id, o.orden_medica_id, + o.creado_por, o.creado_at, o.updated_at, + p.id AS paciente_id, + p.nombre_completo, + p.numero_documento, + p.tipo_documento, + p.telefono, + p.email, + p.fecha_nacimiento, + p.genero, + p.eps + FROM exam_ordenes o + JOIN lab_pacientes p ON p.id = o.paciente_id + WHERE o.id = ? +'); +$stmt->execute([$id]); +$orden = $stmt->fetch(PDO::FETCH_ASSOC); + +if (!$orden) { + jsonError('Orden no encontrada', 404); +} + +// Calcular edad +if ($orden['fecha_nacimiento']) { + $diff = (new DateTime($orden['fecha_nacimiento']))->diff(new DateTime()); + $orden['edad'] = $diff->y; +} else { + $orden['edad'] = null; +} + +// Ítems +$stmt = $pdo->prepare(' + SELECT + ei.id, ei.orden_id, ei.exam_tipo_id, ei.estado, ei.notas_tecnico, ei.creado_at, + et.codigo AS tipo_codigo, et.nombre AS tipo_nombre, + et.categoria, et.requiere_ayuno AS tipo_requiere_ayuno, + et.instrucciones AS tipo_instrucciones + FROM exam_items ei + JOIN exam_tipos et ON et.id = ei.exam_tipo_id + WHERE ei.orden_id = ? + ORDER BY et.categoria, et.nombre +'); +$stmt->execute([$id]); +$items = $stmt->fetchAll(PDO::FETCH_ASSOC); + +// Resultados por ítem +$resultadosPorItem = []; +if ($items) { + $itemIds = array_column($items, 'id'); + $inPh = implode(',', array_fill(0, count($itemIds), '?')); + $stmt = $pdo->prepare(" + SELECT er.*, au.nombre AS ingresado_nombre + FROM exam_resultados er + LEFT JOIN admin_users au ON au.id = er.ingresado_por + WHERE er.item_id IN ({$inPh}) + ORDER BY er.item_id, er.id + "); + $stmt->execute($itemIds); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $res) { + $resultadosPorItem[(int) $res['item_id']][] = $res; + } +} + +// Agregar resultados a cada ítem +foreach ($items as &$item) { + $item['resultados'] = $resultadosPorItem[(int) $item['id']] ?? []; +} +unset($item); + +// Muestras +$stmt = $pdo->prepare(' + SELECT em.*, au.nombre AS tomada_nombre + FROM exam_muestras em + LEFT JOIN admin_users au ON au.id = em.tomada_por + WHERE em.orden_id = ? + ORDER BY em.id +'); +$stmt->execute([$id]); +$muestras = $stmt->fetchAll(PDO::FETCH_ASSOC); + +jsonOk([ + 'data' => array_merge($orden, [ + 'items' => $items, + 'muestras' => $muestras, + ]), +]); diff --git a/modules/registro_exams/api/get_ordenes.php b/modules/registro_exams/api/get_ordenes.php new file mode 100644 index 0000000..44a033d --- /dev/null +++ b/modules/registro_exams/api/get_ordenes.php @@ -0,0 +1,102 @@ +prepare( + "SELECT COUNT(*) FROM exam_ordenes o + JOIN lab_pacientes p ON p.id = o.paciente_id + {$whereSQL}" +); +$countStmt->execute($params); +$total = (int) $countStmt->fetchColumn(); + +// Datos +$sql = " + SELECT + o.id, o.codigo, o.prioridad, o.estado, o.requiere_ayuno, o.horas_ayuno, + o.medico_nombre, o.diagnostico, o.notas, + o.creado_at, o.updated_at, + p.id AS paciente_id, + p.nombre_completo, + p.numero_documento, + p.tipo_documento, + p.telefono, + p.fecha_nacimiento, + (SELECT COUNT(*) FROM exam_items ei WHERE ei.orden_id = o.id) AS total_items, + (SELECT COUNT(*) FROM exam_items ei WHERE ei.orden_id = o.id AND ei.estado = 'resultado_listo') AS items_listos, + (SELECT COUNT(*) FROM exam_muestras em WHERE em.orden_id = o.id AND em.estado = 'tomada') AS muestras_tomadas + FROM exam_ordenes o + JOIN lab_pacientes p ON p.id = o.paciente_id + {$whereSQL} + ORDER BY + FIELD(o.prioridad, 'stat', 'urgente', 'normal'), + o.creado_at ASC + LIMIT {$limit} OFFSET {$offset} +"; + +$stmt = $pdo->prepare($sql); +$stmt->execute($params); +$ordenes = $stmt->fetchAll(PDO::FETCH_ASSOC); + +// Calcular edad en años para cada paciente +foreach ($ordenes as &$row) { + if ($row['fecha_nacimiento']) { + $diff = (new DateTime($row['fecha_nacimiento']))->diff(new DateTime()); + $row['edad'] = $diff->y; + } else { + $row['edad'] = null; + } +} +unset($row); + +jsonOk([ + 'data' => $ordenes, + 'total' => $total, + 'page' => $page, + 'limit' => $limit, + 'pages' => (int) ceil($total / $limit), + 'fecha' => $fecha, +]); diff --git a/modules/registro_exams/api/save_orden.php b/modules/registro_exams/api/save_orden.php new file mode 100644 index 0000000..f472e12 --- /dev/null +++ b/modules/registro_exams/api/save_orden.php @@ -0,0 +1,148 @@ +, codigo: "EX-YYYYMMDD-NNNN" } + */ + +require_once __DIR__ . '/_helpers.php'; +requireMethod('POST'); +requireExams(); + +$body = inputJson(); + +// ── Validaciones ────────────────────────────────────────────── +$pacienteId = isset($body['paciente_id']) ? (int) $body['paciente_id'] : 0; +if ($pacienteId <= 0) { + jsonError('Debe indicar el paciente (paciente_id)'); +} + +$items = isset($body['items']) && is_array($body['items']) + ? array_map('intval', $body['items']) + : []; + +// Filtrar zeros +$items = array_values(array_filter($items, fn($v) => $v > 0)); +if (count($items) === 0) { + jsonError('Debe agregar al menos un examen'); +} + +// Normalizar prioridad +$prioridadesPermitidas = ['normal', 'urgente', 'stat']; +$prioridad = in_array($body['prioridad'] ?? '', $prioridadesPermitidas, true) + ? $body['prioridad'] : 'normal'; + +$ordenId = isset($body['id']) && $body['id'] ? (int) $body['id'] : null; +$solicitudId = isset($body['solicitud_id']) ? ((int) $body['solicitud_id'] ?: null) : null; +$ordenMedId = isset($body['orden_medica_id']) ? ((int) $body['orden_medica_id'] ?: null) : null; +$medicoNombre = trim($body['medico_nombre'] ?? ''); +$medicoReg = trim($body['medico_registro'] ?? ''); +$diagnostico = trim($body['diagnostico'] ?? ''); +$notas = trim($body['notas'] ?? ''); + +$pdo = db(); + +try { + $pdo->beginTransaction(); + + if ($ordenId === null) { + // ── CREAR nueva orden ────────────────────────────────── + $codigo = generarCodigoOrden(); + + $stmt = $pdo->prepare(' + INSERT INTO exam_ordenes + (paciente_id, solicitud_id, orden_medica_id, codigo, + medico_nombre, medico_registro, diagnostico, + prioridad, notas, creado_por, creado_at) + VALUES + (?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, NOW()) + '); + $stmt->execute([ + $pacienteId, $solicitudId, $ordenMedId, $codigo, + $medicoNombre ?: null, $medicoReg ?: null, $diagnostico ?: null, + $prioridad, $notas ?: null, adminId(), + ]); + $ordenId = (int) $pdo->lastInsertId(); + + } else { + // ── ACTUALIZAR orden existente ───────────────────────── + $stmt = $pdo->prepare(' + UPDATE exam_ordenes SET + medico_nombre = ?, + medico_registro = ?, + diagnostico = ?, + prioridad = ?, + notas = ?, + updated_at = NOW() + WHERE id = ? + '); + $stmt->execute([ + $medicoNombre ?: null, $medicoReg ?: null, $diagnostico ?: null, + $prioridad, $notas ?: null, $ordenId, + ]); + + // Leer código actual para devolverlo + $stmt2 = $pdo->prepare('SELECT codigo FROM exam_ordenes WHERE id = ?'); + $stmt2->execute([$ordenId]); + $codigo = $stmt2->fetchColumn() ?: ''; + } + + // ── Sincronizar ítems ────────────────────────────────────── + // Obtener tipos ya existentes en la orden + $stmtEx = $pdo->prepare( + 'SELECT exam_tipo_id FROM exam_items WHERE orden_id = ?' + ); + $stmtEx->execute([$ordenId]); + $existentes = array_column($stmtEx->fetchAll(PDO::FETCH_ASSOC), 'exam_tipo_id'); + $existentes = array_map('intval', $existentes); + + // Insertar los que no existen aún + $nuevos = array_diff($items, $existentes); + if ($nuevos) { + $stmtIns = $pdo->prepare( + 'INSERT INTO exam_items (orden_id, exam_tipo_id) VALUES (?, ?)' + ); + foreach ($nuevos as $tipoId) { + $stmtIns->execute([$ordenId, $tipoId]); + } + } + + // Eliminar los que ya no están en la lista (solo si aún están pendientes) + $eliminar = array_diff($existentes, $items); + if ($eliminar) { + $inPlaceholders = implode(',', array_fill(0, count($eliminar), '?')); + $stmtDel = $pdo->prepare( + "DELETE FROM exam_items + WHERE orden_id = ? + AND exam_tipo_id IN ({$inPlaceholders}) + AND estado = 'pendiente'" + ); + $stmtDel->execute(array_merge([$ordenId], array_values($eliminar))); + } + + $pdo->commit(); + + jsonOk(['id' => $ordenId, 'codigo' => $codigo], 'Orden guardada correctamente'); + +} catch (\Throwable $e) { + $pdo->rollBack(); + jsonError('Error al guardar la orden: ' . $e->getMessage(), 500); +} diff --git a/modules/registro_exams/api/save_resultado.php b/modules/registro_exams/api/save_resultado.php new file mode 100644 index 0000000..b845a31 --- /dev/null +++ b/modules/registro_exams/api/save_resultado.php @@ -0,0 +1,121 @@ +prepare('SELECT id, orden_id FROM exam_items WHERE id = ?'); +$stmt->execute([$itemId]); +$item = $stmt->fetch(PDO::FETCH_ASSOC); +if (!$item) { + jsonError('Ítem no encontrado', 404); +} + +try { + $pdo->beginTransaction(); + + // Borrar resultados anteriores del ítem (reemplazo completo) + $pdo->prepare('DELETE FROM exam_resultados WHERE item_id = ?')->execute([$itemId]); + + $ins = $pdo->prepare(' + INSERT INTO exam_resultados + (item_id, campo_nombre, valor_texto, valor_numerico, + unidad, referencia_min, referencia_max, referencia_texto, + es_anormal, ingresado_por) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + + $inserted = 0; + foreach ($resultados as $r) { + $campo = trim($r['campo_nombre'] ?? ''); + if ($campo === '') { + continue; + } + $ins->execute([ + $itemId, + $campo, + isset($r['valor_texto']) && $r['valor_texto'] !== '' ? $r['valor_texto'] : null, + isset($r['valor_numerico']) && $r['valor_numerico'] !== null ? (float) $r['valor_numerico'] : null, + isset($r['unidad']) && $r['unidad'] !== '' ? $r['unidad'] : null, + isset($r['referencia_min']) && $r['referencia_min'] !== null ? (float) $r['referencia_min'] : null, + isset($r['referencia_max']) && $r['referencia_max'] !== null ? (float) $r['referencia_max'] : null, + isset($r['referencia_texto']) && $r['referencia_texto'] !== '' ? $r['referencia_texto'] : null, + empty($r['es_anormal']) ? 0 : 1, + adminId(), + ]); + $inserted++; + } + + if ($inserted === 0) { + $pdo->rollBack(); + jsonError('No se guardó ningún resultado (verifique los campos)'); + } + + // Actualizar estado del ítem a resultado_listo + $pdo->prepare( + "UPDATE exam_items SET estado = 'resultado_listo' WHERE id = ?" + )->execute([$itemId]); + + // Verificar si todos los ítems de la orden están listos para cerrarla + $stmtCheck = $pdo->prepare( + "SELECT COUNT(*) FROM exam_items + WHERE orden_id = ? + AND estado NOT IN ('resultado_listo','entregado')" + ); + $stmtCheck->execute([$item['orden_id']]); + $pendientes = (int) $stmtCheck->fetchColumn(); + + if ($pendientes === 0) { + $pdo->prepare( + "UPDATE exam_ordenes SET estado = 'completa', updated_at = NOW() WHERE id = ?" + )->execute([$item['orden_id']]); + } + + $pdo->commit(); + + jsonOk(['inserted' => $inserted], 'Resultados guardados'); + +} catch (\Throwable $e) { + $pdo->rollBack(); + jsonError('Error al guardar resultados: ' . $e->getMessage(), 500); +} diff --git a/modules/registro_exams/module.php b/modules/registro_exams/module.php new file mode 100644 index 0000000..148c122 --- /dev/null +++ b/modules/registro_exams/module.php @@ -0,0 +1,21 @@ + 'registro_exams', + 'name' => 'Registro Exámenes', + 'icon' => 'fas fa-vials', + 'category' => 'clinico', + 'route' => '/erp.php?m=registro_exams&v=lista', + 'is_active' => true, + 'sort_order' => 60, + 'oleada' => 2, + 'description' => 'Registro de exámenes de laboratorio en sede. ' + . 'Creación de órdenes, toma de muestras e ingreso de resultados.', + 'links' => [ + ['label' => 'Órdenes del día', 'icon' => 'fas fa-list-check', 'url' => '/erp.php?m=registro_exams&v=lista'], + ['label' => 'Nueva orden', 'icon' => 'fas fa-plus-circle', 'url' => '/erp.php?m=registro_exams&v=nueva_orden'], + ], +]; diff --git a/modules/registro_exams/views/etiqueta.php b/modules/registro_exams/views/etiqueta.php new file mode 100644 index 0000000..88e6a49 --- /dev/null +++ b/modules/registro_exams/views/etiqueta.php @@ -0,0 +1,325 @@ +getConnection(); + + $stmt = $pdo->prepare(' + SELECT + o.id, o.codigo, o.prioridad, o.estado, + o.requiere_ayuno, o.horas_ayuno, + o.medico_nombre, o.diagnostico, o.creado_at, + p.nombre_completo, p.numero_documento, p.tipo_documento, + p.fecha_nacimiento, p.eps + FROM exam_ordenes o + JOIN lab_pacientes p ON p.id = o.paciente_id + WHERE o.id = ? + '); + $stmt->execute([$ordenId]); + $orden = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$orden) { + http_response_code(404); + die('Orden no encontrada.'); + } + + // Ítems + $stmt = $pdo->prepare(' + SELECT ei.estado, et.nombre AS tipo_nombre, et.codigo AS tipo_codigo, + et.requiere_ayuno, et.horas_ayuno + FROM exam_items ei + JOIN exam_tipos et ON et.id = ei.exam_tipo_id + WHERE ei.orden_id = ? + ORDER BY et.nombre + '); + $stmt->execute([$ordenId]); + $items = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Edad + $edad = null; + if ($orden['fecha_nacimiento']) { + $edad = (new DateTime($orden['fecha_nacimiento']))->diff(new DateTime())->y; + } + + // Ayuno requerido (por la orden o por algún ítem) + $ayunoReq = (bool) $orden['requiere_ayuno']; + foreach ($items as $it) { + if ($it['requiere_ayuno']) { $ayunoReq = true; break; } + } + $horasValues = array_filter(array_column($items, 'horas_ayuno')); + $horasAyuno = $orden['horas_ayuno'] ?? ($horasValues ? max($horasValues) : 0); + +} catch (\Throwable $e) { + http_response_code(500); + die('Error al cargar datos: ' . htmlspecialchars($e->getMessage())); +} + +$fecha = $orden['creado_at'] + ? (new DateTime($orden['creado_at']))->format('d/m/Y H:i') + : date('d/m/Y'); + +// Generar código de barras como CSS barcode (sin librería externa) +// Usaremos simplemente el código textual + formato visual +$codigoBarras = $orden['codigo']; +?> + + + + + Etiqueta <?= htmlspecialchars($codigoBarras) ?> + + + + + + + + +
+ + ← Volver a orden +
+ +
+ + +
+
+
+
Exámenes de laboratorio en sede
+
+
+
+
+
+
+ + +
+
+
+ Documento + +
+ +
+ Edad + años +
+ + +
+ EPS + +
+ +
+ Prioridad + + + +
+
+ + +
+ Exámenes solicitados () +
+
+ $item): ?> + . + () + · + + +
+ + + +
+ ⚠ REQUIERE AYUNO +
+ + + + + +
+ + + + diff --git a/modules/registro_exams/views/lista.php b/modules/registro_exams/views/lista.php new file mode 100644 index 0000000..39ccecb --- /dev/null +++ b/modules/registro_exams/views/lista.php @@ -0,0 +1,257 @@ + +
+ + +
+
+

Órdenes de Exámenes

+ Registro y seguimiento de exámenes de laboratorio +
+ + Nueva orden + +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
Total día
+
+
+
+
+
+
Pendientes
+
+
+
+
+
+
En proceso
+
+
+
+
+
+
Completas
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + +
CódigoPacienteEdadPrioridadEstadoÍtemsMuestrasHoraAcciones
+ Cargando… +
+
+
+ +
+
+ + + diff --git a/modules/registro_exams/views/nueva_orden.php b/modules/registro_exams/views/nueva_orden.php new file mode 100644 index 0000000..8eb62d8 --- /dev/null +++ b/modules/registro_exams/views/nueva_orden.php @@ -0,0 +1,419 @@ +getConnection(); + $stmt = $pdo->prepare(' + SELECT s.id AS sol_id, + p.id AS pac_id, p.nombre_completo, p.numero_documento + FROM turnero_solicitudes s + JOIN lab_pacientes p ON p.id = s.paciente_id + WHERE s.id = ? + '); + $stmt->execute([$solicitudId]); + $pre = $stmt->fetch(PDO::FETCH_ASSOC); + if ($pre) { + $pacientePreId = (int) $pre['pac_id']; + $pacientePreNom = $pre['nombre_completo']; + $pacientePreDoc = $pre['numero_documento']; + } + } catch (\Throwable $e) { + // continuar sin pre-cargar + } +} + +// Cargar tipos de exámenes activos agrupados por categoría +$examTipos = []; +try { + $pdo = Database::getInstance()->getConnection(); + $stmt = $pdo->query(' + SELECT id, codigo, nombre, categoria, requiere_ayuno, horas_ayuno, + precio_base, instrucciones + FROM exam_tipos + WHERE activo = 1 + ORDER BY categoria, nombre + '); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $cat = $row['categoria'] ?: 'General'; + $examTipos[$cat][] = $row; + } +} catch (\Throwable $e) {} + +Layout::open('Nueva Orden de Exámenes', 'fas fa-plus-circle'); +?> +
+ +
+ + Volver + +

Nueva Orden de Exámenes

+
+ +
+ + + + +
+
+ 1. Datos del paciente +
+
+ + + + +
+ +
+
+
+
+ +
+
+ +
+
+
+ +
+
+ + +
+ + +
+
+
+
+
+ + +
+
+ 2. Datos médicos +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + 3. Exámenes solicitados +
+
+ + +
+ + +
+ + +
+ No hay tipos de examen configurados. Configure los exámenes en + Configuración → Exámenes. +
+ +
+ $tipos): ?> +
+
+ +
+
+ +
+ +
+ +
+
+ +
+ + + +
+
+ Seleccionados: + + + Ayuno requerido + +
+
+ +
+
+ + +
+ + Cancelar + + +
+ +
+ + + + + diff --git a/modules/registro_exams/views/orden.php b/modules/registro_exams/views/orden.php new file mode 100644 index 0000000..cc1cd04 --- /dev/null +++ b/modules/registro_exams/views/orden.php @@ -0,0 +1,536 @@ + +
+ + +
+ + Volver + +

+ + Cargando… +

+
+ + Etiqueta + + +
+
+ + +
+
+
Cargando orden…
+
+ + +
+ + +
+ +
+
+
+ Paciente +
+
+
+
+
+
Edad:
+
Género:
+
EPS:
+
Tel:
+
+
+
+
+ + +
+
+
+ + Datos de la orden + + +
+
+
+
Médico:
+
Registro:
+
Diagnóstico:
+
Notas:
+
Creado:
+
+
+
+
+
+ + +
+
+ Muestras + +
+
+ + + + + + + + + + + + + +
TipoCódigo barrasEstadoTomada porHora
Sin muestras registradas
+
+
+ + +
+
+ + Exámenes solicitados + +
+
+
Cargando ítems…
+
+
+ +
+
+ + + + + + + + + diff --git a/modules/turnero/api/_helpers.php b/modules/turnero/api/_helpers.php new file mode 100644 index 0000000..54448cf --- /dev/null +++ b/modules/turnero/api/_helpers.php @@ -0,0 +1,219 @@ + false, 'error' => 'Error interno: ' . $err['message']]); + } +}); + +header('Content-Type: application/json; charset=utf-8'); +header('Access-Control-Allow-Origin: *'); +header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); +header('Access-Control-Allow-Headers: Content-Type'); + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + +// ── Funciones de respuesta ─────────────────────────────────── + +function jsonOk(array $payload = [], string $mensaje = ''): void +{ + ob_clean(); + $resp = ['ok' => true, 'success' => true]; + if ($mensaje) { + $resp['message'] = $mensaje; + } + echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE); + exit; +} + +function jsonError(string $mensaje, int $code = 400): void +{ + ob_clean(); + http_response_code($code); + echo json_encode(['ok' => false, 'success' => false, 'error' => $mensaje], JSON_UNESCAPED_UNICODE); + exit; +} + +function requireMethod(string $method): void +{ + if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) { + jsonError('Método no permitido', 405); + } +} + +function inputJson(): array +{ + static $parsed = null; + if ($parsed === null) { + $raw = file_get_contents('php://input'); + $parsed = json_decode($raw, true) ?? []; + } + return $parsed; +} + +// ── Autenticación (kiosko y display son públicos) ──────────── + +/** + * Exige sesión activa. Para endpoints de login requerido. + */ +function requireAuth(): void +{ + if (empty($_SESSION['admin_user']['id'])) { + jsonError('No autenticado', 401); + } +} + +/** + * Exige sesión + módulo turnero. + */ +function requireTurnero(): void +{ + requireAuth(); + $modules = $_SESSION['admin_user']['modules'] ?? []; + $roleId = $_SESSION['admin_user']['role_id'] ?? null; + + // Superadmin / admin sin role_id = acceso total + if (empty($roleId)) { + return; + } + + if (!in_array('turnero', (array) $modules, true)) { + jsonError('Sin acceso al módulo Turnero', 403); + } +} + +function adminId(): ?int +{ + return isset($_SESSION['admin_user']['id']) + ? (int) $_SESSION['admin_user']['id'] + : null; +} + +// ── Helper BD ──────────────────────────────────────────────── + +function db(): PDO +{ + return Database::getInstance()->getConnection(); +} + +// ── Motor de cola ──────────────────────────────────────────── + +/** + * Obtiene o crea la sesión del día actual. + * Devuelve el id de la sesión. + */ +function obtenerOCrearSesionHoy(): int +{ + $pdo = db(); + $hoy = date('Y-m-d'); + + $stmt = $pdo->prepare('SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1'); + $stmt->execute([$hoy]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($row) { + return (int) $row['id']; + } + + // Crear sesión del día + $stmt = $pdo->prepare( + 'INSERT INTO turnero_sesiones (fecha, abierto_por, inicio_at) VALUES (?, ?, NOW())' + ); + $stmt->execute([$hoy, adminId()]); + return (int) $pdo->lastInsertId(); +} + +/** + * Genera el siguiente número correlativo de turno para una sesión. + * Usa bloqueo a nivel de fila para evitar duplicados en concurrencia. + */ +function siguienteNumero(int $sesionId): int +{ + $pdo = db(); + $stmt = $pdo->prepare( + 'SELECT COALESCE(MAX(numero), 0) + 1 AS siguiente + FROM turnero_turnos + WHERE sesion_id = ? + FOR UPDATE' + ); + $stmt->execute([$sesionId]); + return (int) $stmt->fetchColumn(); +} + +/** + * Selecciona el siguiente turno a llamar según el motor de prioridades. + * + * @param string $estado Estado que deben tener los turnos en cola + * @param int|null $lugarId Si no es null, filtra por lugar_destino_id + * @return array|null Fila de turnero_turnos o null si cola vacía + */ +function siguienteTurnoEnCola(string $estado, ?int $lugarId = null): ?array +{ + $pdo = db(); + + if ($lugarId !== null) { + $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 = ? + AND t.lugar_destino_id = ? + ORDER BY p.orden_peso ASC, t.creado_at ASC + LIMIT 1 + '; + $stmt = $pdo->prepare($sql); + $stmt->execute([$estado, $lugarId]); + } else { + $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 = ? + ORDER BY p.orden_peso ASC, t.creado_at ASC + LIMIT 1 + '; + $stmt = $pdo->prepare($sql); + $stmt->execute([$estado]); + } + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ?: null; +} + +/** + * Emite un evento SSE a todos los clientes suscritos escribiendo + * un flag en la tabla turnero_sesiones (campo sse_ping_at). + * Los clientes hacen polling de ese campo o escuchan el SSE directamente. + * + * Implementación ligera: actualiza timestamp en sesión para que + * sse_turno.php lo detecte y reenvíe el estado. + */ +function notificarSSE(int $sesionId): void +{ + try { + db()->prepare( + 'UPDATE turnero_sesiones SET sse_ping_at = NOW() WHERE id = ?' + )->execute([$sesionId]); + } catch (\Throwable $e) { + // No es crítico si falla; el SSE continuará con polling normal + } +} diff --git a/modules/turnero/api/cambiar_estado.php b/modules/turnero/api/cambiar_estado.php new file mode 100644 index 0000000..8a244f0 --- /dev/null +++ b/modules/turnero/api/cambiar_estado.php @@ -0,0 +1,174 @@ + [estados_destino_permitidos]] +const TRANSICIONES = [ + 'espera' => ['en_recepcion', 'ausente', 'cancelado'], + 'en_recepcion' => ['en_espera_lugar', 'ausente', 'cancelado'], + 'en_espera_lugar' => ['en_servicio', 'ausente', 'cancelado'], + 'en_servicio' => ['finalizado', 'ausente', 'cancelado'], +]; + +$pdo = db(); +$pdo->beginTransaction(); + +try { + // Leer turno actual con lock + $stmt = $pdo->prepare( + 'SELECT t.*, p.codigo AS prioridad_codigo + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + WHERE t.id = ? + FOR UPDATE' + ); + $stmt->execute([$turnoId]); + $turno = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$turno) { + $pdo->rollBack(); + jsonError('Turno no encontrado.', 404); + } + + $estadoActual = $turno['estado']; + + // Estados finales no modificables + if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) { + $pdo->rollBack(); + jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422); + } + + // Verificar que la transición sea válida + $permitidos = TRANSICIONES[$estadoActual] ?? []; + if (!in_array($nuevoEstado, $permitidos, true)) { + $pdo->rollBack(); + jsonError( + "Transición '{$estadoActual}' → '{$nuevoEstado}' no está permitida. " + . "Transiciones válidas: " . implode(', ', $permitidos), + 422 + ); + } + + // ── Regla especial: en_espera_lugar → en_servicio ───────── + if ($nuevoEstado === 'en_servicio') { + $cStmt = $pdo->prepare( + "SELECT COUNT(*) FROM turnero_consentimientos + WHERE turno_id = ? + AND estado NOT IN ('firmado', 'rechazado')" + ); + $cStmt->execute([$turnoId]); + $pendientes = (int) $cStmt->fetchColumn(); + + if ($pendientes > 0) { + $pdo->rollBack(); + jsonError( + "Hay {$pendientes} consentimiento(s) pendiente(s). " + . "El turno no puede pasar a 'en_servicio' hasta que todos estén firmados o rechazados.", + 422 + ); + } + } + + // ── Construir UPDATE dinámico ───────────────────────────── + $sets = ['estado = ?']; + $binds = [$nuevoEstado]; + + switch ($nuevoEstado) { + case 'en_recepcion': + $sets[] = 'llamado_recepcion_at = COALESCE(llamado_recepcion_at, NOW())'; + $sets[] = 'inicio_recepcion_at = COALESCE(inicio_recepcion_at, NOW())'; + $sets[] = 'atendido_recepcion_por = COALESCE(atendido_recepcion_por, ?)'; + $binds[] = adminId(); + break; + + case 'en_espera_lugar': + // Requiere lugar_id + if (!$lugarId) { + $pdo->rollBack(); + jsonError('lugar_id es obligatorio para pasar a en_espera_lugar.', 422); + } + $sets[] = 'fin_recepcion_at = COALESCE(fin_recepcion_at, NOW())'; + $sets[] = 'lugar_destino_id = ?'; + $binds[] = $lugarId; + $sets[] = 'atendido_recepcion_por = COALESCE(atendido_recepcion_por, ?)'; + $binds[] = adminId(); + break; + + case 'en_servicio': + $sets[] = 'llamado_lugar_at = COALESCE(llamado_lugar_at, NOW())'; + $sets[] = 'inicio_lugar_at = COALESCE(inicio_lugar_at, NOW())'; + $sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)'; + $binds[] = adminId(); + break; + + case 'finalizado': + $sets[] = 'fin_lugar_at = COALESCE(fin_lugar_at, NOW())'; + $sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)'; + $binds[] = adminId(); + break; + + case 'ausente': + case 'cancelado': + // Sin timestamps adicionales; simplemente cambio de estado + break; + } + + $sets[] = 'actualizado_at = NOW()'; + $sql = 'UPDATE turnero_turnos SET ' . implode(', ', $sets) . ' WHERE id = ?'; + $binds[] = $turnoId; + + $pdo->prepare($sql)->execute($binds); + $pdo->commit(); + + // Refrescar y devolver turno actualizado + $stmt = $pdo->prepare( + 'SELECT t.*, p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + WHERE t.id = ?' + ); + $stmt->execute([$turnoId]); + $turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC); + + notificarSSE((int) $turnoActualizado['sesion_id']); + + jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'"); + +} catch (\Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + jsonError('Error al cambiar estado: ' . $e->getMessage(), 500); +} diff --git a/modules/turnero/api/create_solicitud.php b/modules/turnero/api/create_solicitud.php new file mode 100644 index 0000000..d84ec49 --- /dev/null +++ b/modules/turnero/api/create_solicitud.php @@ -0,0 +1,171 @@ +beginTransaction(); + +try { + // Verificar que el turno existe y está en recepción + $stmt = $pdo->prepare( + "SELECT id, estado FROM turnero_turnos WHERE id = ? FOR UPDATE" + ); + $stmt->execute([$turnoId]); + $turno = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$turno) { + $pdo->rollBack(); + jsonError('Turno no encontrado.', 404); + } + if (!in_array($turno['estado'], ['en_recepcion', 'espera'], true)) { + $pdo->rollBack(); + jsonError("El turno está en estado '{$turno['estado']}' y no se puede editar su solicitud.", 422); + } + + // Verificar que el lugar existe + $stmt = $pdo->prepare("SELECT id FROM turnero_lugares WHERE id = ? AND activo = 1"); + $stmt->execute([$lugarId]); + if (!$stmt->fetch()) { + $pdo->rollBack(); + jsonError('Lugar destino no encontrado o inactivo.', 404); + } + + // Verificar que todos los exam_tipo_ids existen + $in = implode(',', array_fill(0, count($examIds), '?')); + $stmt = $pdo->prepare("SELECT COUNT(*) FROM exam_tipos WHERE id IN ({$in}) AND activo = 1"); + $stmt->execute($examIds); + if ((int)$stmt->fetchColumn() !== count($examIds)) { + $pdo->rollBack(); + jsonError('Uno o más exámenes no existen o están inactivos.', 422); + } + + // ── Insertar o reemplazar solicitud (DELETE + INSERT para poder re-guardar) ── + $stmt = $pdo->prepare("SELECT id FROM turnero_solicitudes WHERE turno_id = ?"); + $stmt->execute([$turnoId]); + $solAnterior = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($solAnterior) { + // Eliminar ítemes anteriores (CASCADE borrará los turnero_examen_items) + $pdo->prepare("DELETE FROM turnero_solicitudes WHERE turno_id = ?")->execute([$turnoId]); + } + + $stmt = $pdo->prepare( + "INSERT INTO turnero_solicitudes + (turno_id, paciente_id, lugar_id, total_cobrado, metodo_pago, observaciones, creado_por) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + $stmt->execute([ + $turnoId, $pacienteId, $lugarId, + $total, $metodoPago ?: null, $obs ?: null, adminId(), + ]); + $solicitudId = (int) $pdo->lastInsertId(); + + // ── Insertar ítemes de examen ────────────────────────────── + $stmtItem = $pdo->prepare( + "INSERT INTO turnero_examen_items (solicitud_id, exam_tipo_id) VALUES (?, ?)" + ); + foreach ($examIds as $examId) { + $stmtItem->execute([$solicitudId, $examId]); + } + + // ── Calcular consentimientos necesarios (sin crearlos todavía) ── + // Deduplica: varios exámenes que apuntan al mismo formulario → 1 solo ítem. + $in = implode(',', array_fill(0, count($examIds), '?')); + $stmt = $pdo->prepare( + "SELECT DISTINCT etc.formulario_id, f.nombre AS formulario_nombre + FROM exam_tipo_consentimientos etc + JOIN lab_formularios f ON f.id = etc.formulario_id + WHERE etc.exam_tipo_id IN ({$in})" + ); + $stmt->execute($examIds); + $consentimientosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Agregar estado actual de cada consentimiento (si ya fue enviado antes) + foreach ($consentimientosRequeridos as &$c) { + $stC = $pdo->prepare( + "SELECT estado FROM turnero_consentimientos WHERE turno_id = ? AND formulario_id = ?" + ); + $stC->execute([$turnoId, $c['formulario_id']]); + $row = $stC->fetch(PDO::FETCH_ASSOC); + $c['estado'] = $row['estado'] ?? 'pendiente'; + } + unset($c); + + $pdo->commit(); + notificarSSE(obtenerOCrearSesionHoy()); + + // Recuperar solicitud completa + $stmt = $pdo->prepare( + "SELECT s.*, + p.full_name AS paciente_nombre, + l.nombre AS lugar_nombre + FROM turnero_solicitudes s + JOIN lab_pacientes l2 ON l2.id = s.lugar_id -- placeholder; join real abajo + -- joins reales: + LEFT JOIN lab_pacientes p ON p.id = s.paciente_id + LEFT JOIN turnero_lugares l ON l.id = s.lugar_id + WHERE s.id = ?" + ); + // Simplificado para evitar error de sintaxis de comentarios SQL embebidos: + $stmt = $pdo->prepare( + "SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id, s.total_cobrado, + s.metodo_pago, s.observaciones, s.creado_at, + p.full_name AS paciente_nombre, + p.documento AS paciente_documento, + l.nombre AS lugar_nombre + FROM turnero_solicitudes s + LEFT JOIN lab_pacientes p ON p.id = s.paciente_id + LEFT JOIN turnero_lugares l ON l.id = s.lugar_id + WHERE s.id = ?" + ); + $stmt->execute([$solicitudId]); + $solicitud = $stmt->fetch(PDO::FETCH_ASSOC); + + jsonOk([ + 'solicitud' => $solicitud, + 'consentimientos_requeridos' => $consentimientosRequeridos, + ], 'Solicitud guardada correctamente'); + +} catch (\Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + jsonError('Error al crear solicitud: ' . $e->getMessage(), 500); +} diff --git a/modules/turnero/api/create_turno.php b/modules/turnero/api/create_turno.php new file mode 100644 index 0000000..4c677c4 --- /dev/null +++ b/modules/turnero/api/create_turno.php @@ -0,0 +1,113 @@ +beginTransaction(); + +try { + $sesionId = obtenerOCrearSesionHoy(); + $numero = siguienteNumero($sesionId); + + // Buscar prioridad_id + $stmt = $pdo->prepare( + 'SELECT id FROM turnero_prioridades WHERE codigo = ? AND activo = 1 LIMIT 1' + ); + $stmt->execute([$prioCodigo]); + $prioridad = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$prioridad) { + $pdo->rollBack(); + jsonError("La prioridad '$prioCodigo' no existe o no está activa.", 422); + } + + $prioridadId = (int) $prioridad['id']; + + // Código visual: "A001", "E042", etc. + $codigo = $prioCodigo . str_pad($numero, 3, '0', STR_PAD_LEFT); + + $stmt = $pdo->prepare( + 'INSERT INTO turnero_turnos + (sesion_id, numero, codigo, prioridad_id, paciente_nombre, paciente_cel, + estado, creado_at) + VALUES + (?, ?, ?, ?, ?, ?, "espera", NOW())' + ); + $stmt->execute([ + $sesionId, + $numero, + $codigo, + $prioridadId, + $pacienteNombre ?: null, + $pacienteCel ?: null, + ]); + + $turnoId = (int) $pdo->lastInsertId(); + $pdo->commit(); + + notificarSSE($sesionId); + + // Contar posición en la cola + $stmt = $pdo->prepare( + ' + SELECT COUNT(*) AS posicion + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + WHERE t.estado = "espera" + AND t.sesion_id = ? + AND ( + p.orden_peso < (SELECT orden_peso FROM turnero_prioridades WHERE id = ?) + OR ( + p.orden_peso = (SELECT orden_peso FROM turnero_prioridades WHERE id = ?) + AND t.creado_at < (SELECT creado_at FROM turnero_turnos WHERE id = ?) + ) + ) + ' + ); + $stmt->execute([$sesionId, $prioridadId, $prioridadId, $turnoId]); + $posicion = (int) $stmt->fetchColumn() + 1; + + jsonOk([ + 'turno' => [ + 'id' => $turnoId, + 'codigo' => $codigo, + 'numero' => $numero, + 'prioridad_codigo' => $prioCodigo, + 'posicion_cola' => $posicion, + 'sesion_id' => $sesionId, + ], + ], "Turno $codigo asignado correctamente"); + +} catch (\Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + jsonError('Error al crear el turno: ' . $e->getMessage(), 500); +} diff --git a/modules/turnero/api/export_csv.php b/modules/turnero/api/export_csv.php new file mode 100644 index 0000000..41a9951 --- /dev/null +++ b/modules/turnero/api/export_csv.php @@ -0,0 +1,125 @@ +prepare('SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1'); +$stmtS->execute([$fecha]); +$sesion = $stmtS->fetch(PDO::FETCH_ASSOC); + +if (!$sesion) { + // Sin sesión: CSV vacío con encabezado + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="turnero_' . $fecha . '.csv"'); + echo "\xEF\xBB\xBF"; // BOM UTF-8 para Excel + echo "Sin datos para la fecha {$fecha}\n"; + exit; +} + +$sesionId = (int)$sesion['id']; + +$stmt = $pdo->prepare( + "SELECT + t.codigo, + t.numero, + p.codigo AS prioridad, + p.nombre AS prioridad_nombre, + COALESCE(s.nombre_completo, t.paciente_nombre) AS paciente, + s.numero_documento, + s.tipo_documento, + t.paciente_cel AS celular, + l.nombre AS lugar, + t.estado, + t.creado_at, + t.llamado_recepcion_at, + t.inicio_recepcion_at, + t.fin_recepcion_at, + t.llamado_lugar_at, + t.inicio_lugar_at, + t.fin_lugar_at, + ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, t.fin_recepcion_at, NOW())) / 60.0, 1) + AS espera_recepcion_min, + ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) + AS servicio_lugar_min, + sol.total_cobrado, + sol.metodo_pago, + GROUP_CONCAT(et.nombre ORDER BY et.nombre SEPARATOR ' | ') AS examenes, + SUM(CASE WHEN tc.estado = 'firmado' THEN 1 ELSE 0 END) AS consent_firmados, + SUM(CASE WHEN tc.estado = 'pendiente' THEN 1 ELSE 0 END) AS consent_pendientes + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id + LEFT JOIN turnero_solicitudes sol ON sol.turno_id = t.id + LEFT JOIN lab_pacientes s ON s.id = sol.paciente_id + LEFT JOIN turnero_examen_items tei ON tei.solicitud_id = sol.id + LEFT JOIN exam_tipos et ON et.id = tei.exam_tipo_id + LEFT JOIN turnero_consentimientos tc ON tc.turno_id = t.id + WHERE t.sesion_id = ? + GROUP BY t.id + ORDER BY t.numero ASC" +); +$stmt->execute([$sesionId]); +$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + +// ── Encabezados CSV ─────────────────────────────────────────── +$headers = [ + 'Código', 'Número', 'Prioridad', 'Prioridad nombre', + 'Paciente', 'Documento', 'Tipo doc.', 'Celular', + 'Lugar', 'Estado', + 'Creado', 'Llamado recepción', 'Inicio recepción', 'Fin recepción', + 'Llamado lugar', 'Inicio lugar', 'Fin lugar', + 'Espera recepción (min)', 'Servicio lugar (min)', + 'Total cobrado', 'Método pago', + 'Exámenes', + 'Consentimientos firmados', 'Consentimientos pendientes', +]; + +$fields = [ + 'codigo', 'numero', 'prioridad', 'prioridad_nombre', + 'paciente', 'numero_documento', 'tipo_documento', 'celular', + 'lugar', 'estado', + 'creado_at', 'llamado_recepcion_at', 'inicio_recepcion_at', 'fin_recepcion_at', + 'llamado_lugar_at', 'inicio_lugar_at', 'fin_lugar_at', + 'espera_recepcion_min', 'servicio_lugar_min', + 'total_cobrado', 'metodo_pago', + 'examenes', + 'consent_firmados', 'consent_pendientes', +]; + +// ── Output ──────────────────────────────────────────────────── +header('Content-Type: text/csv; charset=utf-8'); +header('Content-Disposition: attachment; filename="turnero_' . $fecha . '.csv"'); +header('Cache-Control: no-cache, no-store, must-revalidate'); + +$out = fopen('php://output', 'w'); +echo "\xEF\xBB\xBF"; // BOM UTF-8 para Excel + +fputcsv($out, $headers, ',', '"'); + +foreach ($rows as $row) { + $line = []; + foreach ($fields as $f) { + $v = $row[$f] ?? ''; + // Formatear fechas: quitar segundos fraccionarios si los hay + if ($v && in_array($f, ['creado_at','llamado_recepcion_at','inicio_recepcion_at','fin_recepcion_at','llamado_lugar_at','inicio_lugar_at','fin_lugar_at'])) { + $v = $v ? date('d/m/Y H:i:s', strtotime($v)) : ''; + } + $line[] = $v ?? ''; + } + fputcsv($out, $line, ',', '"'); +} + +fclose($out); +exit; diff --git a/modules/turnero/api/get_cola.php b/modules/turnero/api/get_cola.php new file mode 100644 index 0000000..6b5c500 --- /dev/null +++ b/modules/turnero/api/get_cola.php @@ -0,0 +1,125 @@ +prepare( + "SELECT t.id, + t.codigo, + t.numero, + t.estado, + t.paciente_nombre, + t.creado_at, + t.llamado_recepcion_at, + t.llamado_lugar_at, + p.codigo AS prioridad_codigo, + p.nombre AS prioridad_nombre, + p.color AS prioridad_color, + p.orden_peso + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + WHERE t.sesion_id = ? + AND t.estado IN ({$inEstados}) + {$filtroLugar} + ORDER BY p.orden_peso ASC, t.creado_at ASC" +); +array_unshift($bindsCola, $sesionId); +$stmt->execute($bindsCola); +$cola = $stmt->fetchAll(PDO::FETCH_ASSOC); + +// ── Turno actualmente en pantalla (último llamado) ──────────── +$campoLlamado = $area === 'recepcion' ? 'llamado_recepcion_at' : 'llamado_lugar_at'; +$estadoActivo = $area === 'recepcion' ? "'en_recepcion'" : "'en_servicio'"; +$bindActivo = $area === 'lugar' ? [$sesionId, $lugarId] : [$sesionId]; +$filtroActivo = $area === 'lugar' ? 'AND t.lugar_destino_id = ?' : ''; + +$stmt = $pdo->prepare( + "SELECT t.id, + t.codigo, + t.numero, + t.estado, + t.paciente_nombre, + t.{$campoLlamado} AS llamado_at, + p.codigo AS prioridad_codigo, + p.nombre AS prioridad_nombre, + p.color AS prioridad_color + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + WHERE t.sesion_id = ? + AND t.estado = {$estadoActivo} + {$filtroActivo} + ORDER BY t.{$campoLlamado} DESC + LIMIT 1" +); +$stmt->execute($bindActivo); +$activo = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + +// ── Estadísticas de la sesión ───────────────────────────────── +$stmt = $pdo->prepare( + "SELECT + COUNT(*) AS total, + SUM(estado = 'espera') AS en_espera, + SUM(estado = 'en_recepcion') AS en_recepcion, + SUM(estado = 'en_espera_lugar') AS en_espera_lugar, + SUM(estado = 'en_servicio') AS en_servicio, + SUM(estado = 'finalizado') AS finalizados, + SUM(estado IN ('ausente','cancelado')) AS no_atendidos, + SEC_TO_TIME( + AVG( + CASE WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL + THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) + ELSE NULL END + ) + ) AS tiempo_promedio_atencion + FROM turnero_turnos + WHERE sesion_id = ?" +); +$stmt->execute([$sesionId]); +$stats = $stmt->fetch(PDO::FETCH_ASSOC); + +jsonOk([ + 'sesion_id' => $sesionId, + 'area' => $area, + 'lugar_id' => $lugarId, + 'activo' => $activo, + 'cola' => $cola, + 'stats' => $stats, + 'timestamp' => date('c'), +]); diff --git a/modules/turnero/api/get_consentimientos.php b/modules/turnero/api/get_consentimientos.php new file mode 100644 index 0000000..cae3519 --- /dev/null +++ b/modules/turnero/api/get_consentimientos.php @@ -0,0 +1,98 @@ +prepare("SELECT id, estado FROM turnero_turnos WHERE id = ?"); +$stmt->execute([$turnoId]); +$turno = $stmt->fetch(PDO::FETCH_ASSOC); +if (!$turno) jsonError('Turno no encontrado.', 404); + +// ── Consentimientos ─────────────────────────────────────────── +$stmt = $pdo->prepare( + "SELECT tc.id, + tc.turno_id, + tc.formulario_id, + tc.token, + tc.estado, + tc.enviado_at, + tc.firmado_at, + f.nombre AS formulario_nombre + FROM turnero_consentimientos tc + LEFT JOIN lab_formularios f ON f.id = tc.formulario_id + WHERE tc.turno_id = ? + ORDER BY tc.id ASC" +); +$stmt->execute([$turnoId]); +$consentimientos = $stmt->fetchAll(PDO::FETCH_ASSOC); + +$respuesta = [ + 'turno_id' => $turnoId, + 'turno_estado' => $turno['estado'], + 'consentimientos' => $consentimientos, +]; + +// ── Datos adicionales si solicita solicitud ─────────────────── +if ($incluirSolicitud) { + $stmt = $pdo->prepare( + "SELECT s.id, s.turno_id, s.paciente_id, s.lugar_id, + s.total_cobrado, s.metodo_pago, s.observaciones, s.creado_at, + l.nombre AS lugar_nombre + FROM turnero_solicitudes s + LEFT JOIN turnero_lugares l ON l.id = s.lugar_id + WHERE s.turno_id = ? + LIMIT 1" + ); + $stmt->execute([$turnoId]); + $solicitud = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + + $paciente = null; + $examenes = []; + + if ($solicitud) { + // Paciente + $stmt = $pdo->prepare( + "SELECT id, full_name, tipo_documento, documento, + fecha_nacimiento, telefono, celular + FROM lab_pacientes + WHERE id = ?" + ); + $stmt->execute([$solicitud['paciente_id']]); + $paciente = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + + // Exámenes + $stmt = $pdo->prepare( + "SELECT et.id, et.codigo, et.nombre, et.categoria + FROM turnero_examen_items tei + JOIN exam_tipos et ON et.id = tei.exam_tipo_id + WHERE tei.solicitud_id = ? + ORDER BY et.categoria, et.nombre" + ); + $stmt->execute([$solicitud['id']]); + $examenes = $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + $respuesta['solicitud'] = $solicitud; + $respuesta['paciente'] = $paciente; + $respuesta['examenes'] = $examenes; +} + +jsonOk($respuesta); diff --git a/modules/turnero/api/get_dashboard.php b/modules/turnero/api/get_dashboard.php new file mode 100644 index 0000000..9293fa7 --- /dev/null +++ b/modules/turnero/api/get_dashboard.php @@ -0,0 +1,161 @@ +prepare( + 'SELECT id, inicio_at, fin_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1' +); +$stmtSesion->execute([$fecha]); +$sesion = $stmtSesion->fetch(PDO::FETCH_ASSOC); + +if (!$sesion) { + jsonOk([ + 'fecha' => $fecha, + 'sesion' => null, + 'resumen' => ['total' => 0, 'atendidos' => 0, 'en_espera' => 0, + 'ausentes' => 0, 'cancelados' => 0, 'tiempo_promedio_min' => null], + 'por_prioridad' => [], + 'por_lugar' => [], + 'turnos' => [], + ]); +} + +$sesionId = (int)$sesion['id']; + +// ── 2. Resumen global ───────────────────────────────────────── +$stmtRes = $pdo->prepare( + "SELECT + COUNT(*) AS total, + SUM(estado IN ('finalizado','en_servicio')) AS atendidos, + SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera, + SUM(estado = 'ausente') AS ausentes, + SUM(estado = 'cancelado') AS cancelados, + ROUND( + AVG( + CASE + WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL + THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0 + END + ), 1 + ) AS tiempo_espera_promedio_min, + ROUND( + AVG( + CASE + WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL + THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0 + END + ), 1 + ) AS tiempo_servicio_promedio_min + FROM turnero_turnos + WHERE sesion_id = ?" +); +$stmtRes->execute([$sesionId]); +$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC); + +// ── 3. Desglose por prioridad ───────────────────────────────── +$stmtPri = $pdo->prepare( + "SELECT p.codigo, p.nombre, p.color, + COUNT(t.id) AS total, + SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos, + SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS pendientes, + SUM(t.estado = 'ausente') AS ausentes + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + WHERE t.sesion_id = ? + GROUP BY p.id + ORDER BY p.orden_peso ASC" +); +$stmtPri->execute([$sesionId]); +$porPrioridad = $stmtPri->fetchAll(PDO::FETCH_ASSOC); + +// ── 4. Desglose por lugar ───────────────────────────────────── +$stmtLug = $pdo->prepare( + "SELECT l.nombre AS lugar, + COUNT(t.id) AS total, + SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos, + SUM(t.estado IN ('en_espera_lugar')) AS en_espera, + ROUND(AVG( + CASE + WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL + THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0 + END + ), 1) AS tiempo_servicio_promedio_min + FROM turnero_turnos t + JOIN turnero_lugares l ON l.id = t.lugar_destino_id + WHERE t.sesion_id = ? + GROUP BY l.id + ORDER BY l.sort_order ASC, l.nombre ASC" +); +$stmtLug->execute([$sesionId]); +$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC); + +// ── 5. Lista de turnos del día ──────────────────────────────── +$stmtTurnos = $pdo->prepare( + "SELECT t.id, t.codigo, t.numero, t.estado, + t.paciente_nombre, t.paciente_cel, + t.creado_at, t.inicio_recepcion_at, t.fin_recepcion_at, + t.inicio_lugar_at, t.fin_lugar_at, + p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color, + l.nombre AS lugar_nombre, + s.nombre_completo AS paciente_bd, + ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min, + ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id + LEFT JOIN turnero_solicitudes ts ON ts.turno_id = t.id + LEFT JOIN lab_pacientes s ON s.id = ts.paciente_id + WHERE t.sesion_id = ? + ORDER BY t.numero ASC" +); +$stmtTurnos->execute([$sesionId]); +$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC); + +// ── 6. Consentimientos del día ──────────────────────────────── +$stmtcons = $pdo->prepare( + "SELECT tc.estado, COUNT(*) AS cnt + FROM turnero_consentimientos tc + JOIN turnero_turnos t ON t.id = tc.turno_id + WHERE t.sesion_id = ? + GROUP BY tc.estado" +); +$stmtcons->execute([$sesionId]); +$consentStats = []; +foreach ($stmtcons->fetchAll(PDO::FETCH_ASSOC) as $r) { + $consentStats[$r['estado']] = (int)$r['cnt']; +} + +jsonOk([ + 'fecha' => $fecha, + 'sesion' => [ + 'id' => $sesionId, + 'inicio' => $sesion['inicio_at'] ? date('H:i', strtotime($sesion['inicio_at'])) : null, + 'fin' => $sesion['fin_at'] ? date('H:i', strtotime($sesion['fin_at'])) : null, + 'abierta' => $sesion['fin_at'] === null, + ], + 'resumen' => $resumen, + 'por_prioridad' => $porPrioridad, + 'por_lugar' => $porLugar, + 'consent_stats' => $consentStats, + 'turnos' => $turnos, +]); diff --git a/modules/turnero/api/llamar_turno.php b/modules/turnero/api/llamar_turno.php new file mode 100644 index 0000000..e39d279 --- /dev/null +++ b/modules/turnero/api/llamar_turno.php @@ -0,0 +1,106 @@ +beginTransaction(); + +try { + if ($area === 'recepcion') { + // Cola recepción: turnos en estado 'espera' sin filtro de lugar + $turno = siguienteTurnoEnCola('espera'); + + if (!$turno) { + $pdo->rollBack(); + jsonOk(['turno' => null], 'Cola de recepción vacía'); + } + + // Actualizar estado + timestamp + $stmt = $pdo->prepare( + 'UPDATE turnero_turnos + SET estado = "en_recepcion", + llamado_recepcion_at = NOW(), + inicio_recepcion_at = NOW(), + atendido_recepcion_por = ? + WHERE id = ? AND estado = "espera"' + ); + $stmt->execute([adminId(), $turno['id']]); + + if ($stmt->rowCount() === 0) { + // Otro proceso lo tomó primero (race condition) + $pdo->rollBack(); + jsonError('El turno ya fue tomado por otro operador. Intente de nuevo.', 409); + } + + } else { + // Cola de un Lugar específico: turnos 'en_espera_lugar' asignados a ese lugar + $turno = siguienteTurnoEnCola('en_espera_lugar', $lugarId); + + if (!$turno) { + $pdo->rollBack(); + jsonOk(['turno' => null], 'Cola del lugar vacía'); + } + + $stmt = $pdo->prepare( + 'UPDATE turnero_turnos + SET estado = "en_servicio", + llamado_lugar_at = NOW(), + inicio_lugar_at = NOW(), + atendido_lugar_por = ? + WHERE id = ? AND estado = "en_espera_lugar"' + ); + $stmt->execute([adminId(), $turno['id']]); + + if ($stmt->rowCount() === 0) { + $pdo->rollBack(); + jsonError('El turno ya fue tomado por otro operador. Intente de nuevo.', 409); + } + } + + // Refrescar datos del turno actualizado + $stmt = $pdo->prepare( + 'SELECT t.*, + p.codigo AS prioridad_codigo, + p.nombre AS prioridad_nombre, + p.color AS prioridad_color + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + WHERE t.id = ?' + ); + $stmt->execute([$turno['id']]); + $turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC); + + $pdo->commit(); + notificarSSE((int) $turnoActualizado['sesion_id']); + + jsonOk(['turno' => $turnoActualizado], "Turno {$turnoActualizado['codigo']} llamado"); + +} catch (\Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + jsonError('Error al llamar turno: ' . $e->getMessage(), 500); +} diff --git a/modules/turnero/api/save_ex_tipo.php b/modules/turnero/api/save_ex_tipo.php new file mode 100644 index 0000000..7bee7b3 --- /dev/null +++ b/modules/turnero/api/save_ex_tipo.php @@ -0,0 +1,100 @@ +prepare( + 'SELECT et.*, + (SELECT formulario_id FROM exam_tipo_consentimientos + WHERE exam_tipo_id = et.id LIMIT 1) AS formulario_id + FROM exam_tipos et + WHERE et.id = ?' + ); + $stmt->execute([$id]); + $data = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$data) jsonError('Examen no encontrado', 404); + jsonOk(['data' => $data]); +} + +requireMethod('POST'); +$input = inputJson(); + +$id = (int)($input['id'] ?? 0); +$delete = !empty($input['_delete']); + +// ── ELIMINAR ───────────────────────────────────────────────── +if ($delete) { + if (!$id) jsonError('ID requerido'); + // Bloquear si tiene ítems en solicitudes activas + $stmt = db()->prepare( + 'SELECT COUNT(*) FROM turnero_examen_items WHERE exam_tipo_id = ?' + ); + $stmt->execute([$id]); + if ($stmt->fetchColumn() > 0) { + jsonError('No se puede eliminar: hay solicitudes que incluyen este examen. Desactívelo en su lugar.'); + } + // Eliminar relación de consentimiento primero (FK) + db()->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$id]); + db()->prepare('DELETE FROM exam_tipos WHERE id = ?')->execute([$id]); + jsonOk([], 'Examen eliminado'); +} + +// ── CREAR / ACTUALIZAR ─────────────────────────────────────── +$codigo = strtoupper(trim($input['codigo'] ?? '')); +$nombre = trim($input['nombre'] ?? ''); +$categoria = trim($input['categoria'] ?? ''); +$formId = (int)($input['formulario_id'] ?? 0) ?: null; +$activo = (int)($input['activo'] ?? 1); + +if ($codigo === '') jsonError('El código es requerido'); +if ($nombre === '') jsonError('El nombre es requerido'); +if (strlen($codigo) > 20) jsonError('El código no puede tener más de 20 caracteres'); + +$pdo = db(); +$pdo->beginTransaction(); + +try { + if ($id) { + $stmt = $pdo->prepare( + 'UPDATE exam_tipos SET codigo=?, nombre=?, categoria=?, activo=? WHERE id=?' + ); + $stmt->execute([$codigo, $nombre, $categoria ?: null, $activo, $id]); + $examId = $id; + } else { + $stmt = $pdo->prepare( + 'INSERT INTO exam_tipos (codigo, nombre, categoria, activo) VALUES (?, ?, ?, ?)' + ); + $stmt->execute([$codigo, $nombre, $categoria ?: null, $activo]); + $examId = (int)$pdo->lastInsertId(); + } + + // Actualizar relación formulario ↔ examen + $pdo->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$examId]); + if ($formId) { + $pdo->prepare( + 'INSERT INTO exam_tipo_consentimientos (exam_tipo_id, formulario_id) VALUES (?, ?)' + )->execute([$examId, $formId]); + } + + $pdo->commit(); + jsonOk(['id' => $examId], $id ? 'Examen actualizado' : 'Examen creado'); +} catch (\Throwable $e) { + $pdo->rollBack(); + // Código SQLSTATE 23000 = violación de unicidad + if ($e->getCode() === '23000') { + jsonError('Ya existe un examen con ese código'); + } + jsonError('Error interno: ' . $e->getMessage(), 500); +} diff --git a/modules/turnero/api/save_lugar.php b/modules/turnero/api/save_lugar.php new file mode 100644 index 0000000..a4b1fbd --- /dev/null +++ b/modules/turnero/api/save_lugar.php @@ -0,0 +1,65 @@ +prepare('SELECT * FROM turnero_lugares WHERE id = ?'); + $row->execute([$id]); + $data = $row->fetch(PDO::FETCH_ASSOC); + if (!$data) jsonError('Lugar no encontrado', 404); + jsonOk(['data' => $data]); +} + +requireMethod('POST'); +$input = inputJson(); + +$id = (int)($input['id'] ?? 0); +$delete = !empty($input['_delete']); + +// ── ELIMINAR ───────────────────────────────────────────────── +if ($delete) { + if (!$id) jsonError('ID requerido'); + // Bloquear si tiene turnos asignados + $stmt = db()->prepare('SELECT COUNT(*) FROM turnero_turnos WHERE lugar_destino_id = ?'); + $stmt->execute([$id]); + if ($stmt->fetchColumn() > 0) { + jsonError('No se puede eliminar: tiene turnos asignados. Desactívelo en su lugar.'); + } + $stmt = db()->prepare('DELETE FROM turnero_lugares WHERE id = ?'); + $stmt->execute([$id]); + jsonOk([], 'Lugar eliminado'); +} + +// ── CREAR / ACTUALIZAR ─────────────────────────────────────── +$nombre = trim($input['nombre'] ?? ''); +$desc = trim($input['descripcion'] ?? ''); +$sortOrder = (int)($input['sort_order'] ?? 99); +$activo = (int)($input['activo'] ?? 1); + +if ($nombre === '') jsonError('El nombre es requerido'); +if ($sortOrder < 1) jsonError('El orden debe ser mayor a 0'); + +if ($id) { + $stmt = db()->prepare( + 'UPDATE turnero_lugares SET nombre=?, descripcion=?, sort_order=?, activo=? WHERE id=?' + ); + $stmt->execute([$nombre, $desc ?: null, $sortOrder, $activo, $id]); + jsonOk(['id' => $id], 'Lugar actualizado'); +} else { + $stmt = db()->prepare( + 'INSERT INTO turnero_lugares (nombre, descripcion, sort_order, activo) VALUES (?, ?, ?, ?)' + ); + $stmt->execute([$nombre, $desc ?: null, $sortOrder, $activo]); + jsonOk(['id' => (int)db()->lastInsertId()], 'Lugar creado'); +} diff --git a/modules/turnero/api/save_prioridad.php b/modules/turnero/api/save_prioridad.php new file mode 100644 index 0000000..64d97b2 --- /dev/null +++ b/modules/turnero/api/save_prioridad.php @@ -0,0 +1,34 @@ +prepare( + 'UPDATE turnero_prioridades SET nombre=?, color=?, orden_peso=?, activo=? WHERE id=?' +); +$stmt->execute([$nombre, $color, $ordenPeso, $activo, $id]); + +if ($stmt->rowCount() === 0) { + jsonError('Prioridad no encontrada', 404); +} + +jsonOk(['id' => $id], 'Prioridad actualizada'); diff --git a/modules/turnero/api/send_consentimiento.php b/modules/turnero/api/send_consentimiento.php new file mode 100644 index 0000000..a0ddbd9 --- /dev/null +++ b/modules/turnero/api/send_consentimiento.php @@ -0,0 +1,236 @@ +prepare( + "SELECT t.id, t.codigo, t.paciente_nombre, t.paciente_cel, + t.estado, + s.id AS solicitud_id, + s.paciente_id, + p.telefono AS pac_telefono, + p.celular AS pac_celular, + p.full_name AS pac_nombre + FROM turnero_turnos t + LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id + LEFT JOIN lab_pacientes p ON p.id = s.paciente_id + WHERE t.id = ?" +); +$stmt->execute([$turnoId]); +$turno = $stmt->fetch(PDO::FETCH_ASSOC); + +if (!$turno) jsonError('Turno no encontrado.', 404); +if (!$turno['solicitud_id']) jsonError('El turno no tiene solicitud registrada. Guarde la solicitud primero.', 422); + +// Determinar celular de contacto (preferencia: paciente → kiosko) +$celular = $turno['pac_celular'] ?? $turno['pac_telefono'] ?? $turno['paciente_cel'] ?? null; +if (!$celular) { + jsonError('El paciente no tiene número de celular registrado. Actualice el paciente antes de enviar.', 422); +} + +// Normalizar celular (quitar espacios / guiones, agregar +57 si no tiene código) +$celular = preg_replace('/[\s\-\.]/', '', $celular); +if (!str_starts_with($celular, '+')) { + // Asumir Colombia si no tiene prefijo internacional + $celular = '+57' . ltrim($celular, '0'); +} + +// ── 2. Obtener exámenes de la solicitud ─────────────────────── +$stmt = $pdo->prepare( + "SELECT exam_tipo_id FROM turnero_examen_items WHERE solicitud_id = ?" +); +$stmt->execute([$turno['solicitud_id']]); +$examIds = $stmt->fetchAll(PDO::FETCH_COLUMN); + +if (empty($examIds)) { + jsonError('La solicitud no tiene exámenes registrados.', 422); +} + +// ── 3. Obtener formularios de consentimiento (deduplicados) ─── +$in = implode(',', array_fill(0, count($examIds), '?')); +$stmt = $pdo->prepare( + "SELECT DISTINCT etc.formulario_id, f.nombre AS formulario_nombre + FROM exam_tipo_consentimientos etc + JOIN lab_formularios f ON f.id = etc.formulario_id + WHERE etc.exam_tipo_id IN ({$in})" +); +$stmt->execute($examIds); +$formulariosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC); + +if (empty($formulariosRequeridos)) { + jsonOk(['consentimientos' => [], 'mensaje' => 'Ningún examen requiere consentimiento.']); +} + +// ── 4. Generar / recuperar tokens UUID ──────────────────────── +function generarUuid(): string +{ + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); +} + +$pdo->beginTransaction(); +$consentimientosResultado = []; +$erroresEnvio = []; + +try { + $stmtBuscar = $pdo->prepare( + "SELECT id, token, estado FROM turnero_consentimientos + WHERE turno_id = ? AND formulario_id = ?" + ); + $stmtInsertar = $pdo->prepare( + "INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado) + VALUES (?, ?, ?, 'pendiente')" + ); + $stmtActualizar = $pdo->prepare( + "UPDATE turnero_consentimientos + SET estado = 'enviado', enviado_at = NOW() + WHERE id = ?" + ); + + $wa = new WhatsAppService(); + // URL base del sistema (para generar el enlace de firma) + $baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http') + . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost'); + // Si BASE_URL está definida, úsala + if (defined('BASE_URL')) { + $baseUrl = rtrim(BASE_URL, '/'); + } + + foreach ($formulariosRequeridos as $form) { + $formularioId = (int) $form['formulario_id']; + $formularioNom = $form['formulario_nombre']; + + // Buscar si ya existe + $stmtBuscar->execute([$turnoId, $formularioId]); + $existente = $stmtBuscar->fetch(PDO::FETCH_ASSOC); + + if ($existente) { + $consentId = (int) $existente['id']; + $token = $existente['token']; + // Si ya está firmado/rechazado, no reenviar + if (in_array($existente['estado'], ['firmado', 'rechazado'], true)) { + $consentimientosResultado[] = [ + 'id' => $consentId, + 'formulario_id' => $formularioId, + 'formulario_nombre'=> $formularioNom, + 'estado' => $existente['estado'], + 'enviado' => false, + ]; + continue; + } + } else { + $token = generarUuid(); + $stmtInsertar->execute([$turnoId, $formularioId, $token]); + $consentId = (int) $pdo->lastInsertId(); + } + + // ── Construir enlace de firma ────────────────────────── + $enlaceFirma = $baseUrl . '/ver_formulario_enviado.php?token=' . urlencode($token); + + // ── Nombre del paciente para el mensaje ─────────────── + $nombrePac = $turno['pac_nombre'] ?? $turno['paciente_nombre'] ?? 'Paciente'; + + // ── Enviar mensaje WhatsApp ──────────────────────────── + // Intentamos enviar con template "consentimiento_turno". + // Si el template no existe, enviamos texto plano como fallback. + $enviado = false; + try { + $wa->sendTemplateMessage( + $celular, + 'consentimiento_turno', + 'es', + // Parámetros del body: {{1}} = nombre, {{2}} = nombre formulario, {{3}} = código turno + [ + htmlspecialchars($nombrePac, ENT_QUOTES), + htmlspecialchars($formularioNom, ENT_QUOTES), + $turno['codigo'], + ], + // Parámetro del header o botón URL (URL del enlace) + [$enlaceFirma] + ); + $enviado = true; + } catch (\Throwable $eTemplate) { + // Fallback: enviar mensaje de texto plano + try { + $mensajeTexto = "Hola {$nombrePac}, le informamos que para su turno *{$turno['codigo']}* " + . "debe firmar el siguiente consentimiento informado:\n\n" + . "*{$formularioNom}*\n\n" + . "Puede firmarlo en el siguiente enlace:\n{$enlaceFirma}\n\n" + . "Si ya firmó este documento presencial, ignore este mensaje."; + $wa->sendTextMessage($celular, $mensajeTexto); + $enviado = true; + } catch (\Throwable $eTexto) { + $erroresEnvio[] = "Formulario '{$formularioNom}': " . $eTexto->getMessage(); + } + } + + if ($enviado) { + $stmtActualizar->execute([$consentId]); + $estado = 'enviado'; + } else { + $estado = 'pendiente'; + } + + $consentimientosResultado[] = [ + 'id' => $consentId, + 'formulario_id' => $formularioId, + 'formulario_nombre' => $formularioNom, + 'token' => $token, + 'estado' => $estado, + 'enviado' => $enviado, + 'enlace_firma' => $enlaceFirma, + ]; + } + + $pdo->commit(); + + $msgExtra = !empty($erroresEnvio) + ? ' (Advertencias: ' . implode('; ', $erroresEnvio) . ')' + : ''; + + jsonOk( + ['consentimientos' => $consentimientosResultado], + count(array_filter($consentimientosResultado, fn($c) => $c['enviado'])) + . ' consentimiento(s) enviado(s) por WhatsApp' . $msgExtra + ); + +} catch (\Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + jsonError('Error al procesar consentimientos: ' . $e->getMessage(), 500); +} diff --git a/modules/turnero/api/sesion_turno.php b/modules/turnero/api/sesion_turno.php new file mode 100644 index 0000000..688a6b4 --- /dev/null +++ b/modules/turnero/api/sesion_turno.php @@ -0,0 +1,103 @@ +prepare('SELECT id, fin_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1'); + $stmt->execute([$hoy]); + $existing = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($existing) { + if ($existing['fin_at'] === null) { + jsonError('Ya hay una sesión abierta para hoy'); + } + // Re-abrir (fue cerrada) + $pdo->prepare('UPDATE turnero_sesiones SET fin_at=NULL, cerrado_por=NULL WHERE id=?') + ->execute([$existing['id']]); + jsonOk(['sesion_id' => $existing['id']], 'Sesión del día reabierta'); + } + + $stmt = $pdo->prepare( + 'INSERT INTO turnero_sesiones (fecha, abierto_por, inicio_at) VALUES (?, ?, NOW())' + ); + $stmt->execute([$hoy, $uid]); + jsonOk(['sesion_id' => (int)$pdo->lastInsertId()], 'Sesión del día abierta'); +} + +// ── CERRAR sesión ──────────────────────────────────────────── +if ($accion === 'cerrar') { + $sesionId = (int)($input['sesion_id'] ?? 0); + if (!$sesionId) jsonError('sesion_id requerido'); + + $stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE id = ?'); + $stmt->execute([$sesionId]); + $s = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$s) jsonError('Sesión no encontrada', 404); + if ($s['fin_at']) jsonError('La sesión ya está cerrada'); + + $pdo->prepare('UPDATE turnero_sesiones SET fin_at=NOW(), cerrado_por=? WHERE id=?') + ->execute([$uid, $sesionId]); + jsonOk(['sesion_id' => $sesionId], 'Sesión cerrada correctamente'); +} + +// ── REABRIR sesión ─────────────────────────────────────────── +if ($accion === 'reabrir') { + $sesionId = (int)($input['sesion_id'] ?? 0); + if (!$sesionId) jsonError('sesion_id requerido'); + + $stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE id = ?'); + $stmt->execute([$sesionId]); + $s = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$s) jsonError('Sesión no encontrada', 404); + + $pdo->prepare('UPDATE turnero_sesiones SET fin_at=NULL, cerrado_por=NULL WHERE id=?') + ->execute([$sesionId]); + jsonOk(['sesion_id' => $sesionId], 'Sesión reabierta'); +} + +// ── CONFIG WHATSAPP ────────────────────────────────────────── +if ($accion === 'config_wa') { + $template = trim($input['template'] ?? ''); + $lang = trim($input['lang'] ?? 'es_CO'); + + if ($template === '') jsonError('El nombre de plantilla es requerido'); + if (!preg_match('/^[a-z0-9_]{1,64}$/i', $template)) { + jsonError('Nombre de plantilla inválido — solo letras, números y guiones bajos'); + } + if (!preg_match('/^[a-z]{2}_[A-Z]{2}$/i', $lang)) { + jsonError('Código de idioma inválido (formato: es_CO)'); + } + + // Upsert en lab_config + $upsert = $pdo->prepare( + 'INSERT INTO lab_config (clave, valor) VALUES (?, ?) + ON DUPLICATE KEY UPDATE valor = VALUES(valor)' + ); + $upsert->execute(['turnero_wa_template', $template]); + $upsert->execute(['turnero_wa_lang', $lang]); + + jsonOk([], 'Configuración WhatsApp guardada'); +} diff --git a/modules/turnero/api/sse_turno.php b/modules/turnero/api/sse_turno.php new file mode 100644 index 0000000..ab636db --- /dev/null +++ b/modules/turnero/api/sse_turno.php @@ -0,0 +1,153 @@ +exec( + "ALTER TABLE turnero_sesiones + ADD COLUMN IF NOT EXISTS sse_ping_at DATETIME NULL DEFAULT NULL" +); + +// ── Headers SSE ─────────────────────────────────────────────── +@set_time_limit(0); +@ini_set('output_buffering', 'off'); +@ini_set('zlib.output_compression', false); + +header('Content-Type: text/event-stream; charset=UTF-8'); +header('Cache-Control: no-store, no-cache'); +header('X-Accel-Buffering: no'); // Nginx: deshabilitar buffering +header('Connection: keep-alive'); + +// ── Parámetros ──────────────────────────────────────────────── +$area = isset($_GET['area']) ? trim($_GET['area']) : 'recepcion'; +$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null; + +if (!in_array($area, ['recepcion', 'lugar'], true)) { + $area = 'recepcion'; +} + +// ── Helpers de emisión ──────────────────────────────────────── +function sseEvent(string $event, mixed $payload): void +{ + echo "event: {$event}\n"; + echo 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n"; + flush(); +} + +function ssePing(): void +{ + echo ": ping\n\n"; + flush(); +} + +// ── Función que arma el snapshot de la cola ─────────────────── +function buildSnapshot(PDO $pdo, int $sesionId, string $area, ?int $lugarId): array +{ + if ($area === 'recepcion') { + $estadosCola = ["'espera'", "'en_recepcion'"]; + $filtroLugar = ''; + $bindsCola = [$sesionId]; + $estadoActivo = "'en_recepcion'"; + $campoLlamado = 'llamado_recepcion_at'; + $bindActivo = [$sesionId]; + $filtroActivo = ''; + } else { + $estadosCola = ["'en_espera_lugar'", "'en_servicio'"]; + $filtroLugar = 'AND t.lugar_destino_id = ?'; + $bindsCola = [$sesionId, $lugarId]; + $estadoActivo = "'en_servicio'"; + $campoLlamado = 'llamado_lugar_at'; + $bindActivo = [$sesionId, $lugarId]; + $filtroActivo = 'AND t.lugar_destino_id = ?'; + } + + $inEstados = implode(', ', $estadosCola); + + $stmt = $pdo->prepare( + "SELECT t.id, t.codigo, t.numero, t.estado, t.paciente_nombre, t.creado_at, + p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, + p.color AS prioridad_color, p.orden_peso + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + WHERE t.sesion_id = ? + AND t.estado IN ({$inEstados}) + {$filtroLugar} + ORDER BY p.orden_peso ASC, t.creado_at ASC" + ); + $stmt->execute($bindsCola); + $cola = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $stmt = $pdo->prepare( + "SELECT t.id, t.codigo, t.numero, t.estado, t.paciente_nombre, + t.{$campoLlamado} AS llamado_at, + p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, + p.color AS prioridad_color + FROM turnero_turnos t + JOIN turnero_prioridades p ON p.id = t.prioridad_id + WHERE t.sesion_id = ? + AND t.estado = {$estadoActivo} + {$filtroActivo} + ORDER BY t.{$campoLlamado} DESC + LIMIT 1" + ); + $stmt->execute($bindActivo); + $activo = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + + return [ + 'activo' => $activo, + 'cola' => $cola, + 'timestamp' => date('c'), + ]; +} + +// ── Loop principal ──────────────────────────────────────────── +$sesionId = obtenerOCrearSesionHoy(); +$lastPing = null; // sse_ping_at de la última iteración +$lastKeepalive = time(); +$lastEmit = 0; + +// Emitir estado inicial inmediatamente +$snapshot = buildSnapshot($pdo, $sesionId, $area, $lugarId); +sseEvent('cola_update', $snapshot); + +while (!connection_aborted()) { + sleep(2); + + // Leer el último sse_ping_at de la sesión + $stmt = $pdo->prepare('SELECT sse_ping_at FROM turnero_sesiones WHERE id = ?'); + $stmt->execute([$sesionId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + $pingActual = $row['sse_ping_at'] ?? null; + + // Emitir si hubo cambio (notificarSSE actualiza sse_ping_at) o cada 30 s + $ahora = time(); + $forzar = ($ahora - $lastEmit) >= 30; + + if ($pingActual !== $lastPing || $forzar) { + $snapshot = buildSnapshot($pdo, $sesionId, $area, $lugarId); + sseEvent('cola_update', $snapshot); + $lastPing = $pingActual; + $lastEmit = $ahora; + } + + // Keep-alive cada 15 s + if (($ahora - $lastKeepalive) >= 15) { + ssePing(); + $lastKeepalive = $ahora; + } +} diff --git a/modules/turnero/views/configuracion.php b/modules/turnero/views/configuracion.php new file mode 100644 index 0000000..ba0b4f9 --- /dev/null +++ b/modules/turnero/views/configuracion.php @@ -0,0 +1,857 @@ +getConnection(); + +// ── Cargar datos para los tabs ──────────────────────────────── +$lugares = $pdo->query( + 'SELECT * FROM turnero_lugares ORDER BY sort_order ASC, id ASC' +)->fetchAll(PDO::FETCH_ASSOC); + +$prioridades = $pdo->query( + 'SELECT * FROM turnero_prioridades ORDER BY orden_peso ASC' +)->fetchAll(PDO::FETCH_ASSOC); + +$examTipos = $pdo->query( + 'SELECT et.*, GROUP_CONCAT(etc.formulario_id) AS form_ids + FROM exam_tipos et + LEFT JOIN exam_tipo_consentimientos etc ON etc.exam_tipo_id = et.id + GROUP BY et.id + ORDER BY et.categoria ASC, et.nombre ASC' +)->fetchAll(PDO::FETCH_ASSOC); + +$formulariosCons = $pdo->query( + "SELECT id, nombre FROM lab_formularios WHERE tipo='consentimiento' AND activo=1 ORDER BY nombre ASC" +)->fetchAll(PDO::FETCH_ASSOC); + +// Historial de sesiones (últimas 30) +$sesiones = $pdo->query( + 'SELECT s.*, + a1.full_name AS abierto_nombre, + a2.full_name AS cerrado_nombre + FROM turnero_sesiones s + LEFT JOIN admin_users a1 ON a1.id = s.abierto_por + LEFT JOIN admin_users a2 ON a2.id = s.cerrado_por + ORDER BY s.fecha DESC + LIMIT 30' +)->fetchAll(PDO::FETCH_ASSOC); + +// Sesión abierta hoy +$sesionHoy = $pdo->prepare( + "SELECT s.*, + (SELECT COUNT(*) FROM turnero_turnos WHERE sesion_id = s.id) AS total_turnos + FROM turnero_sesiones s + WHERE s.fecha = CURDATE()" +); +$sesionHoy->execute(); +$sesionHoy = $sesionHoy->fetch(PDO::FETCH_ASSOC); + +// Config WhatsApp del turnero +$cfgRows = $pdo->query("SELECT clave, valor FROM lab_config WHERE clave LIKE 'turnero_%'")->fetchAll(PDO::FETCH_ASSOC); +$cfg = []; +foreach ($cfgRows as $r) { $cfg[$r['clave']] = $r['valor']; } + +$tab = $_GET['tab'] ?? 'lugares'; +?> + + + + + + Configuración Turnero + + + + + + + + +
+ + +
+ + + +

Estaciones de servicio configurables

+ +
+ +
+ + + + + + + + + + + + + +
+ + +

No hay lugares configurados

+ +
+ +
+

Agregar lugar

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + + +
+

URLs de pantalla TV por lugar

+ +
+ +
+
+ +
+
+ +
+ Recepción (pantalla general) +
+
+ +
+
+
+ Kiosko de turnos (tablet/pantalla entrada) +
+
+ +
+
+
+ + + +

Tipos de examen y sus formularios de consentimiento

+ + $items): ?> +
+
+ +
+ +
+ + + + + + Inactivo + + + + + + +
+ +
+ + +

No hay tipos de examen configurados

+ + +
+

Agregar tipo de examen

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+

Vista inversa: por consentimiento

+ +
+ + + Sin exámenes vinculados' + : implode(', ', array_map(fn($e)=>''.$e['codigo'].'', $exsVinculados)) + ?> + +
+ +
+ + + + + + + +

Códigos de prioridad (menor orden_peso = mayor prioridad)

+ +
+ +
+ + + + Orden: + + + + +
+ +
+

+ + Los códigos de prioridad no se pueden eliminar (están vinculados a turnos). Solo puede editarlos. +

+ + + + + + + + +
+

Sesión de hoy

+ +
+
+ + + +
+
+ Fecha: • + Turnos: + + • Inicio: + + + • Cierre: + +
+ + + + + +
+ +

No hay sesión creada para hoy.

+ + +
+ + +
+

Historial de sesiones (últimas 30)

+ +

Sin historial

+ +
+ + + + + + + + + + + + + + + + + + + + + +
FechaInicioCierreAbierto porEstado
+ + + +
+
+ +
+ + +
+

Plantilla de consentimiento WhatsApp

+
+
+ + + Nombre exacto en Meta Business Manager +
+
+ + +
+
+ +
+
+
+ + La plantilla debe estar aprobada en Meta y contener una variable de URL + ({{1}}) para el enlace de firma del consentimiento. + El sistema usará texto plano como fallback si la plantilla falla. +
+
+ + +
+
+ + +
+ + + + + diff --git a/modules/turnero/views/dashboard.php b/modules/turnero/views/dashboard.php new file mode 100644 index 0000000..67b707d --- /dev/null +++ b/modules/turnero/views/dashboard.php @@ -0,0 +1,374 @@ + + + + + + + Dashboard Turnero + + + + + + + + +
+ +
+ + +
+
+
Cargando...
+
+
+ + + + + + + + + + + +
+
+
Detalle de turnos
+ +
+
+ + + + + + + + + + + + + + + + +
CódigoPrioridadPacienteLugarEstadoEsperaServicioEntrada
Cargando...
+
+
+
+ + + + + diff --git a/modules/turnero/views/display.php b/modules/turnero/views/display.php new file mode 100644 index 0000000..7d3f0c3 --- /dev/null +++ b/modules/turnero/views/display.php @@ -0,0 +1,425 @@ + + + + + + Pantalla de Turnos + + + + + + +
+ + +
+
+ + Sistema de Turnos +
+ + Conectando… + +
--:--:--
+
+ + +
+ + +
+
+
Turno en atención
+
+
+
+ Esperando turno +
+
+ + +
+
+ EN ESPERA +
+
+
+ + Cola vacía +
+
+
+ +
+ + +
+
+
+
En espera
+
+
+
+
Atendidos hoy
+
+
+
+
Total del día
+
+
+
+
Tiempo prom.
+
+
+
+ + + + diff --git a/modules/turnero/views/kiosko.php b/modules/turnero/views/kiosko.php new file mode 100644 index 0000000..c7666d5 --- /dev/null +++ b/modules/turnero/views/kiosko.php @@ -0,0 +1,394 @@ +getConnection(); + $stmt = $pdo->query( + "SELECT id, codigo, nombre, color, icono, descripcion, orden_peso + FROM turnero_prioridades + WHERE activo = 1 + ORDER BY orden_peso ASC" + ); + $prioridades = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (\Throwable $e) { + // Fallback con prioridades por defecto si la BD no está disponible + $prioridades = [ + ['codigo'=>'A','nombre'=>'Niños', 'color'=>'#ef4444','icono'=>'fas fa-child', 'descripcion'=>'Pacientes menores de edad'], + ['codigo'=>'B','nombre'=>'Embarazadas', 'color'=>'#f97316','icono'=>'fas fa-heart', 'descripcion'=>'Mujeres en estado de embarazo'], + ['codigo'=>'C','nombre'=>'Adulto mayor', 'color'=>'#eab308','icono'=>'fas fa-person-cane', 'descripcion'=>'Mayores de 60 años'], + ['codigo'=>'D','nombre'=>'Discapacidad', 'color'=>'#8b5cf6','icono'=>'fas fa-wheelchair', 'descripcion'=>'Personas con discapacidad'], + ['codigo'=>'E','nombre'=>'Paciente general', 'color'=>'#3b82f6','icono'=>'fas fa-user', 'descripcion'=>'Atención general'], + ['codigo'=>'F','nombre'=>'Muestra pendiente', 'color'=>'#6b7280','icono'=>'fas fa-vial', 'descripcion'=>'Entrega de muestra tomada'], + ]; +} + +// Iconos por defecto por código (si la BD no tiene el campo icono populado) +$iconosDefecto = [ + 'A' => 'fas fa-child', + 'B' => 'fas fa-heart', + 'C' => 'fas fa-person-cane', + 'D' => 'fas fa-wheelchair', + 'E' => 'fas fa-user', + 'F' => 'fas fa-vial', +]; +foreach ($prioridades as &$p) { + if (empty($p['icono'])) { + $p['icono'] = $iconosDefecto[$p['codigo']] ?? 'fas fa-ticket-alt'; + } +} +unset($p); +?> + + + + + + Kiosko de Turnos + + + + + + + +
+
+ +

Solicitar Turno

+

Seleccione su tipo de atención

+
+ +
+ + + +
+
+ + + + + + + + +
+
+
+ + +
+ + + + diff --git a/modules/turnero/views/lugar.php b/modules/turnero/views/lugar.php new file mode 100644 index 0000000..d5b799a --- /dev/null +++ b/modules/turnero/views/lugar.php @@ -0,0 +1,760 @@ +getConnection(); + $lugares = $pdo->query( + "SELECT id, nombre, descripcion FROM turnero_lugares WHERE activo = 1 ORDER BY sort_order ASC" + )->fetchAll(PDO::FETCH_ASSOC); +} catch (\Throwable) { + $lugares = []; +} + +$lugarIdParam = isset($_GET['lugar_id']) ? (int)$_GET['lugar_id'] : 0; + +// Resolver nombre del lugar para el título +$lugarNombre = 'Estación de Servicio'; +foreach ($lugares as $l) { + if ((int)$l['id'] === $lugarIdParam) { + $lugarNombre = $l['nombre']; + break; + } +} + +$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Operador'; +?> + + + + + + <?= htmlspecialchars($lugarNombre) ?> — Turnero + + + + + + + + + + +
> +
+ +

Seleccione su estación

+ + +
+
+ +
+ + +
+
+ + + +
+
+ + + +
+
+ +
+ + +
+
+
+ + EN ESPERA + + 0 +
+
+
+
+ + Cola vacía +
+
+
+ + +
+ +
+ +

Sin turno activo

+ Pulse "Llamar siguiente" o espere a que llegue el turno +
+ +
+ + +
+
+
+
+
+
+ en espera +
+ + +
+
Paciente
+
+
Nombre
+
Documento
+
Fecha nac.
+
Celular
+
+
+ Sin paciente vinculado +
+
+ + +
+
Exámenes solicitados
+
+ Sin exámenes registrados +
+
+ + + + + +
+ + + + +
+ +
+
+ +
+
+ + + + + + + diff --git a/modules/turnero/views/recepcion.php b/modules/turnero/views/recepcion.php new file mode 100644 index 0000000..a73c578 --- /dev/null +++ b/modules/turnero/views/recepcion.php @@ -0,0 +1,706 @@ +getConnection(); + + $lugares = $pdo->query( + "SELECT id, nombre FROM turnero_lugares WHERE activo = 1 ORDER BY sort_order ASC" + )->fetchAll(PDO::FETCH_ASSOC); + + $examenes = $pdo->query( + "SELECT id, codigo, nombre, categoria FROM exam_tipos WHERE activo = 1 ORDER BY categoria, nombre" + )->fetchAll(PDO::FETCH_ASSOC); + + // Agrupar exámenes por categoría + $examenesAgrupados = []; + foreach ($examenes as $ex) { + $cat = $ex['categoria'] ?: 'General'; + $examenesAgrupados[$cat][] = $ex; + } +} catch (\Throwable $e) { + $lugares = []; + $examenesAgrupados = []; +} + +$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Operador'; +?> + + + + + + Recepción — Turnero + + + + + + + + +
+ + +
+
+ + Recepción + + Turno: + +
+
+ + +
+
+ +
+ + +
+
+
+ + EN ESPERA + + 0 +
+
+
+
+ + Cola vacía +
+
+
+ + +
+
+ +

Sin turno activo

+ Haga clic en "Llamar siguiente" o seleccione un turno de la cola +
+ + +
+ + +
+
+ — +
+
+
+
+
+
+ + +
+
Paciente
+ + +
+ Nombre del kiosko: +
+
+ +
+
+ + +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ + +
+
Lugar destino
+ +
+ + +
+
Exámenes solicitados
+
+ $items): ?> +
+ + + + +
+
+ +
+
+ + +
+
Cobro (opcional)
+
+
+ +
+
+ +
+
+ +
+ + +
+
Consentimientos
+
+ +
+ + +
+ + + + +
+
+
+ +
+
+ + + + diff --git a/run_005_registro_exams.php b/run_005_registro_exams.php new file mode 100644 index 0000000..5f03217 --- /dev/null +++ b/run_005_registro_exams.php @@ -0,0 +1,53 @@ + $s !== '' && !preg_match('/^\s*--/', $s) +); + +$pdo = Database::getInstance()->getConnection(); +$ok = 0; +$err = 0; + +foreach ($statements as $stmt) { + $stmt = trim($stmt); + if ($stmt === '') { + continue; + } + try { + $pdo->exec($stmt); + echo "OK: " . substr(preg_replace('/\s+/', ' ', $stmt), 0, 80) . "\n"; + $ok++; + } catch (PDOException $e) { + $msg = $e->getMessage(); + // Ignorar errores de "ya existe" (idempotente) + if (str_contains($msg, 'already exists') || str_contains($msg, 'Duplicate entry')) { + echo "SKIP (ya existe): " . substr($stmt, 0, 60) . "\n"; + } else { + echo "ERROR: {$msg}\n >> " . substr($stmt, 0, 100) . "\n"; + $err++; + } + } +} + +echo "\n─────────────────────────────────\n"; +echo "Migración 005 completada. OK={$ok} ERRORES={$err}\n"; diff --git a/ver_formulario_enviado.php b/ver_formulario_enviado.php index a4c2bca..5d4c8b8 100644 --- a/ver_formulario_enviado.php +++ b/ver_formulario_enviado.php @@ -1,13 +1,22 @@ fetch( + "SELECT tc.id, tc.turno_id, tc.formulario_id, tc.token, tc.estado, + tc.enviado_at, tc.firmado_at, tc.ip_firma, tc.ua_firma, tc.firma_svg, + f.nombre AS form_nombre, f.categoria, f.descripcion AS form_descripcion, + f.esquema, f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina, + p.nombre_completo AS paciente_nombre, p.numero_documento, p.tipo_documento, + p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps, + t.sesion_id + FROM turnero_consentimientos tc + JOIN lab_formularios f ON f.id = tc.formulario_id + JOIN turnero_turnos t ON t.id = tc.turno_id + JOIN turnero_solicitudes ts ON ts.turno_id = tc.turno_id + LEFT JOIN lab_pacientes p ON p.id = ts.paciente_id + WHERE tc.token = ?", + [$tokenTurnero] + ); + if (!$tcRow) { http_response_code(404); die('Consentimiento no encontrado'); } + + // ── POST: guardar firma y marcar como firmado ────────────────────── + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + header('Content-Type: application/json; charset=utf-8'); + if ($tcRow['estado'] === 'firmado') { + echo json_encode(['ok' => false, 'error' => 'Este consentimiento ya fue firmado']); exit; + } + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $firmaSvg = trim($input['firma_svg'] ?? ''); + if (strlen($firmaSvg) < 100) { + echo json_encode(['ok' => false, 'error' => 'Firma requerida']); exit; + } + $ip = filter_var( + $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '', + FILTER_VALIDATE_IP + ) ?: ($_SERVER['REMOTE_ADDR'] ?? ''); + $ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 500); + $pdo = $db->getConnection(); + $stmt = $pdo->prepare( + "UPDATE turnero_consentimientos + SET estado='firmado', firmado_at=NOW(), firma_svg=?, + ip_firma=?, ua_firma=?, version_formulario=? + WHERE token=?" + ); + $stmt->execute([$firmaSvg, $ip, $ua, (int)$tcRow['formulario_id'], $tokenTurnero]); + require_once __DIR__ . '/modules/turnero/api/_helpers.php'; + notificarSSE((int)$tcRow['sesion_id']); + echo json_encode(['ok' => true, 'message' => 'Consentimiento firmado correctamente']); exit; + } + + // ── GET: construir $envio compatible con la plantilla ───────────── + $envio = [ + 'id' => (int)$tcRow['id'], + 'estado' => $tcRow['estado'], + 'form_nombre' => $tcRow['form_nombre'], + 'form_descripcion' => $tcRow['form_descripcion'], + 'categoria' => $tcRow['categoria'], + 'esquema' => $tcRow['esquema'], + 'firma_svg' => $tcRow['firma_svg'], + 'doc_color' => $tcRow['doc_color'], + 'doc_encabezado' => $tcRow['doc_encabezado'], + 'doc_subtitulo' => $tcRow['doc_subtitulo'], + 'doc_logo_base64' => $tcRow['doc_logo_base64'], + 'doc_pie_pagina' => $tcRow['doc_pie_pagina'], + 'paciente_nombre' => $tcRow['paciente_nombre'], + 'numero_documento' => $tcRow['numero_documento'], + 'tipo_documento' => $tcRow['tipo_documento'], + 'fecha_nacimiento' => $tcRow['fecha_nacimiento'], + 'paciente_telefono' => $tcRow['paciente_telefono'], + 'eps' => $tcRow['eps'], + 'enviado_por_nombre'=> 'Sistema Turnero', + 'enviado_por_email' => null, + 'datos_cliente' => '{}', + 'datos_prefilled' => json_encode(['__paciente' => [ + 'nombre_completo' => $tcRow['paciente_nombre'] ?? '', + 'numero_documento' => $tcRow['numero_documento'] ?? '', + 'tipo_documento' => $tcRow['tipo_documento'] ?? '', + 'telefono' => $tcRow['paciente_telefono'] ?? '', + 'eps' => $tcRow['eps'] ?? '', + ]]), + 'created_at' => $tcRow['enviado_at'], + 'completado_en' => $tcRow['firmado_at'], + 'ip_cliente' => $tcRow['ip_firma'], + 'hash_verificacion' => null, + 'permite_firma' => 1, + 'requiere_firma' => 1, + ]; +} elseif ($modoPublico) { // Acceso por token: solo si el formulario ya fue firmado/completado if (!preg_match('/^[a-f0-9]{64}$/i', $tokenPublico)) { http_response_code(400); die('Token inválido'); @@ -217,15 +312,30 @@ function esc2(mixed $v): string {
- - Volver - - Documento firmado + + + + + +
+ + + + Volver
+ + Documento firmado +
+ +
@@ -466,6 +576,40 @@ function esc2(mixed $v): string {
+ + +
+
+ Firma del paciente / responsable +
+

+ He leído y comprendido el contenido de este documento. + Por favor dibuje su firma en el recuadro: +

+ + +
+ + +
+
+
+ +
+ +
+ Consentimiento firmado
+ Puede cerrar esta ventana. El personal de salud ha sido notificado. +
+
+ +