This commit is contained in:
Lizandro Guarnizo
2026-04-18 23:41:01 -05:00
parent 876e935a66
commit 318a1742cc
37 changed files with 8838 additions and 89 deletions
+575 -81
View File
@@ -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 <?php)
• ?display=recepcion → cola general
• ?display=lugar&lugar_id=X → cola del Lugar X
• SSE EventSource a sse_turno.php con reconexión exponencial
• Código activo gigante con color de prioridad + lista de espera
• Web Audio API para beep al llamar — sin archivos externos
• Stats footer: en espera / atendidos / total / tiempo promedio
✅ FASE 3.4 Puesto de Recepción (1 día) [COMPLETADA]
────────────────────────────────────────
✓ views/recepcion.php
• Requiere login + módulo turnero; layout 2 columnas: cola | ficha
• Cola izquierda: turnos 'espera' con color de prioridad, polling 8 s
• Botón "Llamar siguiente" → llama_turno.php area=recepcion
• Ficha derecha al llamar:
- Nombre del kiosko pre-cargado en buscador
- Buscador de paciente: GET /api/lab/get_pacientes.php?busqueda=
- Selector de lugar destino + checkboxes de exámenes por categoría
- Campo cobro + forma de pago + observaciones
- Sección consentimientos (aparece tras guardar si aplican)
- Botón "Pasar a lugar" → cambiar_estado en_espera_lugar
- Botón "Ausente"
✓ api/create_solicitud.php
• POST, requireTurnero(); validaciones completas
• DELETE + INSERT (permite re-guardar para correcciones)
• Inserta turnero_solicitudes + N turnero_examen_items en transacción
• Devuelve solicitud + consentimientos_requeridos (estado actual de c/u)
✓ api/send_consentimiento.php
• Deduplica formularios: N exámenes → M formularios distintos (M ≤ N)
• Genera UUID por cada formulario; INSERT IGNORE para no duplicar
• No reenvía consentimientos ya firmados o rechazados
• Intenta sendTemplateMessage('consentimiento_turno'); fallback texto plano
• Normaliza celular (+57 por defecto Colombia)
• Registra enviado_at; retorna lista con enlace_firma por c/u
• Retorna lista de consentimientos enviados
✅ FASE 3.5 Puesto Lugar / Estación de Servicio (1 día) [COMPLETADA]
────────────────────────────────────────────────────────
✓ views/lugar.php (?lugar_id=X)
• Genérica: funciona para cualquier lugar (Muestras, Rayos X, etc.)
• Overlay selector si no viene lugar_id en URL; botón "Cambiar lugar"
• Requiere login (isUserLoggedIn)
• Cola izquierda: turnos 'en_espera_lugar' del lugar, polling 7 s
• Botón "Llamar siguiente" → llamar_turno.php area=lugar
• Ficha derecha al llamar (carga vía get_consentimientos.php?incluir_solicitud=1):
- Datos del paciente (nombre, doc, fecha nac, celular)
- Exámenes como pills coloreadas
- Consentimientos con estado visual (firmado/enviado/visto/pendiente)
- BLOQUEO: "Iniciar atención" disabled + banner rojo si hay pendientes
- Botón "Reenviar WhatsApp" → send_consentimiento.php
- Botón "Firmar aquí" → abre ver_formulario_enviado.php en modal/iframe
- "Iniciar atención" → cambiar_estado en_servicio (validado en API)
- "Finalizar" → estado finalizado
- "Regresar a cola" → estado en_espera_lugar
- "Ausente" → estado ausente
• Polling de consentimientos cada 5 s mientras hay turno activo
✓ api/get_consentimientos.php
• GET público dentro del módulo (ruta protegida por la vista)
• ?turno_id=X → retorna arreglo de consentimientos con nombre del formulario
• ?incluir_solicitud=1 → también retorna solicitud + paciente + exámenes
FASE 3.6 Firma digital (reutilizar formularios) (0.5 días) ✅ COMPLETADA
──────────────────────────────────────────────────────────────
✅ Marcar formularios de consentimiento en la interfaz de admin:
lab_formularios → campo tipo = 'consentimiento'
✅ Al firmar ver_formulario_enviado.php:
• Detectar si el token corresponde a un turno (turnero_consentimientos)
• Actualizar turnero_consentimientos.estado = 'firmado'
• Registrar ip_firma, ua_firma, firmado_at, version_formulario
• Guardar firma_svg (base64 PNG) en turnero_consentimientos
• Notificar SSE → lugar.php/recepcion.php actualizan en tiempo real
✅ Vista lugar.php tiene botón "Firmar aquí" → abre el mismo
ver_formulario_enviado.php en un modal (iframe), con canvas de firma
para firma física en la tablet del puesto
✅ lugar.php detecta la firma en tiempo real (polling 5s) +
postMessage instantáneo desde el iframe al firmarse
FASE 3.7 Panel Admin Turnero + configuración (0.5 días) ✅ COMPLETADA
───────────────────────────────────────────────────────────
✅ views/configuracion.php — 4 pestañas:
TAB 1: Lugares ✅
• CRUD de turnero_lugares (nombre, descripción, activo, orden)
• Cada lugar muestra su URL de display → botón copia rápida
• URLs de recepción y kiosko también accesibles
• API: save_lugar.php (GET/POST crear/actualizar/eliminar con guardia FK)
TAB 2: Exámenes y Consentimientos ✅
• CRUD de exam_tipos (código, nombre, categoría, activo)
• Por cada exam_tipo: selector de consentimiento (formularios tipo='consentimiento')
→ Guarda/actualiza en exam_tipo_consentimientos (deduplicado)
• Vista inversa: por consentimiento → lista de exámenes vinculados
• API: save_ex_tipo.php (GET/POST; eliminación bloqueada si hay solicitudes)
TAB 3: Prioridades ✅
• Edición de nombre, color, orden, activo (sin eliminación)
• Picker de color sincronizado con campo hexadecimal
• API: save_prioridad.php (validación de formato #RRGGBB)
TAB 4: Sesión y WhatsApp ✅
• Abrir / cerrar / reabrir sesión del día con un clic
• Historial de sesiones (últimas 30)
• Configuración de plantilla WhatsApp (nombre + código idioma)
• API: sesion_turno.php (acciones: abrir, cerrar, reabrir, config_wa)
✅ views/dashboard.php (admin)
• Resumen del día: turnos atendidos, tiempo promedio, pendientes
• KPI cards + barras por prioridad + tarjetas por lugar
• Tabla detalle de todos los turnos con búsqueda local
• Consentimientos del día (desglose por estado)
• Selector de fecha (consulta histórica)
• Auto-refresh cada 30s (solo si fecha = hoy)
• API: get_dashboard.php (consultas agregadas por sesión)
• Exportar CSV: export_csv.php (BOM UTF-8, 24 columnas, listo para Excel)
FASE 4 Oleada 2 (Registro de Exámenes + futuros)
□ Migración 005: tablas exam_*
□ Catálogo de tipos de examen (CRUD admin)
□ Flujo de recepción: buscar paciente → crear orden → agregar ítems
□ Vista bacteriólogo: lista del día, cambiar estados
□ Impresión de etiquetas (PDF A6)
□ Conectar con lab_pacientes (reutilizar buscador existente)
FASE 4.1 Base de datos (migration 005) ✅ COMPLETADA
─────────────────────────────────────────────────────
✓ migrations/005_registro_exams.sql
ALTER exam_tipos → precio_base, requiere_ayuno, horas_ayuno, instrucciones
CREATE exam_ordenes (código EX-YYYYMMDD-NNNN, prioridad, estado, FK paciente)
CREATE exam_items (estado por ítem, notas_tecnico)
CREATE exam_muestras (trazabilidad: tipo, código_barras, estado)
CREATE exam_resultados (campo, valor, unidad, referencia, es_anormal)
UPDATE system_modules SET is_active=1 WHERE slug='registro_exams'
INSERT role_module_access para bacteriólogo y recepcionista
✓ run_005_registro_exams.php (runner idempotente)
FASE 4.2 Módulo descriptor y helpers ✅ COMPLETADA
─────────────────────────────────────────────────────
✓ modules/registro_exams/module.php
✓ modules/registro_exams/api/_helpers.php
requireExams(), generarCodigoOrden(), jsonOk(), jsonError(), db()
FASE 4.3 Lista del día ✅ COMPLETADA
─────────────────────────────────────────────────────
✓ modules/registro_exams/views/lista.php
KPI cards (total/pendientes/en_proceso/completas)
Filtros: fecha + estado + búsqueda libre
Auto-refresh 45 s; paginación
✓ modules/registro_exams/api/get_ordenes.php
GET ?fecha=&estado=&busqueda=&page=&limit=
Incluye edad calculada, contadores de ítems y muestras
FASE 4.4 Nueva orden ✅ COMPLETADA
─────────────────────────────────────────────────────
✓ modules/registro_exams/views/nueva_orden.php
Buscador de paciente (reutiliza GET /api/lab/get_pacientes.php)
Catálogo de exámenes en checkbox agrupados por categoría
Filtro de búsqueda en catálogo; badge de ayuno y precio
Pre-carga paciente si viene ?solicitud_id= del turnero
✓ modules/registro_exams/api/save_orden.php
POST crear/editar; genera código EX-YYYYMMDD-NNNN
Sync de ítems: inserta nuevos, elimina pendientes removidos
Redirige a orden creada al guardar
FASE 4.5 Detalle de orden + resultados ✅ COMPLETADA
─────────────────────────────────────────────────────
✓ modules/registro_exams/views/orden.php
Cards: paciente, datos médicos (prioridad + estado badges)
Tabla de muestras + modal "Registrar muestra" (tipo + barcode + estado)
Lista de ítems con estado individual + dropdown cambio de estado
Modal de resultados: tabla editable (campo/valor/unidad/referencia/anormal)
Botón "Marcar entregada"; auto-cierre de orden cuando todos listos
✓ modules/registro_exams/api/get_orden.php
GET ?id= → orden + ítems + resultados + muestras
✓ modules/registro_exams/api/save_resultado.php
POST reemplaza resultados de un ítem; auto-marca resultado_listo
Auto-cierra orden a 'completa' si todos los ítems tienen resultado
✓ modules/registro_exams/api/cambiar_estado_item.php
POST tipo=item|muestra|orden; crea muestras nuevas (tipo=muestra, id=null)
Al crear muestra: pasa ítems pendientes a muestra_tomada + orden a en_proceso
FASE 4.6 Etiqueta imprimible ✅ COMPLETADA
─────────────────────────────────────────────────────
✓ modules/registro_exams/views/etiqueta.php
Página autónoma A6 landscape (sin sidebar, sin Layout::open)
Código de barras visual (fuente Libre Barcode 128 via Google Fonts)
Datos: paciente, doc, edad, EPS, prioridad, lista de exámenes
Aviso de ayuno si algún ítem lo requiere
window.print() automático al cargar (delay 800 ms para fuente)
□ Facturación, Inventario, CRM, Portal Paciente…
□ API pública con tokens (para integraciones)
□ App móvil (portal enfermero actual → Progressive Web App)
@@ -519,28 +987,47 @@ API RESPONSE FORMAT (ya establecido, mantener):
────────────────────────────────────────────────────────────────────────────────
12. DECISIONES CLAVE A CONFIRMAR CON EL CLIENTE
12. DECISIONES CONFIRMADAS
────────────────────────────────────────────────────────────────────────────────
1. ¿El turnero es para UNA sede o múltiples sedes?
(Multi-sede requiere añadir sede_id a las tablas)
1. Sede única — No se requiere sede_id en las tablas.
2. ¿Facturación electrónica (DIAN) es obligatorio desde el inicio
o puede dejarse para Oleada 2?
2. Facturación electrónica DIAN — No se maneja en esta fase.
Queda pendiente para una oleada futura.
3. ¿El portal de entrega de resultados es para los pacientes directamente
(requiere login/token para pacientes) o solo descarga interna?
3. Portal de resultados para pacientes — No se requiere aún.
La entrega de resultados es solo gestión interna por ahora.
4. ¿Se quiere app móvil nativa o es suficiente con PWA/responsive?
4. App móvil / PWA — No se usará PWA.
Solo se aplica diseño responsive a: pantalla TV (display) y kiosko.
El resto del sistema funciona como web normal desde PC.
5. ¿Los exámenes tienen que integrar con algún analizador automático
(interfaz LIS) o el resultado se digita manual?
5. Multi-tenant — Instalación para un solo cliente.
Sin aislamiento multi-tenant en la BD.
6. ¿El turno se puede tomar ANTES de llegar (turno virtual por WhatsApp)?
Esto conecta el módulo Turnero con el Bot.
6. Consentimientos por examen — Se configura UNA VEZ en el panel admin.
Relación M:N: exam_tipos ↔ lab_formularios (exam_tipo_consentimientos).
El recepcionista no los marca manualmente; el sistema los calcula
automáticamente según los exámenes del turno.
7. ¿El sistema será multi-tenant (varios laboratorios/clientes en el mismo
servidor) o siempre una instalación por cliente?
────────────────────────────────────────────────────────────────────────────────
12B. DECISIONES PENDIENTES DE CONFIRMAR
────────────────────────────────────────────────────────────────────────────────
1. ¿Los exámenes integran con algún analizador automático (interfaz LIS)
o el resultado se digita manual?
(Impacta diseño de Oleada 2 — registro_exams)
2. ¿El turno se puede tomar ANTES de llegar (turno virtual por WhatsApp)?
(Conectaría el módulo Turnero con el Bot de WhatsApp)
3. ¿Cuántas ventanillas de Recepción y cuántas estaciones de servicio
(Toma de Muestras, Rayos X, etc.) se configurarán inicialmente?
(Ayuda a diseñar la pantalla TV y el layout de recepción)
4. ¿Debe bloquearse TOTALMENTE la atención si hay un consentimiento pendiente,
o solo mostrar advertencia y dejar que el auxiliar decida continuar?
────────────────────────────────────────────────────────────────────────────────
@@ -556,11 +1043,18 @@ API RESPONSE FORMAT (ya establecido, mantener):
✓ Módulos de laboratorio maduros
Lo que HAY QUE CONSTRUIR para el ERP:
→ Estructura de carpetas por módulo (core/ + modules/)
→ Router centralizado y sidebar dinámico
→ Estructura de carpetas por módulo (core/ + modules/) ✓ hecho
→ Router centralizado y sidebar dinámico ✓ hecho
→ RBAC con granularidad de acciones (can_view/create/edit/delete/export)
→ Módulo Turnero — Oleada 1 (3-5 días)
→ SYSTEM_MODULES en BD (en lugar de hardcoded en config.php)
→ Módulo Turnero — Oleada 1 (5-7 días)
FASE 3.1 BD y configuración (turnero_lugares + solicitudes + exam_tipos)
FASE 3.2 Motor de cola + API core (create_solicitud, llamar, SSE)
FASE 3.3 Pantallas sin login (kiosko + display TV por lugar)
FASE 3.4 Puesto de Recepción (solicitud + asignar lugar)
FASE 3.5 Puesto Lugar/Estación genérico (consentimientos + servicio)
FASE 3.6 Firma digital (reutilizar formularios, firmar en tablet)
FASE 3.7 Panel Admin + configuración (4 tabs)
→ SYSTEM_MODULES en BD (en lugar de hardcoded en config.php) ✓ hecho
→ Módulo Registro de Exámenes — Oleada 2 (5-7 días, pendiente)
Estrategia: EVOLUCIÓN INCREMENTAL, no reescritura.
+7
View File
@@ -252,3 +252,10 @@ INSERT IGNORE INTO exam_tipos (codigo, nombre, categoria) VALUES
('HEPB', 'Hepatitis B (HBsAg)', 'Serología'),
('RXP', 'Rayos X de pecho', 'Radiología'),
('ECG', 'Electrocardiograma', 'Cardiología');
-- ── 13. Columna firma_svg en turnero_consentimientos ─────────
-- Almacena el dibujo de firma del paciente (data URL PNG).
-- MEDIUMTEXT soporta firmas de alta resolución (hasta 16 MB).
ALTER TABLE turnero_consentimientos
ADD COLUMN IF NOT EXISTS firma_svg MEDIUMTEXT DEFAULT NULL
COMMENT 'Dibujo de firma del paciente (data URL PNG base64)';
+125
View File
@@ -0,0 +1,125 @@
-- =============================================================
-- Migración 005 — Registro de Exámenes (Oleada 2)
-- Crear: exec_ordenes, exam_items, exam_muestras, exam_resultados
-- Extender: exam_tipos (precio, ayuno, instrucciones)
-- Activar: módulo registro_exams en system_modules
-- =============================================================
-- -----------------------------------------------------------
-- 1. Extender exam_tipos con campos clínicos
-- -----------------------------------------------------------
ALTER TABLE exam_tipos
ADD COLUMN IF NOT EXISTS precio_base DECIMAL(10,2) DEFAULT NULL COMMENT 'Precio de venta al público',
ADD COLUMN IF NOT EXISTS requiere_ayuno TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = requiere ayuno',
ADD COLUMN IF NOT EXISTS horas_ayuno TINYINT(3) DEFAULT NULL COMMENT 'Horas mínimas de ayuno',
ADD COLUMN IF NOT EXISTS instrucciones TEXT DEFAULT NULL COMMENT 'Instrucciones de preparación para el paciente';
-- -----------------------------------------------------------
-- 2. Órdenes de examen (una orden por visita)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS exam_ordenes (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
paciente_id INT NOT NULL COMMENT 'FK lab_pacientes.id',
solicitud_id INT UNSIGNED DEFAULT NULL COMMENT 'FK turnero_solicitudes.id (opcional)',
orden_medica_id INT DEFAULT NULL COMMENT 'FK lab_ordenes_medicas.id (opcional)',
codigo VARCHAR(20) NOT NULL COMMENT 'Código legible EX-YYYYMMDD-NNNN',
medico_nombre VARCHAR(150) DEFAULT NULL,
medico_registro VARCHAR(50) DEFAULT NULL,
diagnostico TEXT DEFAULT NULL,
requiere_ayuno TINYINT(1) NOT NULL DEFAULT 0,
horas_ayuno TINYINT(3) DEFAULT NULL,
prioridad ENUM('normal','urgente','stat') NOT NULL DEFAULT 'normal',
estado ENUM('pendiente','en_proceso','completa','entregada','cancelada') NOT NULL DEFAULT 'pendiente',
notas TEXT DEFAULT NULL,
creado_por INT DEFAULT NULL COMMENT 'FK admin_users.id',
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_codigo (codigo),
KEY idx_paciente (paciente_id),
KEY idx_solicitud (solicitud_id),
KEY idx_estado (estado),
KEY idx_creado_at (creado_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------
-- 3. Ítems individuales de la orden (por tipo de examen)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS exam_items (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
orden_id INT UNSIGNED NOT NULL COMMENT 'FK exam_ordenes.id',
exam_tipo_id INT UNSIGNED NOT NULL COMMENT 'FK exam_tipos.id',
estado ENUM('pendiente','muestra_tomada','en_proceso','resultado_listo','entregado') NOT NULL DEFAULT 'pendiente',
notas_tecnico TEXT DEFAULT NULL,
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_orden (orden_id),
KEY idx_tipo (exam_tipo_id),
KEY idx_estado (estado),
CONSTRAINT fk_ei_orden FOREIGN KEY (orden_id) REFERENCES exam_ordenes (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------
-- 4. Trazabilidad de muestras
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS exam_muestras (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
orden_id INT UNSIGNED NOT NULL COMMENT 'FK exam_ordenes.id',
tipo_muestra ENUM(
'sangre_venosa','sangre_capilar',
'orina_24h','orina_espontanea',
'heces','esputo','hisopado','otro'
) NOT NULL DEFAULT 'sangre_venosa',
codigo_barras VARCHAR(50) DEFAULT NULL,
tomada_por INT DEFAULT NULL COMMENT 'FK admin_users.id',
tomada_at DATETIME DEFAULT NULL,
observaciones TEXT DEFAULT NULL,
estado ENUM('pendiente','tomada','procesando','procesada','rechazada') NOT NULL DEFAULT 'pendiente',
PRIMARY KEY (id),
KEY idx_orden (orden_id),
KEY idx_barras (codigo_barras),
CONSTRAINT fk_em_orden FOREIGN KEY (orden_id) REFERENCES exam_ordenes (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------
-- 5. Resultados por ítem (valor por campo analítico)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS exam_resultados (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
item_id INT UNSIGNED NOT NULL COMMENT 'FK exam_items.id',
campo_nombre VARCHAR(100) NOT NULL COMMENT 'Ej: Hemoglobina, Glucosa',
valor_texto TEXT DEFAULT NULL COMMENT 'Resultado en texto libre',
valor_numerico DECIMAL(12,4) DEFAULT NULL COMMENT 'Resultado numérico (si aplica)',
unidad VARCHAR(50) DEFAULT NULL COMMENT 'g/dL, mg/dL, etc.',
referencia_min DECIMAL(12,4) DEFAULT NULL,
referencia_max DECIMAL(12,4) DEFAULT NULL,
referencia_texto VARCHAR(400) DEFAULT NULL COMMENT 'Rango en texto para imprimir',
es_anormal TINYINT(1) NOT NULL DEFAULT 0,
ingresado_por INT DEFAULT NULL COMMENT 'FK admin_users.id',
ingresado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_item (item_id),
CONSTRAINT fk_er_item FOREIGN KEY (item_id) REFERENCES exam_items (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------
-- 6. Activar el módulo registro_exams
-- -----------------------------------------------------------
UPDATE system_modules
SET is_active = 1
WHERE slug = 'registro_exams';
-- -----------------------------------------------------------
-- 7. Permisos de roles para el módulo (bacteriólogo + recepcionista)
-- role_module_access: role_id, module_slug, can_view, can_edit
-- Solo se inserta si la tabla existe y el rol existe.
-- -----------------------------------------------------------
INSERT IGNORE INTO role_module_access (role_id, module_slug, can_view, can_edit)
SELECT r.id, 'registro_exams', 1, 1
FROM admin_roles r
WHERE r.nombre IN ('Bacteriólogo','Bacteriologo','bacteriologo','Técnico Lab','Lab');
INSERT IGNORE INTO role_module_access (role_id, module_slug, can_view, can_edit)
SELECT r.id, 'registro_exams', 1, 0
FROM admin_roles r
WHERE r.nombre IN ('Recepcionista','recepcionista','Recepción');
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* Helpers compartidos para los endpoints del módulo Registro de Exámenes.
* Incluido al inicio de cada archivo en modules/registro_exams/api/.
*/
ob_start();
require_once __DIR__ . '/../../../config/config.php';
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', '0');
ini_set('html_errors', '0');
register_shutdown_function(function () {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
ob_clean();
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Error interno: ' . $err['message']]);
}
});
header('Content-Type: application/json; charset=utf-8');
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' => '<span class="badge bg-warning text-dark">Urgente</span>',
'stat' => '<span class="badge bg-danger">STAT</span>',
default => '<span class="badge bg-secondary">Normal</span>',
};
}
function badgeEstadoOrden(string $e): string
{
return match ($e) {
'en_proceso' => '<span class="badge bg-primary">En proceso</span>',
'completa' => '<span class="badge bg-success">Completa</span>',
'entregada' => '<span class="badge bg-info text-dark">Entregada</span>',
'cancelada' => '<span class="badge bg-dark">Cancelada</span>',
default => '<span class="badge bg-light text-dark border">Pendiente</span>',
};
}
@@ -0,0 +1,143 @@
<?php
/**
* modules/registro_exams/api/cambiar_estado_item.php
* POST — cambia el estado de un ítem, muestra o de la orden entera.
*
* Body JSON:
* {
* "tipo": "item" | "muestra" | "orden",
* "id": int,
* "estado": string, // valor según ENUM de la tabla
* "notas": string // opcional, solo para ítem
* }
*
* Adicional para tipo="muestra" (crear muestra si id=null):
* {
* "tipo": "muestra",
* "id": null, // null = crear nueva
* "orden_id": int,
* "tipo_muestra": "sangre_venosa" | ...,
* "codigo_barras": string,
* "estado": "tomada"
* }
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireExams();
$body = inputJson();
$tipo = $body['tipo'] ?? '';
$estadosItem = ['pendiente','muestra_tomada','en_proceso','resultado_listo','entregado'];
$estadosMuestra = ['pendiente','tomada','procesando','procesada','rechazada'];
$estadosOrden = ['pendiente','en_proceso','completa','entregada','cancelada'];
$pdo = db();
switch ($tipo) {
// ── Cambiar estado de un ítem individual ──────────────────────
case 'item': {
$id = isset($body['id']) ? (int) $body['id'] : 0;
$estado = $body['estado'] ?? '';
if ($id <= 0 || !in_array($estado, $estadosItem, true)) {
jsonError('Parámetros inválidos para ítem');
}
$notas = isset($body['notas']) && $body['notas'] !== '' ? $body['notas'] : null;
// Obtener orden_id para actualizar padre si aplica
$stmtG = $pdo->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);
}
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* modules/registro_exams/api/get_orden.php
* GET ?id=<orden_id>
* 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,
]),
]);
+102
View File
@@ -0,0 +1,102 @@
<?php
/**
* modules/registro_exams/api/get_ordenes.php
* GET ?fecha=YYYY-MM-DD &estado=pendiente &busqueda=texto &page=1 &limit=25
* Devuelve lista de órdenes con datos del paciente e ítems (count).
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
requireExams();
$pdo = db();
$fecha = trim($_GET['fecha'] ?? date('Y-m-d'));
$estado = trim($_GET['estado'] ?? '');
$busqueda = trim($_GET['busqueda'] ?? '');
$page = max(1, (int) ($_GET['page'] ?? 1));
$limit = min(100, max(1, (int) ($_GET['limit'] ?? 25)));
$offset = ($page - 1) * $limit;
// Validar fecha
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
$fecha = date('Y-m-d');
}
$where = ['DATE(o.creado_at) = ?'];
$params = [$fecha];
if ($estado !== '') {
$allowed = ['pendiente', 'en_proceso', 'completa', 'entregada', 'cancelada'];
if (in_array($estado, $allowed, true)) {
$where[] = 'o.estado = ?';
$params[] = $estado;
}
}
if ($busqueda !== '') {
$like = '%' . $busqueda . '%';
$where[] = '(p.nombre_completo LIKE ? OR p.numero_documento LIKE ? OR o.codigo LIKE ?)';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
$whereSQL = 'WHERE ' . implode(' AND ', $where);
// Total para paginación
$countStmt = $pdo->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,
]);
+148
View File
@@ -0,0 +1,148 @@
<?php
/**
* modules/registro_exams/api/save_orden.php
* POST — crea o actualiza una orden de examen.
*
* Body JSON:
* {
* "id": null | int, // null = nueva / int = editar
* "paciente_id": int, // requerido
* "solicitud_id": null | int,
* "orden_medica_id": null | int,
* "medico_nombre": string,
* "medico_registro": string,
* "diagnostico": string,
* "prioridad": "normal"|"urgente"|"stat",
* "notas": string,
* "items": [int, int, ...] // exam_tipo_id[]
* }
*
* Respuesta exitosa:
* { ok: true, id: <orden_id>, 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);
}
@@ -0,0 +1,121 @@
<?php
/**
* modules/registro_exams/api/save_resultado.php
* POST — agrega o reemplaza resultados de un ítem de examen.
*
* Body JSON:
* {
* "item_id": int, // requerido
* "resultados": [
* {
* "campo_nombre": "Hemoglobina",
* "valor_texto": "14.5",
* "valor_numerico": 14.5, // null si no aplica
* "unidad": "g/dL",
* "referencia_min": 12.0, // null si no aplica
* "referencia_max": 16.0,
* "referencia_texto":"12.0 16.0 g/dL",
* "es_anormal": false
* },
* ...
* ]
* }
*
* Respuesta: { ok: true, inserted: N }
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireExams();
$body = inputJson();
$itemId = isset($body['item_id']) ? (int) $body['item_id'] : 0;
if ($itemId <= 0) {
jsonError('item_id inválido');
}
$resultados = $body['resultados'] ?? [];
if (!is_array($resultados) || count($resultados) === 0) {
jsonError('Debe enviar al menos un resultado');
}
$pdo = db();
// Verificar que el ítem existe
$stmt = $pdo->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);
}
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* modules/registro_exams/module.php
* Descriptor del módulo Registro de Exámenes para el sistema ERP.
*/
return [
'slug' => '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'],
],
];
+325
View File
@@ -0,0 +1,325 @@
<?php
/**
* modules/registro_exams/views/etiqueta.php
* Vista de impresión A6 (148 × 105 mm) para etiqueta de muestra.
* URL: /erp.php?m=registro_exams&v=etiqueta&id=N
*
* - Se abre en nueva pestaña y lanza window.print() automáticamente.
* - Sin sidebar; página mínima optimizada para impresión.
*/
require_once APP_ROOT . '/config/config.php';
$ordenId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($ordenId <= 0) {
http_response_code(400);
die('Orden no especificada.');
}
// Cargar datos de la orden directamente (sin AJAX, para que el HTML sea self-contained)
try {
$pdo = Database::getInstance()->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'];
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Etiqueta <?= htmlspecialchars($codigoBarras) ?></title>
<style>
/* ── Reset e impresión A6 ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
@page {
size: A6 landscape; /* 148mm × 105mm en horizontal */
margin: 4mm;
}
body {
font-family: Arial, Helvetica, sans-serif;
font-size: 9pt;
color: #000;
background: #fff;
width: 140mm;
}
/* Pantalla previa */
@media screen {
body {
width: 148mm;
min-height: 105mm;
margin: 10mm auto;
border: 1px dashed #ccc;
padding: 4mm;
background: #fff;
}
.no-print { display: block; }
}
@media print {
.no-print { display: none !important; }
}
/* ── Layout etiqueta ── */
.etiqueta {
width: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1.5pt solid #000;
padding-bottom: 2mm;
margin-bottom: 2mm;
}
.lab-nombre {
font-size: 10pt;
font-weight: bold;
text-transform: uppercase;
}
.codigo {
font-size: 13pt;
font-weight: bold;
letter-spacing: 1px;
text-align: right;
}
.barcode {
font-family: 'Libre Barcode 128', monospace;
font-size: 26pt;
letter-spacing: 0;
line-height: 1;
text-align: right;
}
.paciente-nombre {
font-size: 11pt;
font-weight: bold;
line-height: 1.2;
}
.meta-row {
display: flex;
gap: 6mm;
margin-top: 1mm;
font-size: 8.5pt;
}
.meta-item { white-space: nowrap; }
.meta-label { color: #555; font-size: 7.5pt; display: block; }
.examenes-titulo {
font-weight: bold;
font-size: 8pt;
text-transform: uppercase;
color: #444;
margin-top: 2.5mm;
border-top: .5pt solid #999;
padding-top: 1.5mm;
}
.examenes-lista {
font-size: 8pt;
line-height: 1.5;
}
.badge-prio {
display: inline-block;
font-size: 7pt;
font-weight: bold;
padding: 0.5mm 1.5mm;
border-radius: 2pt;
border: 1pt solid #000;
text-transform: uppercase;
}
.prio-stat { background: #000; color: #fff; }
.prio-urgente { background: #ff0; color: #000; }
.prio-normal { border-color: #aaa; color: #555; }
.aviso-ayuno {
margin-top: 2mm;
font-size: 8pt;
font-weight: bold;
border: 1pt solid #f90;
padding: 1mm 2mm;
border-radius: 2pt;
color: #a60;
}
.footer {
margin-top: 2mm;
font-size: 7pt;
color: #666;
border-top: .5pt solid #ccc;
padding-top: 1mm;
display: flex;
justify-content: space-between;
}
/* ── Botón de impresión (solo pantalla) ── */
.btn-imprimir {
display: block;
margin: 8mm auto;
padding: 6px 20px;
font-size: 12px;
cursor: pointer;
background: #0d6efd;
color: #fff;
border: none;
border-radius: 4px;
}
</style>
<?php
// Intentar cargar Libre Barcode si hay CDN (solo para pantalla; en impresión el texto es suficiente)
?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Libre+Barcode+128+Text&display=swap" rel="stylesheet">
</head>
<body>
<!-- Botón solo en pantalla -->
<div class="no-print" style="text-align:center;margin-bottom:4mm">
<button class="btn-imprimir" onclick="window.print()">🖨 Imprimir etiqueta</button>
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=orden&id=<?= $ordenId ?>"
style="display:inline-block;margin-left:8px;font-size:12px;color:#666">← Volver a orden</a>
</div>
<div class="etiqueta">
<!-- Encabezado: nombre lab + código -->
<div class="header">
<div>
<div class="lab-nombre"><?= defined('APP_NAME') ? htmlspecialchars(APP_NAME) : 'Laboratorio Clínico' ?></div>
<div style="font-size:7.5pt;color:#555">Exámenes de laboratorio en sede</div>
</div>
<div style="text-align:right">
<div class="codigo"><?= htmlspecialchars($codigoBarras) ?></div>
<div class="barcode"><?= htmlspecialchars($codigoBarras) ?></div>
</div>
</div>
<!-- Paciente -->
<div class="paciente-nombre"><?= htmlspecialchars($orden['nombre_completo']) ?></div>
<div class="meta-row">
<div class="meta-item">
<span class="meta-label">Documento</span>
<?= htmlspecialchars($orden['tipo_documento'] . ' ' . $orden['numero_documento']) ?>
</div>
<?php if ($edad !== null): ?>
<div class="meta-item">
<span class="meta-label">Edad</span>
<?= $edad ?> años
</div>
<?php endif; ?>
<?php if ($orden['eps']): ?>
<div class="meta-item">
<span class="meta-label">EPS</span>
<?= htmlspecialchars($orden['eps']) ?>
</div>
<?php endif; ?>
<div class="meta-item" style="margin-left:auto">
<span class="meta-label">Prioridad</span>
<span class="badge-prio prio-<?= $orden['prioridad'] ?>">
<?= strtoupper($orden['prioridad']) ?>
</span>
</div>
</div>
<!-- Exámenes -->
<div class="examenes-titulo">
<i>Exámenes solicitados (<?= count($items) ?>)</i>
</div>
<div class="examenes-lista">
<?php foreach ($items as $i => $item): ?>
<?= ($i + 1) ?>. <?= htmlspecialchars($item['tipo_nombre']) ?>
<span style="color:#888;font-size:7.5pt">(<?= htmlspecialchars($item['tipo_codigo']) ?>)</span><?php
if (!$item['requiere_ayuno']): ?><?php endif; ?>
<?php if ($i < count($items) - 1): ?> ·
<?php endif; ?>
<?php endforeach; ?>
</div>
<!-- Aviso ayuno -->
<?php if ($ayunoReq): ?>
<div class="aviso-ayuno">
⚠ REQUIERE AYUNO<?= $horasAyuno ? " DE {$horasAyuno} HORAS" : '' ?>
</div>
<?php endif; ?>
<!-- Pie: médico + fecha -->
<div class="footer">
<span>Médico: <?= htmlspecialchars($orden['medico_nombre'] ?: '—') ?></span>
<span><?= $fecha ?></span>
</div>
</div><!-- /etiqueta -->
<script>
// Auto-print al abrir
window.addEventListener('load', function () {
// Pequeño delay para cargar la fuente barcode
setTimeout(function () { window.print(); }, 800);
});
</script>
</body>
</html>
+257
View File
@@ -0,0 +1,257 @@
<?php
/**
* modules/registro_exams/views/lista.php
* Vista principal: listado de órdenes del día con filtros y acciones.
* Accesible en /erp.php?m=registro_exams&v=lista
*/
require_once APP_ROOT . '/config/config.php';
Layout::open('Órdenes de Exámenes', 'fas fa-vials');
?>
<div class="container-fluid py-3">
<!-- Encabezado + botón nueva orden -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<h4 class="mb-0"><i class="fas fa-vials me-2 text-primary"></i>Órdenes de Exámenes</h4>
<small class="text-muted">Registro y seguimiento de exámenes de laboratorio</small>
</div>
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=nueva_orden" class="btn btn-primary">
<i class="fas fa-plus me-1"></i> Nueva orden
</a>
</div>
<!-- Filtros -->
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<form id="filtroForm" class="row g-2 align-items-end">
<div class="col-sm-3 col-md-2">
<label class="form-label mb-1 small fw-semibold">Fecha</label>
<input type="date" id="filtroFecha" name="fecha" class="form-control form-control-sm"
value="<?= date('Y-m-d') ?>">
</div>
<div class="col-sm-3 col-md-2">
<label class="form-label mb-1 small fw-semibold">Estado</label>
<select id="filtroEstado" name="estado" class="form-select form-select-sm">
<option value="">Todos</option>
<option value="pendiente">Pendiente</option>
<option value="en_proceso">En proceso</option>
<option value="completa">Completa</option>
<option value="entregada">Entregada</option>
<option value="cancelada">Cancelada</option>
</select>
</div>
<div class="col-sm-4 col-md-3">
<label class="form-label mb-1 small fw-semibold">Buscar</label>
<input type="search" id="filtroBusqueda" name="busqueda" class="form-control form-control-sm"
placeholder="Nombre, documento o código…">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="fas fa-search me-1"></i>Filtrar
</button>
<button type="button" id="btnHoy" class="btn btn-sm btn-outline-secondary ms-1">
Hoy
</button>
</div>
</form>
</div>
</div>
<!-- KPIs rápidos del día -->
<div class="row g-2 mb-3" id="kpis">
<div class="col-6 col-md-3">
<div class="card border-0 bg-light text-center py-2">
<div class="fs-4 fw-bold text-primary" id="kpiTotal">—</div>
<div class="small text-muted">Total día</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-light text-center py-2">
<div class="fs-4 fw-bold text-warning" id="kpiPendientes">—</div>
<div class="small text-muted">Pendientes</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-light text-center py-2">
<div class="fs-4 fw-bold text-info" id="kpiEnProceso">—</div>
<div class="small text-muted">En proceso</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-light text-center py-2">
<div class="fs-4 fw-bold text-success" id="kpiCompletas">—</div>
<div class="small text-muted">Completas</div>
</div>
</div>
</div>
<!-- Tabla de órdenes -->
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0" id="tablaOrdenes">
<thead class="table-light">
<tr>
<th style="width:110px">Código</th>
<th>Paciente</th>
<th style="width:80px" class="text-center">Edad</th>
<th style="width:90px">Prioridad</th>
<th style="width:110px">Estado</th>
<th style="width:90px" class="text-center">Ítems</th>
<th style="width:80px" class="text-center">Muestras</th>
<th style="width:110px">Hora</th>
<th style="width:90px" class="text-end">Acciones</th>
</tr>
</thead>
<tbody id="tbodyOrdenes">
<tr><td colspan="9" class="text-center text-muted py-4">
<i class="fas fa-spinner fa-spin me-2"></i>Cargando…
</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center py-2" id="paginacion">
<span class="small text-muted" id="paginaInfo"></span>
<div id="paginaBtns" class="btn-group btn-group-sm"></div>
</div>
</div>
</div>
<script>
(function () {
const BASE = '<?= BASE_URL ?>';
const API = BASE + '/modules/registro_exams/api/get_ordenes.php';
let currentPage = 1;
// ── KPI contadores del día (sin filtro estado) ──
async function cargarKpis(fecha) {
try {
const r = await fetch(`${API}?fecha=${fecha}&limit=200`);
const j = await r.json();
if (!j.ok) return;
const rows = j.data;
document.getElementById('kpiTotal').textContent = j.total;
document.getElementById('kpiPendientes').textContent = rows.filter(x => x.estado === 'pendiente').length;
document.getElementById('kpiEnProceso').textContent = rows.filter(x => x.estado === 'en_proceso').length;
document.getElementById('kpiCompletas').textContent = rows.filter(x => x.estado === 'completa').length;
} catch {}
}
// ── Construir fila de tabla ──
function buildRow(o) {
const hora = o.creado_at ? o.creado_at.substr(11, 5) : '—';
const edad = o.edad !== null ? o.edad + ' a.' : '—';
const badgePrio = {
stat: '<span class="badge bg-danger">STAT</span>',
urgente: '<span class="badge bg-warning text-dark">Urgente</span>',
normal: '<span class="badge bg-secondary">Normal</span>',
}[o.prioridad] || '';
const badgeEst = {
pendiente: '<span class="badge bg-light text-dark border">Pendiente</span>',
en_proceso: '<span class="badge bg-primary">En proceso</span>',
completa: '<span class="badge bg-success">Completa</span>',
entregada: '<span class="badge bg-info text-dark">Entregada</span>',
cancelada: '<span class="badge bg-dark">Cancelada</span>',
}[o.estado] || o.estado;
const itemsInfo = `${o.items_listos}/${o.total_items}`;
const muestInfo = o.muestras_tomadas > 0
? `<span class="text-success fw-semibold">${o.muestras_tomadas}</span>`
: `<span class="text-muted">0</span>`;
return `<tr>
<td><a href="${BASE}/erp.php?m=registro_exams&v=orden&id=${o.id}" class="fw-semibold text-decoration-none">${escHtml(o.codigo)}</a></td>
<td>
<div class="fw-semibold lh-sm">${escHtml(o.nombre_completo)}</div>
<div class="small text-muted">${escHtml(o.tipo_documento)} ${escHtml(o.numero_documento)}</div>
</td>
<td class="text-center">${edad}</td>
<td>${badgePrio}</td>
<td>${badgeEst}</td>
<td class="text-center">${itemsInfo}</td>
<td class="text-center">${muestInfo}</td>
<td>${hora}</td>
<td class="text-end">
<a href="${BASE}/erp.php?m=registro_exams&v=orden&id=${o.id}"
class="btn btn-sm btn-outline-primary" title="Ver orden">
<i class="fas fa-eye"></i>
</a>
<a href="${BASE}/erp.php?m=registro_exams&v=etiqueta&id=${o.id}"
target="_blank" class="btn btn-sm btn-outline-secondary ms-1" title="Etiqueta">
<i class="fas fa-print"></i>
</a>
</td>
</tr>`;
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Cargar tabla ──
async function cargar(page = 1) {
currentPage = page;
const fecha = document.getElementById('filtroFecha').value;
const estado = document.getElementById('filtroEstado').value;
const busqueda = document.getElementById('filtroBusqueda').value;
const url = `${API}?fecha=${encodeURIComponent(fecha)}&estado=${encodeURIComponent(estado)}&busqueda=${encodeURIComponent(busqueda)}&page=${page}&limit=25`;
document.getElementById('tbodyOrdenes').innerHTML =
'<tr><td colspan="9" class="text-center text-muted py-4"><i class="fas fa-spinner fa-spin me-2"></i>Cargando…</td></tr>';
try {
const r = await fetch(url);
const j = await r.json();
if (!j.ok) throw new Error(j.error);
const tbody = document.getElementById('tbodyOrdenes');
if (!j.data.length) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted py-4"><i class="fas fa-inbox me-2"></i>Sin órdenes para los filtros seleccionados.</td></tr>';
} else {
tbody.innerHTML = j.data.map(buildRow).join('');
}
// Paginación
document.getElementById('paginaInfo').textContent =
`${j.total} orden(es) — Página ${j.page} de ${j.pages}`;
const btns = document.getElementById('paginaBtns');
btns.innerHTML = '';
for (let p = 1; p <= j.pages; p++) {
const btn = document.createElement('button');
btn.className = 'btn btn-outline-secondary' + (p === j.page ? ' active' : '');
btn.textContent = p;
btn.addEventListener('click', () => cargar(p));
btns.appendChild(btn);
}
// KPIs (solo recargar cuando cambia fecha)
cargarKpis(fecha);
} catch (e) {
document.getElementById('tbodyOrdenes').innerHTML =
`<tr><td colspan="9" class="text-center text-danger py-4"><i class="fas fa-exclamation-circle me-2"></i>${e.message}</td></tr>`;
}
}
// ── Eventos ──
document.getElementById('filtroForm').addEventListener('submit', e => { e.preventDefault(); cargar(1); });
document.getElementById('btnHoy').addEventListener('click', () => {
document.getElementById('filtroFecha').value = new Date().toISOString().slice(0, 10);
cargar(1);
});
// Autorefresh cada 45 s
setInterval(() => cargar(currentPage), 45000);
// Carga inicial
cargar(1);
})();
</script>
<?php Layout::close(); ?>
@@ -0,0 +1,419 @@
<?php
/**
* modules/registro_exams/views/nueva_orden.php
* Formulario para crear una nueva orden de exámenes.
* URL: /erp.php?m=registro_exams&v=nueva_orden[&solicitud_id=N]
*/
require_once APP_ROOT . '/config/config.php';
// Pre-carga de solicitud del turnero (opcional)
$solicitudId = isset($_GET['solicitud_id']) ? (int) $_GET['solicitud_id'] : null;
$pacientePreId = null;
$pacientePreNom = '';
$pacientePreDoc = '';
if ($solicitudId) {
try {
$pdo = Database::getInstance()->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');
?>
<div class="container-fluid py-3" style="max-width: 960px;">
<div class="d-flex align-items-center gap-2 mb-3">
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=lista" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Volver
</a>
<h4 class="mb-0"><i class="fas fa-plus-circle me-2 text-primary"></i>Nueva Orden de Exámenes</h4>
</div>
<form id="formOrden" novalidate>
<input type="hidden" id="ordenId" value="">
<input type="hidden" id="solicitudIdHidden" value="<?= (int) $solicitudId ?>">
<!-- ── SECCIÓN 1: Paciente ─────────────────────────── -->
<div class="card shadow-sm mb-3">
<div class="card-header bg-primary text-white py-2">
<i class="fas fa-user me-2"></i><strong>1. Datos del paciente</strong>
</div>
<div class="card-body">
<input type="hidden" id="pacienteId" value="<?= (int) $pacientePreId ?>">
<?php if ($pacientePreId): ?>
<!-- Paciente pre-cargado desde turnero -->
<div id="pacienteSeleccionado" class="alert alert-success d-flex align-items-center gap-3 mb-2">
<i class="fas fa-user-check fs-4"></i>
<div>
<div class="fw-bold" id="pacNombre"><?= htmlspecialchars($pacientePreNom) ?></div>
<div class="small" id="pacDoc"><?= htmlspecialchars($pacientePreDoc) ?></div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger ms-auto" id="btnCambiarPaciente">
<i class="fas fa-times me-1"></i>Cambiar
</button>
</div>
<div id="buscadorPaciente" class="d-none">
<?php else: ?>
<div id="pacienteSeleccionado" class="alert alert-light border-dashed d-none">
<div class="fw-bold" id="pacNombre"></div>
<div class="small text-muted" id="pacDoc"></div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" id="btnCambiarPaciente">Cambiar</button>
</div>
<div id="buscadorPaciente">
<?php endif; ?>
<label class="form-label fw-semibold">Buscar paciente</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="search" id="inputBuscarPac" class="form-control"
placeholder="Nombre o número de documento…" autocomplete="off">
</div>
<div id="resultadosPac" class="list-group mt-1 shadow-sm"></div>
</div><!-- /buscadorPaciente -->
</div><!-- /card-body -->
</div>
<!-- ── SECCIÓN 2: Datos médicos ───────────────────── -->
<div class="card shadow-sm mb-3">
<div class="card-header py-2">
<i class="fas fa-stethoscope me-2 text-secondary"></i><strong>2. Datos médicos</strong>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-5">
<label class="form-label small fw-semibold">Médico solicitante</label>
<input type="text" id="medicoNombre" class="form-control form-control-sm"
placeholder="Nombre del médico">
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold">Registro médico</label>
<input type="text" id="medicoRegistro" class="form-control form-control-sm"
placeholder="No. registro">
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Prioridad</label>
<select id="prioridad" class="form-select form-select-sm">
<option value="normal" selected>Normal</option>
<option value="urgente">Urgente</option>
<option value="stat">STAT (inmediato)</option>
</select>
</div>
<div class="col-12">
<label class="form-label small fw-semibold">Diagnóstico / indicación</label>
<textarea id="diagnostico" class="form-control form-control-sm" rows="2"
placeholder="CIE-10 o descripción clínica"></textarea>
</div>
<div class="col-12">
<label class="form-label small fw-semibold">Notas internas</label>
<textarea id="notas" class="form-control form-control-sm" rows="2"
placeholder="Observaciones para el bacteriólogo…"></textarea>
</div>
</div>
</div>
</div>
<!-- ── SECCIÓN 3: Exámenes solicitados ───────────── -->
<div class="card shadow-sm mb-3">
<div class="card-header py-2">
<i class="fas fa-vials me-2 text-secondary"></i>
<strong>3. Exámenes solicitados</strong>
</div>
<div class="card-body">
<!-- Buscador de exámenes -->
<div class="input-group mb-3">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="search" id="inputBuscarExam" class="form-control"
placeholder="Filtrar exámenes por nombre o código…">
</div>
<?php if (empty($examTipos)): ?>
<div class="alert alert-warning">
No hay tipos de examen configurados. Configure los exámenes en
<a href="<?= BASE_URL ?>/erp.php?m=turnero&v=configuracion">Configuración → Exámenes</a>.
</div>
<?php else: ?>
<div id="catalogoExams">
<?php foreach ($examTipos as $categoria => $tipos): ?>
<div class="exam-categoria mb-2">
<div class="fw-semibold text-uppercase text-muted small border-bottom pb-1 mb-2 categoria-header">
<?= htmlspecialchars($categoria) ?>
</div>
<div class="row g-2">
<?php foreach ($tipos as $t): ?>
<div class="col-sm-6 col-md-4 exam-item"
data-nombre="<?= htmlspecialchars(strtolower($t['nombre'])) ?>"
data-codigo="<?= htmlspecialchars(strtolower($t['codigo'])) ?>">
<label class="card h-100 border-0 bg-light p-2 cursor-pointer exam-card"
for="et<?= $t['id'] ?>">
<div class="d-flex align-items-start gap-2">
<input class="form-check-input mt-1 exam-checkbox flex-shrink-0"
type="checkbox"
id="et<?= $t['id'] ?>"
value="<?= $t['id'] ?>"
data-nombre="<?= htmlspecialchars($t['nombre']) ?>"
data-ayuno="<?= $t['requiere_ayuno'] ? '1' : '0' ?>"
data-horas-ayuno="<?= (int) $t['horas_ayuno'] ?>"
data-precio="<?= $t['precio_base'] !== null ? number_format((float)$t['precio_base'], 0, ',', '.') : '' ?>">
<div class="lh-sm">
<div class="fw-semibold small"><?= htmlspecialchars($t['nombre']) ?></div>
<div class="text-muted" style="font-size:.75rem"><?= htmlspecialchars($t['codigo']) ?></div>
<?php if ($t['requiere_ayuno']): ?>
<span class="badge bg-warning text-dark" style="font-size:.65rem">
<i class="fas fa-clock me-1"></i>Ayuno <?= $t['horas_ayuno'] ? $t['horas_ayuno'].'h' : '' ?>
</span>
<?php endif; ?>
<?php if ($t['precio_base']): ?>
<span class="badge bg-light text-secondary border" style="font-size:.65rem">
$<?= number_format((float)$t['precio_base'], 0, ',', '.') ?>
</span>
<?php endif; ?>
</div>
</div>
</label>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Resumen seleccionados -->
<div id="resumenExams" class="mt-3 d-none">
<div class="alert alert-primary py-2 mb-0">
<strong><i class="fas fa-check-circle me-1"></i>Seleccionados:</strong>
<span id="resumenLista"></span>
<span id="ayunoAviso" class="ms-2 badge bg-warning text-dark d-none">
<i class="fas fa-clock me-1"></i> Ayuno requerido
</span>
</div>
</div>
</div>
</div>
<!-- ── Botones ───────────────────────────────────── -->
<div class="d-flex gap-2 justify-content-end mb-4">
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=lista"
class="btn btn-outline-secondary">
Cancelar
</a>
<button type="submit" class="btn btn-primary px-4" id="btnGuardar">
<i class="fas fa-save me-1"></i> Crear orden
</button>
</div>
</form>
</div>
<style>
.exam-card { cursor: pointer; transition: border-color .15s; }
.exam-card:hover { border-color: #0d6efd !important; background: #e9f0ff !important; }
.exam-card:has(.exam-checkbox:checked) { background: #dbeafe !important; border: 1px solid #0d6efd !important; }
.cursor-pointer { cursor: pointer; }
</style>
<script>
(function () {
const BASE = '<?= BASE_URL ?>';
const API_PAC = BASE + '/api/lab/get_pacientes.php';
const API_SV = BASE + '/modules/registro_exams/api/save_orden.php';
let debounceTimer = null;
let examDebounce = null;
// ── Buscador de pacientes ──────────────────────────────────
const inputBuscarPac = document.getElementById('inputBuscarPac');
const resultadosPac = document.getElementById('resultadosPac');
const buscadorDiv = document.getElementById('buscadorPaciente');
const seleccionadoDiv = document.getElementById('pacienteSeleccionado');
const pacNombreEl = document.getElementById('pacNombre');
const pacDocEl = document.getElementById('pacDoc');
const pacienteIdEl = document.getElementById('pacienteId');
if (inputBuscarPac) {
inputBuscarPac.addEventListener('input', () => {
clearTimeout(debounceTimer);
const q = inputBuscarPac.value.trim();
if (q.length < 2) { resultadosPac.innerHTML = ''; return; }
debounceTimer = setTimeout(() => buscarPaciente(q), 300);
});
}
async function buscarPaciente(q) {
try {
const r = await fetch(`${API_PAC}?busqueda=${encodeURIComponent(q)}&limit=8`);
const j = await r.json();
if (!j.ok || !j.data?.length) {
resultadosPac.innerHTML = '<div class="list-group-item text-muted small">Sin resultados</div>';
return;
}
resultadosPac.innerHTML = j.data.map(p =>
`<button type="button" class="list-group-item list-group-item-action py-2"
data-id="${p.id}" data-nombre="${escAtt(p.nombre_completo)}" data-doc="${escAtt(p.numero_documento)} ${escAtt(p.tipo_documento||'')}">
<div class="fw-semibold lh-sm">${escHtml(p.nombre_completo)}</div>
<div class="small text-muted">${escHtml(p.tipo_documento||'')} ${escHtml(p.numero_documento)}</div>
</button>`
).join('');
resultadosPac.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => seleccionarPaciente(
parseInt(btn.dataset.id), btn.dataset.nombre, btn.dataset.doc
));
});
} catch { resultadosPac.innerHTML = ''; }
}
function seleccionarPaciente(id, nombre, doc) {
pacienteIdEl.value = id;
pacNombreEl.textContent = nombre;
pacDocEl.textContent = doc;
resultadosPac.innerHTML = '';
buscadorDiv.classList.add('d-none');
seleccionadoDiv.classList.remove('d-none');
seleccionadoDiv.classList.remove('alert-light');
seleccionadoDiv.classList.add('alert-success');
}
const btnCambiar = document.getElementById('btnCambiarPaciente');
if (btnCambiar) {
btnCambiar.addEventListener('click', () => {
pacienteIdEl.value = '';
buscadorDiv.classList.remove('d-none');
seleccionadoDiv.classList.add('d-none');
if (inputBuscarPac) { inputBuscarPac.value = ''; inputBuscarPac.focus(); }
});
}
// ── Buscador de exámenes ───────────────────────────────────
const inputBuscarExam = document.getElementById('inputBuscarExam');
if (inputBuscarExam) {
inputBuscarExam.addEventListener('input', () => {
clearTimeout(examDebounce);
examDebounce = setTimeout(() => filtrarExams(inputBuscarExam.value.trim().toLowerCase()), 200);
});
}
function filtrarExams(q) {
const items = document.querySelectorAll('.exam-item');
const cats = document.querySelectorAll('.exam-categoria');
items.forEach(el => {
const visible = !q || el.dataset.nombre.includes(q) || el.dataset.codigo.includes(q);
el.style.display = visible ? '' : 'none';
});
// Ocultar categorías vacías
cats.forEach(cat => {
const visibles = [...cat.querySelectorAll('.exam-item')].some(i => i.style.display !== 'none');
cat.style.display = visibles ? '' : 'none';
});
}
// ── Resumen de exámenes seleccionados ──────────────────────
document.querySelectorAll('.exam-checkbox').forEach(cb => {
cb.addEventListener('change', actualizarResumen);
});
function actualizarResumen() {
const sel = [...document.querySelectorAll('.exam-checkbox:checked')];
const resumenDiv = document.getElementById('resumenExams');
const resumenLista = document.getElementById('resumenLista');
const ayunoAviso = document.getElementById('ayunoAviso');
if (!sel.length) {
resumenDiv.classList.add('d-none');
return;
}
resumenDiv.classList.remove('d-none');
resumenLista.textContent = sel.map(c => c.dataset.nombre).join(' · ');
const ayuno = sel.some(c => c.dataset.ayuno === '1');
ayunoAviso.classList.toggle('d-none', !ayuno);
}
// ── Enviar formulario ──────────────────────────────────────
document.getElementById('formOrden').addEventListener('submit', async (e) => {
e.preventDefault();
const pacId = parseInt(document.getElementById('pacienteId').value) || 0;
if (!pacId) {
alert('Seleccione un paciente antes de continuar.');
return;
}
const items = [...document.querySelectorAll('.exam-checkbox:checked')]
.map(c => parseInt(c.value));
if (!items.length) {
alert('Seleccione al menos un examen.');
return;
}
const btn = document.getElementById('btnGuardar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
const body = {
id: null,
paciente_id: pacId,
solicitud_id: parseInt(document.getElementById('solicitudIdHidden').value) || null,
medico_nombre: document.getElementById('medicoNombre').value.trim(),
medico_registro: document.getElementById('medicoRegistro').value.trim(),
diagnostico: document.getElementById('diagnostico').value.trim(),
prioridad: document.getElementById('prioridad').value,
notas: document.getElementById('notas').value.trim(),
items: items,
};
try {
const r = await fetch(API_SV, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
// Redirigir a la orden creada
window.location.href = `${BASE}/erp.php?m=registro_exams&v=orden&id=${j.id}`;
} catch (err) {
alert('Error al guardar: ' + err.message);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i> Crear orden';
}
});
function escHtml(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAtt(s) { return String(s||'').replace(/"/g,'&quot;'); }
})();
</script>
<?php Layout::close(); ?>
+536
View File
@@ -0,0 +1,536 @@
<?php
/**
* modules/registro_exams/views/orden.php
* Detalle de una orden: datos paciente, ítems, muestras y resultados.
* URL: /erp.php?m=registro_exams&v=orden&id=N
*/
require_once APP_ROOT . '/config/config.php';
$ordenId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($ordenId <= 0) {
header('Location: ' . BASE_URL . '/erp.php?m=registro_exams&v=lista');
exit;
}
Layout::open('Orden de Exámenes', 'fas fa-file-medical');
?>
<div class="container-fluid py-3" style="max-width: 1100px;">
<!-- Encabezado -->
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=lista" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Volver
</a>
<h4 class="mb-0" id="tituloOrden">
<i class="fas fa-file-medical me-2 text-primary"></i>
<span id="codigoOrden">Cargando…</span>
</h4>
<div class="ms-auto d-flex gap-2 flex-wrap">
<a href="<?= BASE_URL ?>/erp.php?m=registro_exams&v=etiqueta&id=<?= $ordenId ?>"
target="_blank" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-print me-1"></i>Etiqueta
</a>
<button class="btn btn-sm btn-outline-success" id="btnMarcarEntregada">
<i class="fas fa-hand-holding me-1"></i>Marcar entregada
</button>
</div>
</div>
<!-- Spinner de carga -->
<div id="spinnerOrden" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<div class="mt-2 text-muted">Cargando orden…</div>
</div>
<!-- Contenido principal (oculto hasta carga) -->
<div id="contenidoOrden" class="d-none">
<!-- Fila 1: Paciente + Datos médicos -->
<div class="row g-3 mb-3">
<!-- Card Paciente -->
<div class="col-md-5">
<div class="card shadow-sm h-100">
<div class="card-header bg-primary text-white py-2">
<i class="fas fa-user me-2"></i><strong>Paciente</strong>
</div>
<div class="card-body py-2">
<div class="fs-5 fw-bold" id="detPacNombre"></div>
<div class="text-muted small mb-1" id="detPacDoc"></div>
<div class="row g-1 small">
<div class="col-6"><span class="text-muted">Edad:</span> <span id="detPacEdad"></span></div>
<div class="col-6"><span class="text-muted">Género:</span> <span id="detPacGenero"></span></div>
<div class="col-12"><span class="text-muted">EPS:</span> <span id="detPacEps"></span></div>
<div class="col-12"><span class="text-muted">Tel:</span> <span id="detPacTel"></span></div>
</div>
</div>
</div>
</div>
<!-- Card Orden -->
<div class="col-md-7">
<div class="card shadow-sm h-100">
<div class="card-header py-2">
<i class="fas fa-file-medical-alt me-2 text-secondary"></i>
<strong>Datos de la orden</strong>
<span class="ms-2" id="detBadgePrio"></span>
<span class="ms-1" id="detBadgeEstado"></span>
</div>
<div class="card-body py-2">
<div class="row g-1 small">
<div class="col-sm-6"><span class="text-muted">Médico:</span> <span id="detMedico"></span></div>
<div class="col-sm-6"><span class="text-muted">Registro:</span> <span id="detMedicoReg"></span></div>
<div class="col-12"><span class="text-muted">Diagnóstico:</span> <span id="detDiagnostico"></span></div>
<div class="col-12"><span class="text-muted">Notas:</span> <span id="detNotas"></span></div>
<div class="col-12"><span class="text-muted">Creado:</span> <span id="detFecha"></span></div>
</div>
</div>
</div>
</div>
</div>
<!-- Fila 2: Muestras -->
<div class="card shadow-sm mb-3">
<div class="card-header d-flex align-items-center justify-content-between py-2">
<span><i class="fas fa-tint me-2 text-danger"></i><strong>Muestras</strong></span>
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#modalMuestra">
<i class="fas fa-plus me-1"></i>Registrar muestra
</button>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0" id="tablaMuestras">
<thead class="table-light">
<tr>
<th>Tipo</th>
<th>Código barras</th>
<th>Estado</th>
<th>Tomada por</th>
<th>Hora</th>
</tr>
</thead>
<tbody id="tbodyMuestras">
<tr><td colspan="5" class="text-muted text-center py-3">Sin muestras registradas</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Fila 3: Ítems de examen con sección de resultados expandible -->
<div class="card shadow-sm mb-3">
<div class="card-header py-2">
<i class="fas fa-vials me-2 text-secondary"></i>
<strong>Exámenes solicitados</strong>
<span class="badge bg-secondary ms-2" id="contadorItems"></span>
</div>
<div class="card-body p-0" id="listaItems">
<div class="text-center text-muted py-3">Cargando ítems…</div>
</div>
</div>
</div><!-- /contenidoOrden -->
</div>
<!-- ── Modal: Registrar muestra ──────────────────────────── -->
<div class="modal fade" id="modalMuestra" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white py-2">
<h6 class="modal-title"><i class="fas fa-tint me-2"></i>Registrar muestra</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label small fw-semibold">Tipo de muestra</label>
<select id="mTipoMuestra" class="form-select form-select-sm">
<option value="sangre_venosa">Sangre venosa</option>
<option value="sangre_capilar">Sangre capilar</option>
<option value="orina_24h">Orina 24 h</option>
<option value="orina_espontanea">Orina espontánea</option>
<option value="heces">Heces</option>
<option value="esputo">Esputo</option>
<option value="hisopado">Hisopado</option>
<option value="otro">Otro</option>
</select>
</div>
<div class="mb-3">
<label class="form-label small fw-semibold">Código de barras (opcional)</label>
<input type="text" id="mCodigoBarras" class="form-control form-control-sm"
placeholder="Escanear o ingresar código">
</div>
<div class="mb-0">
<label class="form-label small fw-semibold">Estado</label>
<select id="mEstadoMuestra" class="form-select form-select-sm">
<option value="tomada" selected>Tomada</option>
<option value="procesando">Procesando</option>
<option value="procesada">Procesada</option>
<option value="rechazada">Rechazada</option>
</select>
</div>
</div>
<div class="modal-footer py-2">
<button class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-sm btn-danger" id="btnGuardarMuestra">
<i class="fas fa-save me-1"></i>Guardar muestra
</button>
</div>
</div>
</div>
</div>
<!-- ── Modal: Ingresar resultados ────────────────────────── -->
<div class="modal fade modal-xl" id="modalResultados" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header bg-primary text-white py-2">
<h6 class="modal-title" id="modalResTitle"><i class="fas fa-flask me-2"></i>Ingresar resultados</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="resItemId">
<div id="tablaResultados">
<!-- Filas generadas dinámicamente -->
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="btnAgregarFila">
<i class="fas fa-plus me-1"></i>Agregar campo
</button>
</div>
<div class="modal-footer py-2">
<button class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-sm btn-primary" id="btnGuardarResultados">
<i class="fas fa-save me-1"></i>Guardar resultados
</button>
</div>
</div>
</div>
</div>
<script>
(function () {
const ORDEN_ID = <?= $ordenId ?>;
const BASE = '<?= BASE_URL ?>';
const API_GET = BASE + '/modules/registro_exams/api/get_orden.php';
const API_CAM = BASE + '/modules/registro_exams/api/cambiar_estado_item.php';
const API_RES = BASE + '/modules/registro_exams/api/save_resultado.php';
let ordenData = null;
// ── Carga inicial ─────────────────────────────────────────
async function cargarOrden() {
try {
const r = await fetch(`${API_GET}?id=${ORDEN_ID}`);
const j = await r.json();
if (!j.ok) throw new Error(j.error);
ordenData = j.data;
renderOrden(j.data);
} catch (e) {
document.getElementById('spinnerOrden').innerHTML =
`<div class="alert alert-danger m-4">Error al cargar: ${escHtml(e.message)}</div>`;
}
}
function renderOrden(o) {
document.getElementById('spinnerOrden').classList.add('d-none');
document.getElementById('contenidoOrden').classList.remove('d-none');
// Encabezado
document.getElementById('codigoOrden').textContent = o.codigo;
// Paciente
document.getElementById('detPacNombre').textContent = o.nombre_completo;
document.getElementById('detPacDoc').textContent = `${o.tipo_documento} ${o.numero_documento}`;
document.getElementById('detPacEdad').textContent = o.edad !== null ? `${o.edad} años` : '—';
document.getElementById('detPacGenero').textContent = o.genero || '—';
document.getElementById('detPacEps').textContent = o.eps || '—';
document.getElementById('detPacTel').textContent = o.telefono || '—';
// Orden
document.getElementById('detBadgePrio').innerHTML = badgePrio(o.prioridad);
document.getElementById('detBadgeEstado').innerHTML = badgeEstado(o.estado);
document.getElementById('detMedico').textContent = o.medico_nombre || '—';
document.getElementById('detMedicoReg').textContent = o.medico_registro || '—';
document.getElementById('detDiagnostico').textContent = o.diagnostico || '—';
document.getElementById('detNotas').textContent = o.notas || '—';
document.getElementById('detFecha').textContent = o.creado_at ? o.creado_at.substr(0, 16) : '—';
// Muestras
renderMuestras(o.muestras);
// Ítems
renderItems(o.items);
// Contador
document.getElementById('contadorItems').textContent = o.items.length;
// Botón marcar entregada (solo si está completa)
const btnEntregar = document.getElementById('btnMarcarEntregada');
btnEntregar.disabled = !['completa'].includes(o.estado);
}
// ── Muestras ──────────────────────────────────────────────
function renderMuestras(muestras) {
const tbody = document.getElementById('tbodyMuestras');
if (!muestras.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-muted text-center py-3">Sin muestras registradas</td></tr>';
return;
}
const labelTipo = {
sangre_venosa: 'Sangre venosa', sangre_capilar: 'Sangre capilar',
orina_24h: 'Orina 24h', orina_espontanea: 'Orina espontánea',
heces: 'Heces', esputo: 'Esputo', hisopado: 'Hisopado', otro: 'Otro'
};
const badgeEst = {
pendiente:'<span class="badge bg-light text-dark border">Pendiente</span>',
tomada: '<span class="badge bg-success">Tomada</span>',
procesando:'<span class="badge bg-info text-dark">Procesando</span>',
procesada: '<span class="badge bg-primary">Procesada</span>',
rechazada: '<span class="badge bg-danger">Rechazada</span>',
};
tbody.innerHTML = muestras.map(m =>
`<tr>
<td>${labelTipo[m.tipo_muestra] || m.tipo_muestra}</td>
<td>${escHtml(m.codigo_barras || '—')}</td>
<td>${badgeEst[m.estado] || m.estado}</td>
<td>${escHtml(m.tomada_nombre || '—')}</td>
<td>${m.tomada_at ? m.tomada_at.substr(11,5) : '—'}</td>
</tr>`
).join('');
}
// ── Ítems ─────────────────────────────────────────────────
function renderItems(items) {
const cont = document.getElementById('listaItems');
if (!items.length) {
cont.innerHTML = '<div class="text-muted text-center py-3">Sin ítems</div>';
return;
}
const estadoColor = {
pendiente: 'bg-light text-dark border',
muestra_tomada: 'bg-warning text-dark',
en_proceso: 'bg-info text-dark',
resultado_listo: 'bg-success',
entregado: 'bg-secondary',
};
cont.innerHTML = items.map(item => {
const badge = `<span class="badge ${estadoColor[item.estado] || 'bg-light'}">${labelEstItem(item.estado)}</span>`;
const nResultados = item.resultados?.length ?? 0;
const resHTML = nResultados > 0 ? renderTablaResultados(item.resultados) : '';
return `
<div class="border-bottom p-3" id="item-${item.id}">
<div class="d-flex flex-wrap align-items-start gap-2">
<div class="flex-grow-1">
<div class="fw-semibold">${escHtml(item.tipo_nombre)}
<small class="text-muted ms-1">${escHtml(item.tipo_codigo)}</small>
</div>
<div class="small text-muted">${escHtml(item.categoria || '')}</div>
${item.tipo_instrucciones
? `<div class="small text-info mt-1"><i class="fas fa-info-circle me-1"></i>${escHtml(item.tipo_instrucciones)}</div>`
: ''}
</div>
<div class="d-flex align-items-center gap-1 flex-wrap">
${badge}
<button class="btn btn-sm btn-outline-primary"
onclick="abrirResultados(${item.id}, '${escAtt(item.tipo_nombre)}', ${JSON.stringify(item.resultados || []).replace(/"/g,'&quot;')})">
<i class="fas fa-flask me-1"></i>${nResultados > 0 ? 'Editar resultados' : 'Ingresar resultados'}
</button>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">
Estado
</button>
<ul class="dropdown-menu dropdown-menu-end">
${['pendiente','muestra_tomada','en_proceso','resultado_listo','entregado'].map(est =>
`<li><a class="dropdown-item small" href="#"
onclick="cambiarEstado('item',${item.id},'${est}');return false;">
${labelEstItem(est)}</a></li>`
).join('')}
</ul>
</div>
</div>
</div>
${resHTML ? `<div class="mt-2">${resHTML}</div>` : ''}
</div>`;
}).join('');
}
function renderTablaResultados(resultados) {
if (!resultados.length) return '';
const rows = resultados.map(r =>
`<tr class="${r.es_anormal ? 'table-danger' : ''}">
<td>${escHtml(r.campo_nombre)}</td>
<td class="fw-semibold">${escHtml(r.valor_texto || (r.valor_numerico !== null ? r.valor_numerico : '—'))}</td>
<td>${escHtml(r.unidad || '—')}</td>
<td>${escHtml(r.referencia_texto || (r.referencia_min !== null ? `${r.referencia_min} ${r.referencia_max}` : '—'))}</td>
<td>${r.es_anormal ? '<span class="badge bg-danger">ANORMAL</span>' : ''}</td>
</tr>`
).join('');
return `<table class="table table-sm table-bordered mb-0" style="font-size:.85rem">
<thead class="table-light">
<tr><th>Campo</th><th>Valor</th><th>Unidad</th><th>Referencia</th><th>Obs.</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
}
// ── Cambiar estado ────────────────────────────────────────
window.cambiarEstado = async function(tipo, id, estado) {
try {
const r = await fetch(API_CAM, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tipo, id, estado }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
await cargarOrden();
} catch (e) {
alert('Error: ' + e.message);
}
};
document.getElementById('btnMarcarEntregada').addEventListener('click', async () => {
if (!confirm('¿Marcar esta orden como entregada?')) return;
await cambiarEstado('orden', ORDEN_ID, 'entregada');
});
// ── Registrar muestra ─────────────────────────────────────
document.getElementById('btnGuardarMuestra').addEventListener('click', async () => {
const btn = document.getElementById('btnGuardarMuestra');
btn.disabled = true;
try {
const r = await fetch(API_CAM, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tipo: 'muestra',
id: null,
orden_id: ORDEN_ID,
tipo_muestra: document.getElementById('mTipoMuestra').value,
codigo_barras: document.getElementById('mCodigoBarras').value.trim(),
estado: document.getElementById('mEstadoMuestra').value,
}),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
bootstrap.Modal.getInstance(document.getElementById('modalMuestra')).hide();
document.getElementById('mCodigoBarras').value = '';
await cargarOrden();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btn.disabled = false;
}
});
// ── Modal Resultados ──────────────────────────────────────
window.abrirResultados = function(itemId, nombre, resultados) {
document.getElementById('resItemId').value = itemId;
document.getElementById('modalResTitle').innerHTML =
`<i class="fas fa-flask me-2"></i>Resultados — ${escHtml(nombre)}`;
renderFilasResultados(resultados);
bootstrap.Modal.getOrCreateInstance(document.getElementById('modalResultados')).show();
};
function renderFilasResultados(resultados) {
const cont = document.getElementById('tablaResultados');
const filas = resultados.length > 0 ? resultados : [{}];
cont.innerHTML = `
<table class="table table-sm table-bordered" id="tResultados">
<thead class="table-light">
<tr>
<th>Campo</th>
<th>Valor</th>
<th>Unidad</th>
<th>Referencia</th>
<th class="text-center">Anormal</th>
<th></th>
</tr>
</thead>
<tbody id="tbodyRes">${filas.map(r => buildFilaRes(r)).join('')}</tbody>
</table>`;
}
function buildFilaRes(r = {}) {
return `<tr>
<td><input class="form-control form-control-sm r-campo" value="${escAtt(r.campo_nombre||'')}" placeholder="Hemoglobina"></td>
<td><input class="form-control form-control-sm r-valor" value="${escAtt(r.valor_texto||r.valor_numerico||'')}" placeholder="14.5"></td>
<td><input class="form-control form-control-sm r-unidad" value="${escAtt(r.unidad||'')}" placeholder="g/dL" style="width:80px"></td>
<td><input class="form-control form-control-sm r-ref" value="${escAtt(r.referencia_texto || (r.referencia_min!=null ? r.referencia_min+' '+r.referencia_max : ''))}" placeholder="12.0 16.0"></td>
<td class="text-center"><input type="checkbox" class="form-check-input r-anormal" ${r.es_anormal ? 'checked' : ''}></td>
<td><button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('tr').remove()"><i class="fas fa-times"></i></button></td>
</tr>`;
}
document.getElementById('btnAgregarFila').addEventListener('click', () => {
document.getElementById('tbodyRes').insertAdjacentHTML('beforeend', buildFilaRes());
});
document.getElementById('btnGuardarResultados').addEventListener('click', async () => {
const itemId = parseInt(document.getElementById('resItemId').value);
const filas = [...document.querySelectorAll('#tbodyRes tr')];
const resultados = filas.map(tr => ({
campo_nombre: tr.querySelector('.r-campo').value.trim(),
valor_texto: tr.querySelector('.r-valor').value.trim(),
valor_numerico: parseFloat(tr.querySelector('.r-valor').value) || null,
unidad: tr.querySelector('.r-unidad').value.trim(),
referencia_texto: tr.querySelector('.r-ref').value.trim(),
es_anormal: tr.querySelector('.r-anormal').checked,
})).filter(r => r.campo_nombre !== '');
if (!resultados.length) { alert('Ingrese al menos un campo de resultado.'); return; }
const btn = document.getElementById('btnGuardarResultados');
btn.disabled = true;
try {
const r = await fetch(API_RES, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: itemId, resultados }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
bootstrap.Modal.getInstance(document.getElementById('modalResultados')).hide();
await cargarOrden();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btn.disabled = false;
}
});
// ── Helpers ───────────────────────────────────────────────
function labelEstItem(e) {
return {
pendiente: 'Pendiente',
muestra_tomada: 'Muestra tomada',
en_proceso: 'En proceso',
resultado_listo: 'Resultado listo',
entregado: 'Entregado',
}[e] || e;
}
function badgePrio(p) {
return {stat:'<span class="badge bg-danger">STAT</span>',urgente:'<span class="badge bg-warning text-dark">Urgente</span>',normal:'<span class="badge bg-secondary">Normal</span>'}[p]||'';
}
function badgeEstado(e) {
return {pendiente:'<span class="badge bg-light text-dark border">Pendiente</span>',en_proceso:'<span class="badge bg-primary">En proceso</span>',completa:'<span class="badge bg-success">Completa</span>',entregada:'<span class="badge bg-info text-dark">Entregada</span>',cancelada:'<span class="badge bg-dark">Cancelada</span>'}[e]||e;
}
function escHtml(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAtt(s) { return String(s||'').replace(/"/g,'&quot;'); }
// ── Inicio ───────────────────────────────────────────────
cargarOrden();
})();
</script>
<?php Layout::close(); ?>
+219
View File
@@ -0,0 +1,219 @@
<?php
/**
* Helpers compartidos para los endpoints del módulo Turnero.
* Incluido por cada endpoint de modules/turnero/api/.
*/
ob_start();
require_once __DIR__ . '/../../../config/config.php';
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', '0');
ini_set('html_errors', '0');
register_shutdown_function(function () {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
ob_clean();
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Error interno: ' . $err['message']]);
}
});
header('Content-Type: application/json; charset=utf-8');
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
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
/**
* POST /modules/turnero/api/cambiar_estado.php
* Avanza o cancela el estado de un turno.
* Requiere login + módulo turnero.
*
* Body JSON:
* turno_id int requerido
* nuevo_estado string requerido (ver transiciones válidas abajo)
* lugar_id int requerido cuando nuevo_estado = "en_espera_lugar"
*
* Transiciones permettidas:
* espera → en_recepcion
* en_recepcion → en_espera_lugar, ausente, cancelado
* en_espera_lugar → en_servicio, ausente, cancelado
* en_servicio → finalizado, ausente, cancelado
* (cualquier) → ausente, cancelado
*
* Bloqueo especial:
* en_espera_lugar → en_servicio requiere que todos los consentimientos
* del turno estén en estado 'firmado' o 'rechazado'.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$datos = inputJson();
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
$nuevoEstado = isset($datos['nuevo_estado']) ? trim((string) $datos['nuevo_estado']) : '';
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
// ── Validación básica ─────────────────────────────────────────
if ($turnoId <= 0) jsonError('turno_id inválido.');
if ($nuevoEstado === '') jsonError('nuevo_estado es requerido.');
// Mapa de transiciones válidas [estado_actual => [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);
}
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* POST /modules/turnero/api/create_solicitud.php
* Crea la solicitud interna de un turno: vincula paciente, lugar y exámenes.
* Además devuelve qué consentimientos serán necesarios (sin enviarlos aún).
* Requiere login + módulo turnero.
*
* Body JSON:
* turno_id int requerido
* paciente_id int requerido
* lugar_id int requerido
* exam_tipo_ids int[] requerido (al menos 1)
* total_cobrado float opcional
* metodo_pago string opcional
* observaciones string opcional
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$datos = inputJson();
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
$pacienteId = isset($datos['paciente_id']) ? (int) $datos['paciente_id'] : 0;
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : 0;
$examIds = isset($datos['exam_tipo_ids']) && is_array($datos['exam_tipo_ids'])
? array_filter(array_map('intval', $datos['exam_tipo_ids'])) : [];
$total = isset($datos['total_cobrado']) && $datos['total_cobrado'] !== null
? (float) $datos['total_cobrado'] : null;
$metodoPago = isset($datos['metodo_pago']) ? trim((string) $datos['metodo_pago']) : null;
$obs = isset($datos['observaciones']) ? trim((string) $datos['observaciones']) : null;
// ── Validaciones ──────────────────────────────────────────────
if ($turnoId <= 0) jsonError('turno_id inválido.');
if ($pacienteId <= 0) jsonError('paciente_id inválido.');
if ($lugarId <= 0) jsonError('lugar_id inválido.');
if (empty($examIds)) jsonError('Debe seleccionar al menos un examen.');
$metodosValidos = ['efectivo', 'transferencia', 'tarjeta', 'eps', 'cortesia', ''];
if ($metodoPago && !in_array($metodoPago, $metodosValidos, true)) {
jsonError('metodo_pago inválido.');
}
$pdo = db();
$pdo->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);
}
+113
View File
@@ -0,0 +1,113 @@
<?php
/**
* POST /modules/turnero/api/create_turno.php
* Genera un nuevo turno desde el kiosko o recepción.
* No requiere login (kiosko es público).
*
* Body JSON:
* prioridad_codigo string requerido "A"|"B"|"C"|"D"|"E"|"F"
* paciente_nombre string opcional
* paciente_cel string opcional
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
// Este endpoint es público (kiosko no requiere login)
// Solo validamos CSRF básico: debe ser POST con JSON
$datos = inputJson();
// ── Validación ────────────────────────────────────────────────
$prioCodigo = strtoupper(trim($datos['prioridad_codigo'] ?? ''));
if (!in_array($prioCodigo, ['A', 'B', 'C', 'D', 'E', 'F'], true)) {
jsonError('prioridad_codigo inválido. Use A, B, C, D, E o F.');
}
$pacienteNombre = substr(trim($datos['paciente_nombre'] ?? ''), 0, 150);
$pacienteCel = preg_replace('/[^0-9+\- ]/', '', $datos['paciente_cel'] ?? '');
$pacienteCel = substr($pacienteCel, 0, 20);
// ── Lógica principal (dentro de transacción para evitar race conditions) ──
$pdo = db();
$pdo->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);
}
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* modules/turnero/api/export_csv.php
* GET ?fecha=YYYY-MM-DD → descarga CSV con todos los turnos del día
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
$fecha = trim($_GET['fecha'] ?? '');
if ($fecha === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
$fecha = date('Y-m-d');
}
$pdo = db();
// Buscar sesión del día
$stmtS = $pdo->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;
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* GET /modules/turnero/api/get_cola.php
* Devuelve el estado de la cola para pantallas TV y kiosko.
* No requiere autenticación (acceso público).
*
* Query params:
* area string requerido "recepcion" | "lugar"
* lugar_id int requerido si area = "lugar"
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
$area = isset($_GET['area']) ? trim($_GET['area']) : '';
$lugarId = isset($_GET['lugar_id']) ? (int) $_GET['lugar_id'] : null;
if (!in_array($area, ['recepcion', 'lugar'], true)) {
jsonError('area debe ser "recepcion" o "lugar".');
}
if ($area === 'lugar' && !$lugarId) {
jsonError('lugar_id es obligatorio cuando area = "lugar".');
}
$pdo = db();
// ── Sesión de hoy ─────────────────────────────────────────────
$sesionId = obtenerOCrearSesionHoy();
// ── Cola según área ───────────────────────────────────────────
if ($area === 'recepcion') {
$estadosCola = ["'espera'", "'en_recepcion'"];
$filtroLugar = '';
$bindsCola = [];
} else {
$estadosCola = ["'en_espera_lugar'", "'en_servicio'"];
$filtroLugar = 'AND t.lugar_destino_id = ?';
$bindsCola = [$lugarId];
}
$inEstados = implode(', ', $estadosCola);
$stmt = $pdo->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'),
]);
@@ -0,0 +1,98 @@
<?php
/**
* GET /modules/turnero/api/get_consentimientos.php
* Retorna el estado actualizado de los consentimientos de un turno.
* Usado por lugar.php (polling cada 5 s) y recepcion.php.
* No requiere autenticación (las pantallas internas ya están protegidas).
*
* Query params:
* turno_id int requerido
* incluir_solicitud int opcional 1 → también retorna solicitud, paciente y exámenes
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
$turnoId = isset($_GET['turno_id']) ? (int) $_GET['turno_id'] : 0;
$incluirSolicitud = isset($_GET['incluir_solicitud']) ? (bool)(int)$_GET['incluir_solicitud'] : false;
if ($turnoId <= 0) jsonError('turno_id inválido.');
$pdo = db();
// ── Verificar que el turno existe ─────────────────────────────
$stmt = $pdo->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);
+161
View File
@@ -0,0 +1,161 @@
<?php
/**
* modules/turnero/api/get_dashboard.php
* GET ?fecha=YYYY-MM-DD (por defecto = hoy)
*
* Devuelve métricas del día para el dashboard del turnero:
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
* - desglose por prioridad
* - desglose por lugar
* - lista de turnos del día (para tabla detalle)
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('GET');
$fecha = trim($_GET['fecha'] ?? '');
if ($fecha === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
$fecha = date('Y-m-d');
}
$pdo = db();
// ── 1. Sesión del día ─────────────────────────────────────────
$stmtSesion = $pdo->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,
]);
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* POST /modules/turnero/api/llamar_turno.php
* Llama el siguiente turno de una cola con el motor de prioridades.
* Requiere login + módulo turnero.
*
* Body JSON:
* area string requerido "recepcion" | "lugar"
* lugar_id int requerido si area = "lugar"
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$datos = inputJson();
$area = $datos['area'] ?? '';
$lugarId = isset($datos['lugar_id']) ? (int) $datos['lugar_id'] : null;
// ── Validación ────────────────────────────────────────────────
if (!in_array($area, ['recepcion', 'lugar'], true)) {
jsonError('area debe ser "recepcion" o "lugar".');
}
if ($area === 'lugar' && !$lugarId) {
jsonError('lugar_id es obligatorio cuando area = "lugar".');
}
$pdo = db();
$pdo->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);
}
+100
View File
@@ -0,0 +1,100 @@
<?php
/**
* modules/turnero/api/save_ex_tipo.php
* GET ?id=X → devuelve datos de un tipo de examen (incluyendo formulario_id vinculado)
* POST {codigo, nombre, categoria, formulario_id, activo} → crear
* POST {id, codigo, nombre, categoria, formulario_id, activo} → actualizar
* POST {id, _delete: true} → eliminar
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
// ── GET: devolver datos de un tipo de examen ─────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) jsonError('ID requerido');
$stmt = db()->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);
}
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* modules/turnero/api/save_lugar.php
* GET ?id=X → devuelve datos de un lugar
* POST {nombre, descripcion, sort_order, activo} → crear
* POST {id, nombre, descripcion, sort_order, activo} → actualizar
* POST {id, _delete: true} → eliminar
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
// ── GET: devolver datos de un lugar ──────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) jsonError('ID requerido');
$row = db()->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');
}
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* modules/turnero/api/save_prioridad.php
* POST {id, nombre, color, orden_peso, activo} → actualizar prioridad existente
* (Las prioridades no se crean ni eliminan vía API — se gestionan en la migración)
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('POST');
$input = inputJson();
$id = (int)($input['id'] ?? 0);
$nombre = trim($input['nombre'] ?? '');
$color = trim($input['color'] ?? '#6b7280');
$ordenPeso = (int)($input['orden_peso'] ?? 1);
$activo = (int)($input['activo'] ?? 1);
if (!$id) jsonError('ID requerido');
if ($nombre === '') jsonError('El nombre es requerido');
if ($ordenPeso < 1) jsonError('El orden debe ser mayor a 0');
if (!preg_match('/^#[0-9a-f]{6}$/i', $color)) jsonError('Color hexadecimal inválido (formato: #RRGGBB)');
$stmt = db()->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');
+236
View File
@@ -0,0 +1,236 @@
<?php
/**
* POST /modules/turnero/api/send_consentimiento.php
* Genera y/o reenvía consentimientos informados por WhatsApp para un turno.
*
* Lógica:
* 1. Lee los turnero_examen_items del turno.
* 2. Para cada exam_tipo_id busca en exam_tipo_consentimientos qué formulario_id requiere.
* 3. Deduplica: varios exámenes que apunten al mismo formulario → 1 solo envío.
* 4. Por cada formulario distinto:
* a. Si ya existe un turnero_consentimientos con ese (turno_id, formulario_id) → reutiliza / reenvía.
* b. Si no existe: genera token UUID, inserta con estado 'pendiente'.
* 5. Envía mensaje WhatsApp al celular del turno / paciente con enlace de firma.
* 6. Actualiza estado → 'enviado' y registra enviado_at.
*
* Body JSON:
* turno_id int requerido
*
* Requiere login + módulo turnero.
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
require_once __DIR__ . '/../../../services/WhatsAppService.php';
$datos = inputJson();
$turnoId = isset($datos['turno_id']) ? (int) $datos['turno_id'] : 0;
if ($turnoId <= 0) jsonError('turno_id inválido.');
$pdo = db();
// ── 1. Cargar turno y su solicitud ────────────────────────────
$stmt = $pdo->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);
}
+103
View File
@@ -0,0 +1,103 @@
<?php
/**
* modules/turnero/api/sesion_turno.php
*
* POST {accion: 'abrir'} → Crear/abrir sesión de hoy
* POST {accion: 'cerrar', sesion_id: X} → Cerrar sesión activa
* POST {accion: 'reabrir', sesion_id: X} → Reabrir sesión (borra fin_at)
* POST {accion: 'config_wa', template, lang} → Guardar config WhatsApp turnero
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('POST');
$input = inputJson();
$accion = trim($input['accion'] ?? '');
if (!in_array($accion, ['abrir', 'cerrar', 'reabrir', 'config_wa'], true)) {
jsonError('Acción no válida');
}
$pdo = db();
$uid = adminId();
// ── ABRIR sesión del día ─────────────────────────────────────
if ($accion === 'abrir') {
$hoy = date('Y-m-d');
// Verificar si ya existe
$stmt = $pdo->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');
}
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* GET /modules/turnero/api/sse_turno.php
* Server-Sent Events — actualiza pantallas TV y kiosko en tiempo real.
* No requiere autenticación (acceso público).
*
* Query params:
* area string opcional "recepcion" | "lugar" (default: "recepcion")
* lugar_id int requerido si area = "lugar"
*
* Eventos emitidos:
* cola_update — estado completo de la cola cada vez que haya cambio o cada 30 s
* ping — comentario de keep-alive cada 15 s
*/
// ── Asegurar que la columna sse_ping_at exista (idempotente) ──
require_once __DIR__ . '/_helpers.php';
$pdo = db();
$pdo->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;
}
}
+857
View File
@@ -0,0 +1,857 @@
<?php
/**
* modules/turnero/views/configuracion.php
* Panel de configuración del módulo Turnero — 4 pestañas:
* TAB 1: Lugares / estaciones
* TAB 2: Exámenes y Consentimientos
* TAB 3: Prioridades
* TAB 4: Sesión y WhatsApp
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
$db = Database::getInstance();
$pdo = $db->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';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Configuración Turnero</title>
<link rel="stylesheet" href="<?= BASE_URL ?>assets/css/bootstrap.min.css">
<link rel="stylesheet" href="<?= BASE_URL ?>assets/css/fontawesome.min.css">
<style>
body { background: #f1f5f9; }
.page-header { background:#fff; border-bottom:1px solid #e2e8f0; padding:16px 24px; display:flex; align-items:center; gap:12px; }
.page-header h1 { font-size:1.25rem; font-weight:700; color:#1e293b; margin:0; }
.page-header .back-btn { color:#64748b; text-decoration:none; font-size:.9rem; }
.page-header .back-btn:hover { color:#0f172a; }
.content-wrap { max-width:1000px; margin:24px auto; padding:0 16px; }
.nav-tabs .nav-link { color:#475569; font-weight:500; }
.nav-tabs .nav-link.active { color:#1565c0; border-bottom:2px solid #1565c0; }
.tab-card { background:#fff; border:1px solid #e2e8f0; border-top:none; border-radius:0 0 10px 10px; padding:24px; }
.section-title { font-size:.75rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:#64748b; margin:0 0 14px; }
.item-row { display:flex; align-items:center; gap:8px; padding:10px 0; border-bottom:1px solid #f1f5f9; }
.item-row:last-child { border-bottom:none; }
.item-row .item-name { flex:1; font-weight:500; color:#1e293b; }
.btn-icon { border:none; background:none; color:#94a3b8; cursor:pointer; padding:4px 6px; border-radius:6px; }
.btn-icon:hover { background:#f1f5f9; color:#475569; }
.badge-color { width:14px; height:14px; border-radius:50%; display:inline-block; flex-shrink:0; }
.form-add { background:#f8fafc; border:1.5px dashed #cbd5e1; border-radius:8px; padding:16px; margin-top:16px; }
.url-box { font-family:monospace; font-size:.78rem; background:#f0f4ff; border:1px solid #c7d2fe; border-radius:6px; padding:6px 10px; color:#3730a3; word-break:break-all; }
.url-row { display:flex; align-items:center; gap:8px; }
.toast-stack { position:fixed; bottom:24px; right:24px; z-index:9999; display:flex; flex-direction:column; gap:8px; }
.session-badge { font-size:.75rem; padding:2px 8px; border-radius:99px; font-weight:600; }
.session-open { background:#dcfce7; color:#15803d; }
.session-closed{ background:#f1f5f9; color:#64748b; }
.consent-tag { font-size:.7rem; background:#ede9fe; color:#5b21b6; border-radius:99px; padding:1px 7px; white-space:nowrap; }
</style>
</head>
<body>
<div class="page-header">
<a href="<?= BASE_URL ?>modules/turnero/views/recepcion.php" class="back-btn">
<i class="fas fa-arrow-left me-1"></i>Volver
</a>
<h1><i class="fas fa-cogs me-2 text-primary"></i>Configuración del Turnero</h1>
</div>
<div class="content-wrap">
<ul class="nav nav-tabs" id="cfgTabs">
<li class="nav-item">
<a class="nav-link <?= $tab==='lugares'?'active':'' ?>" href="?tab=lugares">
<i class="fas fa-map-marker-alt me-1"></i>Lugares
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $tab==='examenes'?'active':'' ?>" href="?tab=examenes">
<i class="fas fa-flask me-1"></i>Exámenes y Consentimientos
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $tab==='prioridades'?'active':'' ?>" href="?tab=prioridades">
<i class="fas fa-sort-amount-up me-1"></i>Prioridades
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $tab==='sesion'?'active':'' ?>" href="?tab=sesion">
<i class="fas fa-calendar-day me-1"></i>Sesión y WhatsApp
</a>
</li>
</ul>
<div class="tab-card">
<!-- ════════════════════════════════════════
TAB 1 — LUGARES
════════════════════════════════════════ -->
<?php if ($tab === 'lugares'): ?>
<p class="section-title">Estaciones de servicio configurables</p>
<div id="lista-lugares">
<?php foreach ($lugares as $lu): ?>
<div class="item-row" data-id="<?= $lu['id'] ?>">
<span class="drag-handle text-muted me-1" style="cursor:grab" title="Reordenar">
<i class="fas fa-grip-vertical"></i>
</span>
<span class="item-name"><?= htmlspecialchars($lu['nombre']) ?></span>
<?php if ($lu['descripcion']): ?>
<small class="text-muted d-none d-md-inline"><?= htmlspecialchars($lu['descripcion']) ?></small>
<?php endif; ?>
<span class="badge <?= $lu['activo'] ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary' ?> ms-1">
<?= $lu['activo'] ? 'Activo' : 'Inactivo' ?>
</span>
<button class="btn-icon" onclick="copiarUrlDisplay(<?= $lu['id'] ?>)" title="Copiar URL pantalla TV">
<i class="fas fa-tv"></i>
</button>
<button class="btn-icon text-primary" onclick="editarLugar(<?= $lu['id'] ?>,'<?= addslashes($lu['nombre']) ?>','<?= addslashes($lu['descripcion'] ?? '') ?>',<?= $lu['activo'] ?>,<?= $lu['sort_order'] ?>)" title="Editar">
<i class="fas fa-pencil-alt"></i>
</button>
<button class="btn-icon text-danger" onclick="eliminarLugar(<?= $lu['id'] ?>, '<?= addslashes($lu['nombre']) ?>')" title="Eliminar">
<i class="fas fa-trash"></i>
</button>
</div>
<?php endforeach; ?>
<?php if (empty($lugares)): ?>
<p class="text-muted text-center py-3">No hay lugares configurados</p>
<?php endif; ?>
</div>
<div class="form-add mt-4">
<p class="section-title mb-3"><i class="fas fa-plus me-1"></i>Agregar lugar</p>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small fw-semibold">Nombre <span class="text-danger">*</span></label>
<input type="text" id="lu-nombre" class="form-control form-control-sm" placeholder="Toma de Muestras 3">
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Descripción</label>
<input type="text" id="lu-desc" class="form-control form-control-sm" placeholder="Opcional">
</div>
<div class="col-md-2">
<label class="form-label small fw-semibold">Orden</label>
<input type="number" id="lu-orden" class="form-control form-control-sm" value="<?= count($lugares) + 1 ?>" min="1">
</div>
<div class="col-md-2">
<button class="btn btn-primary btn-sm w-100" onclick="guardarLugar()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
</div>
<!-- Modal editar lugar -->
<div class="modal fade" id="modalEditarLugar" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2 px-3">
<h6 class="modal-title">Editar lugar</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-lu-id">
<div class="mb-2">
<label class="form-label small fw-semibold">Nombre</label>
<input type="text" id="edit-lu-nombre" class="form-control form-control-sm">
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Descripción</label>
<input type="text" id="edit-lu-desc" class="form-control form-control-sm">
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Orden</label>
<input type="number" id="edit-lu-orden" class="form-control form-control-sm" min="1">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-lu-activo">
<label class="form-check-label small" for="edit-lu-activo">Activo</label>
</div>
</div>
<div class="modal-footer py-2 px-3">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-primary btn-sm" onclick="guardarEditarLugar()">Guardar</button>
</div>
</div>
</div>
</div>
<!-- URL panel TV por lugar -->
<div class="mt-4">
<p class="section-title"><i class="fas fa-desktop me-1"></i>URLs de pantalla TV por lugar</p>
<?php foreach ($lugares as $lu): ?>
<div class="mb-2">
<small class="fw-semibold text-muted d-block mb-1"><?= htmlspecialchars($lu['nombre']) ?></small>
<div class="url-row">
<div class="url-box flex-1"
id="url-lugar-<?= $lu['id'] ?>"
data-url="<?= htmlspecialchars(BASE_URL . 'modules/turnero/views/display.php?display=lugar&lugar_id=' . $lu['id']) ?>"
><?= htmlspecialchars(BASE_URL . 'modules/turnero/views/display.php?display=lugar&lugar_id=' . $lu['id']) ?></div>
<button class="btn btn-outline-secondary btn-sm" onclick="copiarUrl('url-lugar-<?= $lu['id'] ?>')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<?php endforeach; ?>
<div class="mb-2 mt-3">
<small class="fw-semibold text-muted d-block mb-1">Recepción (pantalla general)</small>
<div class="url-row">
<div class="url-box flex-1"
id="url-recepcion"
data-url="<?= htmlspecialchars(BASE_URL . 'modules/turnero/views/display.php?display=recepcion') ?>"
><?= htmlspecialchars(BASE_URL . 'modules/turnero/views/display.php?display=recepcion') ?></div>
<button class="btn btn-outline-secondary btn-sm" onclick="copiarUrl('url-recepcion')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="mb-2 mt-1">
<small class="fw-semibold text-muted d-block mb-1">Kiosko de turnos (tablet/pantalla entrada)</small>
<div class="url-row">
<div class="url-box flex-1"
id="url-kiosko"
data-url="<?= htmlspecialchars(BASE_URL . 'modules/turnero/views/kiosko.php') ?>"
><?= htmlspecialchars(BASE_URL . 'modules/turnero/views/kiosko.php') ?></div>
<button class="btn btn-outline-secondary btn-sm" onclick="copiarUrl('url-kiosko')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
<!-- ════════════════════════════════════════
TAB 2 — EXÁMENES Y CONSENTIMIENTOS
════════════════════════════════════════ -->
<?php elseif ($tab === 'examenes'): ?>
<p class="section-title">Tipos de examen y sus formularios de consentimiento</p>
<?php
// Agrupar por categoría
$categorias = [];
foreach ($examTipos as $et) {
$cat = $et['categoria'] ?: 'Sin categoría';
$categorias[$cat][] = $et;
}
?>
<?php foreach ($categorias as $cat => $items): ?>
<div class="mb-3">
<div class="fw-semibold text-uppercase small text-secondary mb-1" style="font-size:.7rem;letter-spacing:.07em">
<i class="fas fa-tag me-1"></i><?= htmlspecialchars($cat) ?>
</div>
<?php foreach ($items as $et):
$formIds = $et['form_ids'] ? array_map('intval', explode(',', $et['form_ids'])) : [];
?>
<div class="item-row" data-exam-id="<?= $et['id'] ?>">
<span class="item-name">
<span class="badge bg-primary-subtle text-primary me-2" style="font-size:.7rem"><?= htmlspecialchars($et['codigo']) ?></span>
<?= htmlspecialchars($et['nombre']) ?>
</span>
<?php if (!$et['activo']): ?>
<span class="badge bg-secondary-subtle text-secondary" style="font-size:.65rem">Inactivo</span>
<?php endif; ?>
<?php
// Etiquetas de consentimientos vinculados
foreach ($formIds as $fid):
$fNombre = '';
foreach ($formulariosCons as $fc) {
if ($fc['id'] == $fid) { $fNombre = $fc['nombre']; break; }
}
?>
<span class="consent-tag"><i class="fas fa-file-signature me-1"></i><?= htmlspecialchars($fNombre ?: 'Form #'.$fid) ?></span>
<?php endforeach; ?>
<button class="btn-icon text-primary" onclick="editarExamen(<?= $et['id'] ?>)" title="Editar">
<i class="fas fa-pencil-alt"></i>
</button>
<button class="btn-icon text-danger" onclick="eliminarExamen(<?= $et['id'] ?>, '<?= addslashes($et['nombre']) ?>')" title="Eliminar">
<i class="fas fa-trash"></i>
</button>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<?php if (empty($examTipos)): ?>
<p class="text-muted text-center py-3">No hay tipos de examen configurados</p>
<?php endif; ?>
<div class="form-add mt-4">
<p class="section-title mb-3"><i class="fas fa-plus me-1"></i>Agregar tipo de examen</p>
<div class="row g-2">
<div class="col-md-2">
<label class="form-label small fw-semibold">Código <span class="text-danger">*</span></label>
<input type="text" id="ex-codigo" class="form-control form-control-sm" placeholder="HEM" maxlength="20">
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Nombre <span class="text-danger">*</span></label>
<input type="text" id="ex-nombre" class="form-control form-control-sm" placeholder="Hemograma completo">
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold">Categoría</label>
<input type="text" id="ex-categoria" class="form-control form-control-sm" placeholder="Hematología">
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold">Consentimiento</label>
<select id="ex-form" class="form-select form-select-sm">
<option value="">— Sin consentimiento —</option>
<?php foreach ($formulariosCons as $fc): ?>
<option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['nombre']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 text-end">
<button class="btn btn-primary btn-sm" onclick="guardarExamen()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
</div>
<?php if (!empty($formulariosCons)): ?>
<div class="mt-4">
<p class="section-title"><i class="fas fa-file-signature me-1"></i>Vista inversa: por consentimiento</p>
<?php foreach ($formulariosCons as $fc):
$exsVinculados = [];
foreach ($examTipos as $et) {
$fids = $et['form_ids'] ? array_map('intval', explode(',', $et['form_ids'])) : [];
if (in_array((int)$fc['id'], $fids)) $exsVinculados[] = $et;
}
?>
<div class="mb-2">
<span class="fw-semibold small"><?= htmlspecialchars($fc['nombre']) ?></span>
<span class="text-muted small ms-2">
<?= empty($exsVinculados)
? '<em>Sin exámenes vinculados</em>'
: implode(', ', array_map(fn($e)=>'<code>'.$e['codigo'].'</code>', $exsVinculados))
?>
</span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Modal editar examen -->
<div class="modal fade" id="modalEditarExamen" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header py-2 px-3">
<h6 class="modal-title">Editar tipo de examen</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-ex-id">
<div class="row g-2">
<div class="col-md-3">
<label class="form-label small fw-semibold">Código</label>
<input type="text" id="edit-ex-codigo" class="form-control form-control-sm" maxlength="20">
</div>
<div class="col-md-9">
<label class="form-label small fw-semibold">Nombre</label>
<input type="text" id="edit-ex-nombre" class="form-control form-control-sm">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Categoría</label>
<input type="text" id="edit-ex-categoria" class="form-control form-control-sm">
</div>
<div class="col-md-6">
<label class="form-label small fw-semibold">Consentimiento</label>
<select id="edit-ex-form" class="form-select form-select-sm">
<option value="">— Sin consentimiento —</option>
<?php foreach ($formulariosCons as $fc): ?>
<option value="<?= $fc['id'] ?>"><?= htmlspecialchars($fc['nombre']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-ex-activo">
<label class="form-check-label small" for="edit-ex-activo">Activo</label>
</div>
</div>
</div>
</div>
<div class="modal-footer py-2 px-3">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-primary btn-sm" onclick="guardarEditarExamen()">Guardar</button>
</div>
</div>
</div>
</div>
<!-- ════════════════════════════════════════
TAB 3 — PRIORIDADES
════════════════════════════════════════ -->
<?php elseif ($tab === 'prioridades'): ?>
<p class="section-title">Códigos de prioridad (menor orden_peso = mayor prioridad)</p>
<div id="lista-prioridades">
<?php foreach ($prioridades as $pr): ?>
<div class="item-row" data-id="<?= $pr['id'] ?>">
<span class="badge-color me-2" style="background:<?= htmlspecialchars($pr['color']) ?>"></span>
<span class="fw-bold me-2" style="min-width:28px; color:<?= htmlspecialchars($pr['color']) ?>"><?= htmlspecialchars($pr['codigo']) ?></span>
<span class="item-name"><?= htmlspecialchars($pr['nombre']) ?></span>
<small class="text-muted me-2">Orden: <?= $pr['orden_peso'] ?></small>
<span class="badge <?= $pr['activo'] ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary' ?>">
<?= $pr['activo'] ? 'Activo' : 'Inactivo' ?>
</span>
<button class="btn-icon text-primary" onclick="editarPrioridad(<?= $pr['id'] ?>,'<?= addslashes($pr['codigo']) ?>','<?= addslashes($pr['nombre']) ?>','<?= htmlspecialchars($pr['color']) ?>',<?= $pr['orden_peso'] ?>,<?= $pr['activo'] ?>)" title="Editar">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<?php endforeach; ?>
</div>
<p class="text-muted small mt-2">
<i class="fas fa-info-circle me-1"></i>
Los códigos de prioridad no se pueden eliminar (están vinculados a turnos). Solo puede editarlos.
</p>
<!-- Modal editar prioridad -->
<div class="modal fade" id="modalEditarPrioridad" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2 px-3">
<h6 class="modal-title">Editar prioridad</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-pr-id">
<div class="mb-2">
<label class="form-label small fw-semibold">Código</label>
<input type="text" id="edit-pr-codigo" class="form-control form-control-sm" maxlength="1" readonly>
<small class="text-muted">El código no puede cambiarse</small>
</div>
<div class="mb-2">
<label class="form-label small fw-semibold">Nombre</label>
<input type="text" id="edit-pr-nombre" class="form-control form-control-sm">
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label small fw-semibold">Color</label>
<div class="input-group input-group-sm">
<input type="color" id="edit-pr-color" class="form-control form-control-color" style="width:40px;padding:2px">
<input type="text" id="edit-pr-color-text" class="form-control form-control-sm" maxlength="7" placeholder="#6b7280">
</div>
</div>
<div class="col-6">
<label class="form-label small fw-semibold">Orden</label>
<input type="number" id="edit-pr-orden" class="form-control form-control-sm" min="1">
</div>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="edit-pr-activo">
<label class="form-check-label small" for="edit-pr-activo">Activo</label>
</div>
</div>
<div class="modal-footer py-2 px-3">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-primary btn-sm" onclick="guardarPrioridad()">Guardar</button>
</div>
</div>
</div>
</div>
<!-- ════════════════════════════════════════
TAB 4 — SESIÓN Y WHATSAPP
════════════════════════════════════════ -->
<?php elseif ($tab === 'sesion'): ?>
<!-- Sesión de hoy -->
<div class="mb-4">
<p class="section-title"><i class="fas fa-calendar-day me-1"></i>Sesión de hoy</p>
<?php if ($sesionHoy): ?>
<div class="d-flex align-items-center gap-3 flex-wrap">
<div>
<span class="session-badge <?= $sesionHoy['fin_at'] ? 'session-closed' : 'session-open' ?>">
<?= $sesionHoy['fin_at'] ? 'Cerrada' : 'Abierta' ?>
</span>
</div>
<div class="small text-muted">
Fecha: <strong><?= htmlspecialchars($sesionHoy['fecha']) ?></strong> &bull;
Turnos: <strong><?= $sesionHoy['total_turnos'] ?></strong>
<?php if ($sesionHoy['inicio_at']): ?>
&bull; Inicio: <strong><?= date('H:i', strtotime($sesionHoy['inicio_at'])) ?></strong>
<?php endif; ?>
<?php if ($sesionHoy['fin_at']): ?>
&bull; Cierre: <strong><?= date('H:i', strtotime($sesionHoy['fin_at'])) ?></strong>
<?php endif; ?>
</div>
<?php if (!$sesionHoy['fin_at']): ?>
<button class="btn btn-danger btn-sm" onclick="cambiarSesion('cerrar',<?= $sesionHoy['id'] ?>)">
<i class="fas fa-lock me-1"></i>Cerrar sesión del día
</button>
<?php else: ?>
<button class="btn btn-success btn-sm" onclick="cambiarSesion('reabrir',<?= $sesionHoy['id'] ?>)">
<i class="fas fa-lock-open me-1"></i>Reabrir sesión
</button>
<?php endif; ?>
</div>
<?php else: ?>
<p class="text-muted">No hay sesión creada para hoy.</p>
<button class="btn btn-success btn-sm" onclick="cambiarSesion('abrir',0)">
<i class="fas fa-play me-1"></i>Abrir sesión de hoy
</button>
<?php endif; ?>
</div>
<!-- Historial de sesiones -->
<div class="mb-4">
<p class="section-title"><i class="fas fa-history me-1"></i>Historial de sesiones (últimas 30)</p>
<?php if (empty($sesiones)): ?>
<p class="text-muted">Sin historial</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-hover small">
<thead class="table-light">
<tr>
<th>Fecha</th>
<th>Inicio</th>
<th>Cierre</th>
<th>Abierto por</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
<?php foreach ($sesiones as $s): ?>
<tr>
<td><?= htmlspecialchars($s['fecha']) ?></td>
<td><?= $s['inicio_at'] ? date('H:i', strtotime($s['inicio_at'])) : '—' ?></td>
<td><?= $s['fin_at'] ? date('H:i', strtotime($s['fin_at'])) : '—' ?></td>
<td><?= htmlspecialchars($s['abierto_nombre'] ?? '—') ?></td>
<td>
<span class="session-badge <?= $s['fin_at'] ? 'session-closed' : 'session-open' ?>">
<?= $s['fin_at'] ? 'Cerrada' : 'Abierta' ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<!-- Config WhatsApp turnero -->
<div>
<p class="section-title"><i class="fab fa-whatsapp me-1" style="color:#25d366"></i>Plantilla de consentimiento WhatsApp</p>
<div class="row g-2">
<div class="col-md-6">
<label class="form-label small fw-semibold">Nombre de plantilla Meta aprobada</label>
<input type="text" id="wa-template" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['turnero_wa_template'] ?? 'consentimiento_turno') ?>"
placeholder="consentimiento_turno">
<small class="text-muted">Nombre exacto en Meta Business Manager</small>
</div>
<div class="col-md-3">
<label class="form-label small fw-semibold">Código de idioma</label>
<input type="text" id="wa-lang" class="form-control form-control-sm"
value="<?= htmlspecialchars($cfg['turnero_wa_lang'] ?? 'es_CO') ?>"
placeholder="es_CO">
</div>
<div class="col-md-3 d-flex align-items-end">
<button class="btn btn-primary btn-sm w-100" onclick="guardarWhatsApp()">
<i class="fas fa-save me-1"></i>Guardar
</button>
</div>
</div>
<div class="alert alert-info py-2 px-3 mt-3" style="font-size:.82rem">
<i class="fas fa-info-circle me-1"></i>
La plantilla debe estar aprobada en Meta y contener una variable de URL
(<code>{{1}}</code>) para el enlace de firma del consentimiento.
El sistema usará texto plano como fallback si la plantilla falla.
</div>
</div>
<?php endif; ?>
</div><!-- /tab-card -->
</div><!-- /content-wrap -->
<!-- Toast stack -->
<div class="toast-stack" id="toastStack"></div>
<script src="<?= BASE_URL ?>assets/js/bootstrap.bundle.min.js"></script>
<script>
/* ═══════════════════════════════════════════════════════════
Configuración Turnero — JS
═══════════════════════════════════════════════════════════ */
const API = '<?= BASE_URL ?>modules/turnero/api/';
// ── Toast helper ────────────────────────────────────────────
function toast(msg, type = 'success') {
const stack = document.getElementById('toastStack');
const el = document.createElement('div');
el.className = `toast align-items-center text-white bg-${type === 'error' ? 'danger' : 'success'} border-0`;
el.setAttribute('role', 'alert');
el.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
stack.appendChild(el);
new bootstrap.Toast(el, { delay: 3500 }).show();
el.addEventListener('hidden.bs.toast', () => el.remove());
}
// ── Copiar URL al portapapeles ───────────────────────────────
function copiarUrl(elId) {
const url = document.getElementById(elId)?.dataset?.url || document.getElementById(elId)?.textContent || '';
navigator.clipboard.writeText(url.trim()).then(() => toast('URL copiada al portapapeles'));
}
function copiarUrlDisplay(lugarId) {
copiarUrl('url-lugar-' + lugarId);
}
// ─────────────────────────────────────────────────────────────
// TAB 1: LUGARES
// ─────────────────────────────────────────────────────────────
async function guardarLugar(id = null) {
const nombre = (id ? document.getElementById('edit-lu-nombre') : document.getElementById('lu-nombre'))?.value.trim();
const desc = (id ? document.getElementById('edit-lu-desc') : document.getElementById('lu-desc'))?.value.trim();
const orden = parseInt((id ? document.getElementById('edit-lu-orden') : document.getElementById('lu-orden'))?.value) || 99;
const activo = id ? (document.getElementById('edit-lu-activo')?.checked ? 1 : 0) : 1;
if (!nombre) { toast('El nombre es requerido', 'error'); return; }
const body = { nombre, descripcion: desc, sort_order: orden, activo };
if (id) body.id = id;
try {
const res = await fetch(API + 'save_lugar.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast(id ? 'Lugar actualizado' : 'Lugar creado');
if (id) bootstrap.Modal.getInstance(document.getElementById('modalEditarLugar'))?.hide();
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
function editarLugar(id, nombre, desc, activo, orden) {
document.getElementById('edit-lu-id').value = id;
document.getElementById('edit-lu-nombre').value = nombre;
document.getElementById('edit-lu-desc').value = desc;
document.getElementById('edit-lu-orden').value = orden;
document.getElementById('edit-lu-activo').checked = !!activo;
new bootstrap.Modal(document.getElementById('modalEditarLugar')).show();
}
function guardarEditarLugar() {
guardarLugar(parseInt(document.getElementById('edit-lu-id').value));
}
async function eliminarLugar(id, nombre) {
if (!confirm(`¿Eliminar el lugar "${nombre}"?\nSolo se puede si no tiene turnos asignados.`)) return;
try {
const res = await fetch(API + 'save_lugar.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, _delete: true })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error al eliminar', 'error'); return; }
toast('Lugar eliminado');
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
// ─────────────────────────────────────────────────────────────
// TAB 2: EXÁMENES
// ─────────────────────────────────────────────────────────────
async function guardarExamen(id = null) {
const codigo = (id ? document.getElementById('edit-ex-codigo') : document.getElementById('ex-codigo'))?.value.trim().toUpperCase();
const nombre = (id ? document.getElementById('edit-ex-nombre') : document.getElementById('ex-nombre'))?.value.trim();
const categoria = (id ? document.getElementById('edit-ex-categoria'): document.getElementById('ex-categoria'))?.value.trim();
const formId = parseInt((id ? document.getElementById('edit-ex-form') : document.getElementById('ex-form'))?.value) || null;
const activo = id ? (document.getElementById('edit-ex-activo')?.checked ? 1 : 0) : 1;
if (!codigo || !nombre) { toast('Código y nombre son requeridos', 'error'); return; }
const body = { codigo, nombre, categoria, formulario_id: formId, activo };
if (id) body.id = id;
try {
const res = await fetch(API + 'save_ex_tipo.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast(id ? 'Examen actualizado' : 'Examen creado');
if (id) bootstrap.Modal.getInstance(document.getElementById('modalEditarExamen'))?.hide();
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
function editarExamen(id) {
const row = document.querySelector(`[data-exam-id="${id}"]`);
const tags = row ? row.querySelectorAll('.consent-tag') : [];
// Leer datos de los atributos del servidor (via fetch de datos ya cargados)
fetch(API + 'save_ex_tipo.php?id=' + id)
.then(r => r.json())
.then(json => {
if (!json.ok) return;
const d = json.data;
document.getElementById('edit-ex-id').value = d.id;
document.getElementById('edit-ex-codigo').value = d.codigo;
document.getElementById('edit-ex-nombre').value = d.nombre;
document.getElementById('edit-ex-categoria').value= d.categoria || '';
document.getElementById('edit-ex-form').value = d.formulario_id || '';
document.getElementById('edit-ex-activo').checked = !!d.activo;
new bootstrap.Modal(document.getElementById('modalEditarExamen')).show();
})
.catch(() => toast('Error cargando datos', 'error'));
}
function guardarEditarExamen() {
guardarExamen(parseInt(document.getElementById('edit-ex-id').value));
}
async function eliminarExamen(id, nombre) {
if (!confirm(`¿Eliminar el examen "${nombre}"?`)) return;
try {
const res = await fetch(API + 'save_ex_tipo.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, _delete: true })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast('Examen eliminado');
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
// ─────────────────────────────────────────────────────────────
// TAB 3: PRIORIDADES
// ─────────────────────────────────────────────────────────────
function editarPrioridad(id, codigo, nombre, color, orden, activo) {
document.getElementById('edit-pr-id').value = id;
document.getElementById('edit-pr-codigo').value = codigo;
document.getElementById('edit-pr-nombre').value = nombre;
document.getElementById('edit-pr-color').value = color;
document.getElementById('edit-pr-color-text').value = color;
document.getElementById('edit-pr-orden').value = orden;
document.getElementById('edit-pr-activo').checked = !!activo;
new bootstrap.Modal(document.getElementById('modalEditarPrioridad')).show();
}
// Sincronizar input color ↔ texto hex
document.addEventListener('DOMContentLoaded', () => {
const colorPicker = document.getElementById('edit-pr-color');
const colorText = document.getElementById('edit-pr-color-text');
if (colorPicker && colorText) {
colorPicker.addEventListener('input', () => { colorText.value = colorPicker.value; });
colorText.addEventListener('input', () => {
if (/^#[0-9a-f]{6}$/i.test(colorText.value)) {
colorPicker.value = colorText.value;
}
});
}
});
async function guardarPrioridad() {
const id = parseInt(document.getElementById('edit-pr-id').value);
const nombre = document.getElementById('edit-pr-nombre').value.trim();
const color = document.getElementById('edit-pr-color-text').value.trim() || document.getElementById('edit-pr-color').value;
const orden = parseInt(document.getElementById('edit-pr-orden').value) || 1;
const activo = document.getElementById('edit-pr-activo').checked ? 1 : 0;
if (!nombre) { toast('El nombre es requerido', 'error'); return; }
if (!id) { toast('ID no válido', 'error'); return; }
try {
const res = await fetch(API + 'save_prioridad.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, nombre, color, orden_peso: orden, activo })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast('Prioridad actualizada');
bootstrap.Modal.getInstance(document.getElementById('modalEditarPrioridad'))?.hide();
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
// ─────────────────────────────────────────────────────────────
// TAB 4: SESIÓN Y WHATSAPP
// ─────────────────────────────────────────────────────────────
async function cambiarSesion(accion, sesionId) {
const msgs = {
cerrar: '¿Confirma el cierre de la sesión del día?',
reabrir: '¿Reabrir la sesión de hoy?',
abrir: '¿Abrir una nueva sesión para hoy?',
};
if (!confirm(msgs[accion] || '¿Confirmar?')) return;
try {
const res = await fetch(API + 'sesion_turno.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accion, sesion_id: sesionId })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast(json.message || 'Operación completada');
setTimeout(() => location.reload(), 600);
} catch (e) { toast('Error de conexión', 'error'); }
}
async function guardarWhatsApp() {
const template = document.getElementById('wa-template')?.value.trim();
const lang = document.getElementById('wa-lang')?.value.trim();
if (!template) { toast('El nombre de plantilla es requerido', 'error'); return; }
try {
const res = await fetch(API + 'sesion_turno.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accion: 'config_wa', template, lang })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error', 'error'); return; }
toast('Configuración WhatsApp guardada');
} catch (e) { toast('Error de conexión', 'error'); }
}
</script>
</body>
</html>
+374
View File
@@ -0,0 +1,374 @@
<?php
/**
* modules/turnero/views/dashboard.php
* Panel de resumen diario del Turnero.
* Carga datos vía fetch a get_dashboard.php (permite cambiar fecha sin recargar).
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard Turnero</title>
<link rel="stylesheet" href="<?= BASE_URL ?>assets/css/bootstrap.min.css">
<link rel="stylesheet" href="<?= BASE_URL ?>assets/css/fontawesome.min.css">
<style>
body { background:#f1f5f9; }
.page-header {
background:#fff; border-bottom:1px solid #e2e8f0;
padding:14px 24px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;
}
.page-header h1 { font-size:1.18rem; font-weight:700; color:#1e293b; margin:0; flex:1; }
.page-header .back-btn { color:#64748b; text-decoration:none; font-size:.9rem; }
.content-wrap { max-width:1100px; margin:24px auto; padding:0 16px 48px; }
/* ── KPI cards ── */
.kpi-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:14px; margin-bottom:24px; }
.kpi-card {
background:#fff; border:1px solid #e2e8f0; border-radius:12px;
padding:16px 18px; text-align:center;
}
.kpi-card .kpi-val { font-size:2rem; font-weight:800; line-height:1.1; }
.kpi-card .kpi-lbl { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#64748b; margin-top:4px; }
.kpi-card .kpi-sub { font-size:.75rem; color:#94a3b8; margin-top:2px; }
/* ── Charts / tables section ── */
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; margin-bottom:20px; }
.section-title { font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:#64748b; margin:0 0 14px; }
/* ── Priority bar ── */
.pri-row { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
.pri-dot { width:10px; height:10px; border-radius:50%; flex-shrink:0; }
.pri-name { width:160px; font-size:.82rem; color:#334155; }
.pri-bar-wrap { flex:1; background:#f1f5f9; border-radius:99px; height:10px; overflow:hidden; }
.pri-bar-fill { height:100%; border-radius:99px; transition:width .5s; }
.pri-nums { font-size:.78rem; color:#64748b; white-space:nowrap; }
/* ── Lugar cards ── */
.lugar-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:12px; }
.lugar-card { border:1px solid #e2e8f0; border-radius:10px; padding:14px; }
.lugar-card .lc-name { font-weight:600; font-size:.9rem; color:#1e293b; margin-bottom:8px; }
.lugar-card .lc-row { display:flex; justify-content:space-between; font-size:.8rem; color:#475569; margin-bottom:4px; }
.lugar-card .lc-row span:last-child { font-weight:600; color:#0f172a; }
/* ── Tabla de turnos ── */
.estado-badge { font-size:.65rem; padding:2px 7px; border-radius:99px; font-weight:600; white-space:nowrap; }
.eb-espera { background:#fef9c3; color:#92400e; }
.eb-en_recepcion { background:#dbeafe; color:#1e40af; }
.eb-en_espera_lugar { background:#fde68a; color:#92400e; }
.eb-en_servicio { background:#dcfce7; color:#166534; }
.eb-finalizado { background:#f1f5f9; color:#475569; }
.eb-ausente { background:#fee2e2; color:#991b1b; }
.eb-cancelado { background:#f1f5f9; color:#9ca3af; }
/* ── Sesión badge ── */
.sesion-open { background:#dcfce7; color:#15803d; }
.sesion-closed { background:#f1f5f9; color:#64748b; }
/* ── Spinner / empty ── */
#loadingOverlay { min-height:180px; display:flex; align-items:center; justify-content:center; }
.empty-msg { color:#94a3b8; font-size:.9rem; text-align:center; padding:40px 0; }
</style>
</head>
<body>
<div class="page-header">
<a href="<?= BASE_URL ?>modules/turnero/views/recepcion.php" class="back-btn">
<i class="fas fa-arrow-left me-1"></i>Volver
</a>
<h1><i class="fas fa-chart-bar me-2 text-primary"></i>Dashboard Turnero</h1>
<!-- Selector de fecha -->
<div class="d-flex align-items-center gap-2">
<label class="small fw-semibold text-muted mb-0">Fecha:</label>
<input type="date" id="fechaInput" class="form-control form-control-sm" style="width:150px"
value="<?= date('Y-m-d') ?>" max="<?= date('Y-m-d') ?>">
</div>
<button class="btn btn-outline-success btn-sm" id="btnExport" title="Exportar CSV del día">
<i class="fas fa-file-csv me-1"></i>Exportar CSV
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="cargarDatos()" title="Refrescar">
<i class="fas fa-sync-alt"></i>
</button>
<a href="<?= BASE_URL ?>modules/turnero/views/configuracion.php" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-cogs"></i>
</a>
</div>
<div class="content-wrap">
<!-- Estado sesión -->
<div id="sesionInfo" class="mb-3 small text-muted"></div>
<!-- KPIs -->
<div class="kpi-grid" id="kpiGrid">
<div id="loadingOverlay" class="col-span-full">
<div class="text-center text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando...</div>
</div>
</div>
<!-- Consentimientos (solo si hay) -->
<div class="section-card" id="sectionConsent" style="display:none">
<div class="section-title"><i class="fas fa-file-signature me-1"></i>Consentimientos del día</div>
<div id="consentKpis" class="d-flex gap-4 flex-wrap"></div>
</div>
<!-- Desglose por prioridad -->
<div class="section-card" id="sectionPrioridad" style="display:none">
<div class="section-title"><i class="fas fa-sort-amount-up me-1"></i>Por prioridad</div>
<div id="barrasPrioridad"></div>
</div>
<!-- Desglose por lugar -->
<div class="section-card" id="sectionLugar" style="display:none">
<div class="section-title"><i class="fas fa-map-marker-alt me-1"></i>Por lugar / estación</div>
<div class="lugar-grid" id="lugarGrid"></div>
</div>
<!-- Tabla detalle de turnos -->
<div class="section-card">
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<div class="section-title mb-0"><i class="fas fa-list me-1"></i>Detalle de turnos</div>
<input type="text" id="filtroBusqueda" class="form-control form-control-sm"
style="max-width:220px" placeholder="Buscar paciente / código...">
</div>
<div class="table-responsive">
<table class="table table-sm table-hover small align-middle" id="tablaTurnos">
<thead class="table-light">
<tr>
<th>Código</th>
<th>Prioridad</th>
<th>Paciente</th>
<th>Lugar</th>
<th>Estado</th>
<th>Espera</th>
<th>Servicio</th>
<th>Entrada</th>
</tr>
</thead>
<tbody id="tbodyTurnos">
<tr><td colspan="8" class="empty-msg">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<script src="<?= BASE_URL ?>assets/js/bootstrap.bundle.min.js"></script>
<script>
/* ═══════════════════════════════════════════════════════════
Dashboard Turnero
═══════════════════════════════════════════════════════════ */
const API = '<?= BASE_URL ?>modules/turnero/api/';
const BASE_WA = '<?= BASE_URL ?>';
let _turnos = []; // cache para búsqueda local
let _autoReloadId = null;
// ── Entrada ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
cargarDatos();
document.getElementById('fechaInput').addEventListener('change', cargarDatos);
document.getElementById('filtroBusqueda').addEventListener('input', filtrarTabla);
document.getElementById('btnExport').addEventListener('click', exportarCSV);
// Auto-refresh cada 30s solo si la fecha es hoy
_autoReloadId = setInterval(() => {
const hoy = new Date().toISOString().slice(0, 10);
if (document.getElementById('fechaInput').value === hoy) cargarDatos();
}, 30000);
});
// ── Carga principal ──────────────────────────────────────────
async function cargarDatos() {
const fecha = document.getElementById('fechaInput').value;
document.getElementById('kpiGrid').innerHTML =
'<div id="loadingOverlay"><div class="text-center text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando...</div></div>';
document.getElementById('sesionInfo').textContent = '';
try {
const res = await fetch(`${API}get_dashboard.php?fecha=${fecha}`);
const json = await res.json();
if (!json.ok) { mostrarError(json.error || 'Error'); return; }
renderDashboard(json);
} catch (e) {
mostrarError('Error de conexión');
}
}
function mostrarError(msg) {
document.getElementById('kpiGrid').innerHTML =
`<div class="empty-msg text-danger"><i class="fas fa-exclamation-circle me-1"></i>${escHtml(msg)}</div>`;
}
// ── Render ───────────────────────────────────────────────────
function renderDashboard(json) {
const { sesion, resumen, por_prioridad, por_lugar, consent_stats, turnos } = json;
// ── Sesión info ────────────────────────────────────────
const si = document.getElementById('sesionInfo');
if (!sesion) {
si.innerHTML = '<span class="badge bg-secondary-subtle text-secondary">Sin sesión para este día</span>';
} else {
const cls = sesion.abierta ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary';
const est = sesion.abierta ? 'Abierta' : 'Cerrada';
si.innerHTML = `<span class="badge ${cls} me-2">${est}</span>
<span>Inicio: <strong>${sesion.inicio || '—'}</strong></span>
${sesion.fin ? ` &bull; Cierre: <strong>${sesion.fin}</strong>` : ''}`;
}
// ── KPIs ───────────────────────────────────────────────
const total = parseInt(resumen.total || 0);
const atendidos = parseInt(resumen.atendidos || 0);
const enEspera = parseInt(resumen.en_espera || 0);
const ausentes = parseInt(resumen.ausentes || 0);
const cancelados= parseInt(resumen.cancelados || 0);
const tEspera = resumen.tiempo_espera_promedio_min;
const tServicio = resumen.tiempo_servicio_promedio_min;
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
document.getElementById('kpiGrid').innerHTML = `
${kpiCard(total, '📋 Total turnos', '')}
${kpiCard(atendidos,'✅ Atendidos', pct + '%')}
${kpiCard(enEspera, '⏳ En espera', '')}
${kpiCard(ausentes, '🚫 Ausentes', '')}
${kpiCard(cancelados,'❌ Cancelados', '')}
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera prom.', 'hasta recepción')}
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Servicio prom.', 'en lugar')}
`;
// ── Consentimientos ────────────────────────────────────
const totalConsent = Object.values(consent_stats).reduce((a,b)=>a+(+b),0);
const secCons = document.getElementById('sectionConsent');
if (totalConsent > 0) {
secCons.style.display = '';
const labels = { pendiente:'Pendientes', enviado:'Enviados', visto:'Vistos', firmado:'Firmados', rechazado:'Rechazados' };
const colors = { pendiente:'#fef9c3', enviado:'#dbeafe', visto:'#e0f2fe', firmado:'#dcfce7', rechazado:'#fee2e2' };
const tcolors= { pendiente:'#92400e', enviado:'#1e40af', visto:'#0369a1', firmado:'#166534', rechazado:'#991b1b' };
document.getElementById('consentKpis').innerHTML =
Object.entries(consent_stats).map(([est, cnt]) =>
`<div style="text-align:center">
<div style="font-size:1.5rem;font-weight:800;color:${tcolors[est]||'#0f172a'}">${cnt}</div>
<div style="font-size:.7rem;color:#64748b;text-transform:uppercase;letter-spacing:.05em">${labels[est]||est}</div>
</div>`
).join('');
} else {
secCons.style.display = 'none';
}
// ── Barras por prioridad ───────────────────────────────
const secPri = document.getElementById('sectionPrioridad');
if (por_prioridad.length) {
secPri.style.display = '';
const maxTotal = Math.max(...por_prioridad.map(p => +p.total), 1);
document.getElementById('barrasPrioridad').innerHTML = por_prioridad.map(p => {
const pct2 = Math.round(+p.total / maxTotal * 100);
return `<div class="pri-row">
<div class="pri-dot" style="background:${p.color}"></div>
<div class="pri-name">${escHtml(p.codigo + ' ' + p.nombre)}</div>
<div class="pri-bar-wrap">
<div class="pri-bar-fill" style="width:${pct2}%;background:${p.color}"></div>
</div>
<div class="pri-nums">${p.total} (✅${p.atendidos} ⏳${p.pendientes} 🚫${p.ausentes})</div>
</div>`;
}).join('');
} else {
secPri.style.display = 'none';
}
// ── Tarjetas por lugar ─────────────────────────────────
const secLug = document.getElementById('sectionLugar');
if (por_lugar.length) {
secLug.style.display = '';
document.getElementById('lugarGrid').innerHTML = por_lugar.map(l => `
<div class="lugar-card">
<div class="lc-name"><i class="fas fa-map-marker-alt text-primary me-1"></i>${escHtml(l.lugar)}</div>
<div class="lc-row"><span>Total</span><span>${l.total}</span></div>
<div class="lc-row"><span>Atendidos</span><span>${l.atendidos}</span></div>
<div class="lc-row"><span>En espera</span><span>${l.en_espera}</span></div>
<div class="lc-row"><span>T. servicio prom.</span><span>${l.tiempo_servicio_promedio_min !== null ? l.tiempo_servicio_promedio_min + ' min' : '—'}</span></div>
</div>`).join('');
} else {
secLug.style.display = 'none';
}
// ── Tabla de turnos ────────────────────────────────────
_turnos = turnos;
renderTabla(turnos);
}
function kpiCard(val, lbl, sub) {
return `<div class="kpi-card">
<div class="kpi-val">${val !== null && val !== undefined ? escHtml(String(val)) : '—'}</div>
<div class="kpi-lbl">${lbl}</div>
${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''}
</div>`;
}
// ── Tabla detalle ─────────────────────────────────────────────
function renderTabla(turnos) {
const tbody = document.getElementById('tbodyTurnos');
if (!turnos.length) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-msg">Sin turnos para este día</td></tr>';
return;
}
const mapEstado = {
espera: 'eb-espera',
en_recepcion: 'eb-en_recepcion',
en_espera_lugar: 'eb-en_espera_lugar',
en_servicio: 'eb-en_servicio',
finalizado: 'eb-finalizado',
ausente: 'eb-ausente',
cancelado: 'eb-cancelado',
};
const labelEstado = {
espera: 'Espera', en_recepcion: 'Recepción', en_espera_lugar: 'Esp. lugar',
en_servicio: 'En servicio', finalizado: 'Finalizado', ausente: 'Ausente', cancelado: 'Cancelado',
};
tbody.innerHTML = turnos.map(t => {
const nombrePac = escHtml(t.paciente_bd || t.paciente_nombre || '—');
const lugar = escHtml(t.lugar_nombre || '—');
const estadoCls = mapEstado[t.estado] || 'eb-finalizado';
const estadoLbl = labelEstado[t.estado] || t.estado;
const hora = t.creado_at ? new Date(t.creado_at.replace(' ', 'T')).toLocaleTimeString('es-CO', {hour:'2-digit',minute:'2-digit'}) : '—';
const espMin = t.espera_min !== null ? t.espera_min + ' m' : '—';
const srvMin = t.servicio_min !== null ? t.servicio_min + ' m' : '—';
return `<tr>
<td><span class="fw-bold" style="color:${escHtml(t.prioridad_color || '#333')}">${escHtml(t.codigo)}</span></td>
<td><span class="estado-badge" style="background:${escHtml(t.prioridad_color+'22'||'#eee')};color:${escHtml(t.prioridad_color||'#333')}">${escHtml(t.prioridad_codigo)}</span></td>
<td>${nombrePac}</td>
<td>${lugar}</td>
<td><span class="estado-badge ${estadoCls}">${estadoLbl}</span></td>
<td>${espMin}</td>
<td>${srvMin}</td>
<td>${hora}</td>
</tr>`;
}).join('');
}
function filtrarTabla() {
const q = document.getElementById('filtroBusqueda').value.toLowerCase().trim();
if (!q) { renderTabla(_turnos); return; }
renderTabla(_turnos.filter(t =>
(t.codigo || '').toLowerCase().includes(q) ||
(t.paciente_bd || '').toLowerCase().includes(q) ||
(t.paciente_nombre || '').toLowerCase().includes(q)
));
}
// ── Exportar CSV ──────────────────────────────────────────────
function exportarCSV() {
const fecha = document.getElementById('fechaInput').value;
window.location.href = `${API}export_csv.php?fecha=${fecha}`;
}
// ── Helper ────────────────────────────────────────────────────
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str ?? '')));
return d.innerHTML;
}
</script>
</body>
</html>
+425
View File
@@ -0,0 +1,425 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pantalla de Turnos</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
/* ── Base ─────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
height: 100%; width: 100%;
overflow: hidden;
background: #0a0f1e;
color: #f0f4ff;
font-family: 'Segoe UI', system-ui, sans-serif;
}
/* ── Layout ───────────────────────────────────────── */
.display-grid {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh;
gap: 0;
}
.display-header {
display: flex; align-items: center; justify-content: space-between;
padding: .75rem 2rem;
background: #0f172a;
border-bottom: 1px solid #1e293b;
flex-shrink: 0;
}
.display-header .titulo {
font-size: clamp(1rem, 2.5vw, 1.5rem);
font-weight: 700; color: #94a3b8;
display: flex; align-items: center; gap: .6rem;
}
.display-header .reloj {
font-size: clamp(1.2rem, 3vw, 1.8rem);
font-weight: 800; color: #e2e8f0;
font-variant-numeric: tabular-nums;
}
.display-header .estado-chip {
font-size: .8rem; padding: .25rem .75rem; border-radius: 20px;
font-weight: 700;
}
.estado-chip.conectado { background: #14532d; color: #86efac; }
.estado-chip.conectando { background: #422006; color: #fdba74; }
.estado-chip.error { background: #450a0a; color: #fca5a5; }
/* ── Panel central ────────────────────────────────── */
.display-body {
display: grid;
grid-template-columns: 1fr 340px;
height: 100%;
overflow: hidden;
}
@media (max-width: 768px) {
.display-body { grid-template-columns: 1fr; }
.cola-panel { display: none !important; }
}
/* ── Turno activo ─────────────────────────────────── */
.turno-activo {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 2rem; position: relative;
overflow: hidden;
background: radial-gradient(ellipse at center, #111827 0%, #0a0f1e 70%);
}
.turno-activo .etiqueta {
font-size: clamp(.9rem, 2vw, 1.2rem);
color: #64748b; font-weight: 600;
text-transform: uppercase; letter-spacing: 3px;
margin-bottom: .5rem;
}
.turno-activo .codigo-grande {
font-size: clamp(5rem, 22vw, 16rem);
font-weight: 900; line-height: 1;
letter-spacing: -4px;
transition: color .4s;
}
.turno-activo .nombre-pac {
font-size: clamp(1rem, 3vw, 2rem);
font-weight: 600; color: #cbd5e1;
margin-top: .5rem; text-align: center;
}
.turno-activo .prio-badge {
display: inline-flex; align-items: center; gap: .5rem;
padding: .4rem 1.2rem; border-radius: 30px;
font-size: clamp(.8rem, 1.8vw, 1.15rem); font-weight: 700;
margin-top: 1rem;
}
.turno-activo .sin-turno {
font-size: clamp(1.5rem, 4vw, 3rem);
color: #334155; text-align: center;
}
/* Anillo de animación al llamar turno */
@keyframes ping-ring {
0% { transform: scale(.8); opacity: .9; }
100% { transform: scale(2.5); opacity: 0; }
}
.ring-anim {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
pointer-events: none; z-index: 0;
}
.ring-anim::before {
content: '';
width: 35vmin; height: 35vmin;
border-radius: 50%;
background: transparent;
border: 4px solid currentColor;
opacity: 0;
}
.ring-anim.animar::before {
animation: ping-ring .7s ease-out 1;
}
.turno-activo > *:not(.ring-anim) { position: relative; z-index: 1; }
/* ── Panel de cola ────────────────────────────────── */
.cola-panel {
background: #0f172a;
border-left: 1px solid #1e293b;
display: flex; flex-direction: column;
overflow: hidden;
}
.cola-panel-header {
padding: 1rem 1.2rem .6rem;
font-size: .85rem; font-weight: 700;
color: #64748b; text-transform: uppercase; letter-spacing: 2px;
border-bottom: 1px solid #1e293b;
flex-shrink: 0;
}
.cola-lista {
flex: 1; overflow-y: auto;
padding: .5rem;
scrollbar-width: thin; scrollbar-color: #1e293b transparent;
}
.cola-item {
display: flex; align-items: center; gap: .75rem;
padding: .6rem .8rem; border-radius: 10px;
margin-bottom: .35rem;
background: #1e293b;
transition: background .2s;
}
.cola-item:first-child { background: #1e3a5f; }
.cola-item .cod-badge {
font-size: 1.2rem; font-weight: 900;
min-width: 52px; text-align: center;
padding: .2rem .4rem; border-radius: 8px;
}
.cola-item .pac-info { flex: 1; min-width: 0; }
.cola-item .pac-nombre {
font-size: .85rem; color: #e2e8f0; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cola-item .pac-hora { font-size: .72rem; color: #475569; }
.cola-vacia { color: #334155; text-align: center; padding: 3rem 1rem; font-size: .95rem; }
/* ── Stats (footer) ──────────────────────────────── */
.display-footer {
display: flex; justify-content: center; gap: 2.5rem; flex-wrap: wrap;
padding: .65rem 2rem;
background: #0f172a;
border-top: 1px solid #1e293b;
flex-shrink: 0;
}
.stat-item { text-align: center; }
.stat-item .val { font-size: 1.3rem; font-weight: 800; color: #e2e8f0; }
.stat-item .lbl { font-size: .7rem; color: #64748b; text-transform: uppercase; letter-spacing: 1px; }
</style>
</head>
<body>
<div class="display-grid">
<!-- ── Cabecera ─────────────────────────────────────── -->
<header class="display-header">
<div class="titulo">
<i class="fas fa-ticket-alt"></i>
<span id="lbl-area">Sistema de Turnos</span>
</div>
<span id="chip-estado" class="estado-chip conectando">
<i class="fas fa-circle-notch fa-spin me-1"></i>Conectando…
</span>
<div class="reloj" id="reloj">--:--:--</div>
</header>
<!-- ── Cuerpo ────────────────────────────────────────── -->
<div class="display-body">
<!-- Turno activo -->
<div class="turno-activo" id="zona-activo">
<div class="ring-anim" id="ring" style="color:#3b82f6"></div>
<div class="etiqueta">Turno en atención</div>
<div class="codigo-grande" id="codigo-activo" style="color:#3b82f6"></div>
<div class="nombre-pac" id="nombre-activo"></div>
<div class="prio-badge" id="prio-activo" style="background:#1e293b;color:#94a3b8">
<i class="fas fa-ticket-alt"></i> Esperando turno
</div>
</div>
<!-- Lista de cola -->
<div class="cola-panel">
<div class="cola-panel-header">
<i class="fas fa-list-ol me-1"></i>EN ESPERA
</div>
<div class="cola-lista" id="cola-lista">
<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>
Cola vacía
</div>
</div>
</div>
</div>
<!-- ── Footer stats ──────────────────────────────────── -->
<footer class="display-footer">
<div class="stat-item">
<div class="val" id="stat-espera"></div>
<div class="lbl">En espera</div>
</div>
<div class="stat-item">
<div class="val" id="stat-atendidos"></div>
<div class="lbl">Atendidos hoy</div>
</div>
<div class="stat-item">
<div class="val" id="stat-total"></div>
<div class="lbl">Total del día</div>
</div>
<div class="stat-item">
<div class="val" id="stat-tiempo"></div>
<div class="lbl">Tiempo prom.</div>
</div>
</footer>
</div>
<script>
// ── Configuración desde URL ───────────────────────────────────
const params = new URLSearchParams(location.search);
const area = params.get('display') || 'recepcion';
const lugarId = params.get('lugar_id') || '';
const BASE_API = '../api/';
// Etiqueta de área
document.getElementById('lbl-area').textContent =
area === 'lugar' && lugarId ? `Lugar #${lugarId} — Turnos` : 'Recepción — Turnos';
// ── Reloj ─────────────────────────────────────────────────────
function actualizarReloj() {
document.getElementById('reloj').textContent =
new Date().toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
actualizarReloj();
setInterval(actualizarReloj, 1000);
// ── Audio (Web Audio API — sin archivos externos) ─────────────
let audioCtx = null;
function playBeep() {
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(880, audioCtx.currentTime);
osc.frequency.setValueAtTime(660, audioCtx.currentTime + 0.12);
gain.gain.setValueAtTime(0.25, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.5);
osc.start(audioCtx.currentTime);
osc.stop(audioCtx.currentTime + 0.5);
} catch (_) { /* Sin audio si el contexto no está disponible */ }
}
// Desbloquear AudioContext ante primer tap/clic (requerido por navegadores)
document.addEventListener('click', () => {
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}, { once: true });
// ── Estado previo ─────────────────────────────────────────────
let lastCodigoActivo = null;
// ── Render ────────────────────────────────────────────────────
function renderSnapshot(snap) {
const activo = snap.activo;
const cola = snap.cola || [];
const stats = snap.stats || {};
// Turno activo
const elCodigo = document.getElementById('codigo-activo');
const elNombre = document.getElementById('nombre-activo');
const elPrio = document.getElementById('prio-activo');
const elRing = document.getElementById('ring');
if (activo) {
const color = activo.prioridad_color || '#3b82f6';
// ¿Cambió el turno? → animar + sonido
if (activo.codigo !== lastCodigoActivo) {
playBeep();
elRing.style.color = color;
elRing.classList.remove('animar');
// Forzar reflow para reiniciar animación
void elRing.offsetWidth;
elRing.classList.add('animar');
lastCodigoActivo = activo.codigo;
}
elCodigo.textContent = activo.codigo;
elCodigo.style.color = color;
elNombre.textContent = activo.paciente_nombre || '';
elPrio.style.background = color + '33'; // 20% opacity
elPrio.style.color = color;
elPrio.innerHTML = `<i class="fas fa-ticket-alt"></i> ${activo.prioridad_codigo} &mdash; ${activo.prioridad_nombre}`;
} else {
elCodigo.textContent = '—';
elCodigo.style.color = '#334155';
elNombre.textContent = '';
elPrio.style.background = '#1e293b';
elPrio.style.color = '#94a3b8';
elPrio.innerHTML = '<i class="fas fa-ticket-alt"></i> Esperando turno';
lastCodigoActivo = null;
}
// Lista de cola (excluir el activo si está ahí)
const colaEspera = cola.filter(t =>
t.estado === 'espera' || t.estado === 'en_espera_lugar'
);
const elLista = document.getElementById('cola-lista');
if (colaEspera.length === 0) {
elLista.innerHTML = `
<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>
Cola vacía
</div>`;
} else {
elLista.innerHTML = colaEspera.map((t, idx) => {
const hora = t.creado_at ? new Date(t.creado_at).toLocaleTimeString('es-CO',
{ hour: '2-digit', minute: '2-digit' }) : '';
return `
<div class="cola-item">
<span class="cod-badge" style="background:${t.prioridad_color}22;color:${t.prioridad_color}">${t.codigo}</span>
<div class="pac-info">
<div class="pac-nombre">${escHtml(t.paciente_nombre || 'Paciente')}</div>
<div class="pac-hora">${t.prioridad_nombre} &bull; ${hora}</div>
</div>
${idx === 0 ? '<i class="fas fa-arrow-right" style="color:#3b82f6;font-size:.8rem"></i>' : ''}
</div>`;
}).join('');
}
// Stats
const enEspera = parseInt(stats.en_espera || 0) + parseInt(stats.en_espera_lugar || 0);
document.getElementById('stat-espera').textContent = enEspera;
document.getElementById('stat-atendidos').textContent = stats.finalizados || 0;
document.getElementById('stat-total').textContent = stats.total || 0;
document.getElementById('stat-tiempo').textContent = stats.tiempo_promedio_atencion
? stats.tiempo_promedio_atencion.substring(0, 5) : '—';
}
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(str));
return d.innerHTML;
}
// ── Carga inicial ─────────────────────────────────────────────
async function cargarInicial() {
const url = new URL(BASE_API + 'get_cola.php', location.href);
url.searchParams.set('area', area);
if (lugarId) url.searchParams.set('lugar_id', lugarId);
try {
const res = await fetch(url.toString());
const json = await res.json();
if (json.ok) renderSnapshot(json.data);
} catch (_) { /* SSE tomará el relevo */ }
}
// ── SSE ───────────────────────────────────────────────────────
const chipEstado = document.getElementById('chip-estado');
let esSource = null;
let reconnectDelay = 2000;
function conectarSSE() {
const sseUrl = new URL(BASE_API + 'sse_turno.php', location.href);
sseUrl.searchParams.set('area', area);
if (lugarId) sseUrl.searchParams.set('lugar_id', lugarId);
esSource = new EventSource(sseUrl.toString());
esSource.addEventListener('open', () => {
reconnectDelay = 2000;
chipEstado.className = 'estado-chip conectado';
chipEstado.innerHTML = '<i class="fas fa-circle me-1" style="font-size:.5rem"></i>En vivo';
});
esSource.addEventListener('cola_update', (e) => {
try {
const snap = JSON.parse(e.data);
renderSnapshot(snap);
} catch (_) {}
});
esSource.addEventListener('error', () => {
chipEstado.className = 'estado-chip error';
chipEstado.innerHTML = '<i class="fas fa-exclamation-circle me-1"></i>Reconectando…';
esSource.close();
setTimeout(conectarSSE, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
});
}
// ── Inicio ────────────────────────────────────────────────────
cargarInicial();
conectarSSE();
</script>
</body>
</html>
+394
View File
@@ -0,0 +1,394 @@
<?php
/**
* Kiosko de turnos — pantalla táctil pública
* Ruta: /modules/turnero/views/kiosko.php
* Sin login requerido. NUNCA expone datos de otros pacientes.
*/
require_once __DIR__ . '/../../../config/config.php';
// Cargar prioridades activas desde BD
$prioridades = [];
try {
$pdo = Database::getInstance()->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);
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Kiosko de Turnos</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
/* ── Reset fullscreen ── */
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
height: 100%; width: 100%;
overflow: hidden;
background: #0f172a;
color: #f8fafc;
font-family: 'Segoe UI', system-ui, sans-serif;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* ── Pantalla / paso ── */
.screen {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 2rem;
transition: opacity .35s ease, transform .35s ease;
}
.screen.hidden { opacity: 0; pointer-events: none; transform: scale(.97); }
.screen.visible { opacity: 1; pointer-events: all; transform: scale(1); }
/* ── Logo / cabecera ── */
.kiosko-header { text-align: center; margin-bottom: 2.5rem; }
.kiosko-header h1 { font-size: clamp(1.6rem, 4vw, 3rem); font-weight: 700; letter-spacing: -.5px; }
.kiosko-header p { font-size: clamp(.9rem, 2vw, 1.2rem); color: #94a3b8; margin: 0; }
/* ── Botones de prioridad ── */
.prioridad-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
gap: 1rem;
width: 100%;
max-width: 900px;
}
.btn-prioridad {
border: none; border-radius: 16px;
padding: 1.6rem 1.2rem;
cursor: pointer;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: .6rem;
transition: transform .12s, box-shadow .12s, filter .12s;
color: #fff; font-weight: 600;
box-shadow: 0 4px 20px rgba(0,0,0,.35);
-webkit-user-select: none; user-select: none;
}
.btn-prioridad:active { transform: scale(.95); filter: brightness(.88); }
.btn-prioridad .letra { font-size: clamp(2rem, 6vw, 4rem); line-height: 1; font-weight: 800; }
.btn-prioridad .nombre { font-size: clamp(.85rem, 2vw, 1.1rem); text-align: center; line-height: 1.2; }
.btn-prioridad .desc { font-size: clamp(.7rem, 1.5vw, .85rem); opacity: .8; text-align: center; }
.btn-prioridad i { font-size: clamp(1.4rem, 3.5vw, 2.2rem); }
/* ── Formulario opcional ── */
.form-kiosko { width: 100%; max-width: 520px; }
.form-kiosko label { color: #cbd5e1; font-size: 1rem; margin-bottom: .35rem; }
.form-kiosko .form-control {
background: #1e293b; border: 1.5px solid #334155;
color: #f8fafc; border-radius: 12px;
padding: .8rem 1rem; font-size: 1.1rem;
}
.form-kiosko .form-control:focus {
background: #1e293b; border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96,165,250,.2); color: #f8fafc;
}
.form-kiosko .hint { color: #64748b; font-size: .85rem; margin-top: .4rem; }
.btn-kiosko-main {
border: none; border-radius: 14px;
padding: 1rem 2.5rem; font-size: 1.15rem; font-weight: 700;
cursor: pointer; color: #fff;
background: linear-gradient(135deg, #3b82f6, #6366f1);
box-shadow: 0 4px 16px rgba(99,102,241,.4);
transition: transform .12s, box-shadow .12s;
width: 100%; margin-top: 1rem;
}
.btn-kiosko-main:active { transform: scale(.97); }
.btn-kiosko-back {
background: transparent; border: 1.5px solid #475569;
color: #94a3b8; border-radius: 12px; padding: .7rem 1.5rem;
font-size: .95rem; cursor: pointer; margin-top: .6rem; width: 100%;
transition: background .12s;
}
.btn-kiosko-back:active { background: #1e293b; }
/* ── Pantalla de ticket ── */
.ticket-box {
background: #1e293b; border-radius: 24px;
padding: 2.5rem 3rem; text-align: center;
box-shadow: 0 8px 40px rgba(0,0,0,.5);
max-width: 480px; width: 100%;
}
.ticket-codigo {
font-size: clamp(4rem, 18vw, 9rem);
font-weight: 900; line-height: 1;
letter-spacing: -2px;
}
.ticket-subtitle { color: #94a3b8; font-size: 1rem; margin-top: .5rem; }
.ticket-prioridad { font-size: 1.05rem; margin-top: .8rem; }
.ticket-numero { font-size: 2rem; font-weight: 700; color: #e2e8f0; }
.ticket-instruc {
margin-top: 1.5rem; color: #64748b; font-size: .9rem; line-height: 1.5;
}
.btn-nuevo {
margin-top: 2rem; border: none; border-radius: 14px;
padding: .85rem 2rem; font-size: 1rem; font-weight: 600;
cursor: pointer; color: #fff;
background: #475569;
transition: background .15s;
}
.btn-nuevo:hover { background: #64748b; }
/* ── Spinner ── */
.spinner-overlay {
position: fixed; inset: 0;
background: rgba(15,23,42,.75);
display: flex; align-items: center; justify-content: center;
z-index: 999; opacity: 0; pointer-events: none;
transition: opacity .2s;
}
.spinner-overlay.active { opacity: 1; pointer-events: all; }
.spinner { width: 56px; height: 56px; border: 5px solid #334155;
border-top-color: #60a5fa; border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Error toast ── */
.toast-error {
position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%);
background: #ef4444; color: #fff; padding: .8rem 1.6rem;
border-radius: 12px; font-size: .95rem; font-weight: 600;
box-shadow: 0 4px 20px rgba(0,0,0,.4);
opacity: 0; pointer-events: none; transition: opacity .25s;
z-index: 1000; white-space: nowrap;
}
.toast-error.show { opacity: 1; }
/* ── Responsive: pantalla grande (TV táctil) ── */
@media (min-height: 800px) {
.prioridad-grid { gap: 1.4rem; }
.btn-prioridad { padding: 2rem 1.5rem; }
}
</style>
</head>
<body>
<!-- ══ PASO 1: Selección de prioridad ══════════════════════════ -->
<div id="screen-prio" class="screen visible">
<div class="kiosko-header">
<i class="fas fa-ticket-alt fa-2x mb-3" style="color:#60a5fa"></i>
<h1>Solicitar Turno</h1>
<p>Seleccione su tipo de atención</p>
</div>
<div class="prioridad-grid">
<?php foreach ($prioridades as $prio): ?>
<button
class="btn-prioridad"
style="background: <?= htmlspecialchars($prio['color']) ?>;"
data-codigo="<?= htmlspecialchars($prio['codigo']) ?>"
data-nombre="<?= htmlspecialchars($prio['nombre']) ?>"
data-color="<?= htmlspecialchars($prio['color']) ?>"
onclick="seleccionarPrioridad(this)"
>
<i class="<?= htmlspecialchars($prio['icono']) ?>"></i>
<span class="letra"><?= htmlspecialchars($prio['codigo']) ?></span>
<span class="nombre"><?= htmlspecialchars($prio['nombre']) ?></span>
<?php if (!empty($prio['descripcion'])): ?>
<span class="desc"><?= htmlspecialchars($prio['descripcion']) ?></span>
<?php endif; ?>
</button>
<?php endforeach; ?>
</div>
</div>
<!-- ══ PASO 2: Datos opcionales ════════════════════════════════ -->
<div id="screen-datos" class="screen hidden">
<div class="kiosko-header">
<div id="badge-prio" style="display:inline-block; padding:.5rem 1.4rem; border-radius:40px; font-size:1.2rem; font-weight:700; margin-bottom:1rem;"></div>
<h1>Datos de contacto</h1>
<p>Opcional — para notificaciones por WhatsApp</p>
</div>
<div class="form-kiosko">
<div class="mb-4">
<label for="inp-nombre">Nombre completo</label>
<input type="text" id="inp-nombre" class="form-control" placeholder="Ej: Juan Pérez" maxlength="120" autocomplete="off">
</div>
<div class="mb-4">
<label for="inp-cel">Celular (WhatsApp)</label>
<input type="tel" id="inp-cel" class="form-control" placeholder="Ej: 3001234567" maxlength="20" autocomplete="off" inputmode="numeric">
<div class="hint">Incluya código de país si es diferente a Colombia (+57)</div>
</div>
<button class="btn-kiosko-main" onclick="confirmarTurno()">
<i class="fas fa-ticket-alt me-2"></i>Obtener mi turno
</button>
<button class="btn-kiosko-back" onclick="volverPrioridades()">
<i class="fas fa-arrow-left me-1"></i>Cambiar tipo
</button>
</div>
</div>
<!-- ══ PASO 3: Ticket asignado ══════════════════════════════════ -->
<div id="screen-ticket" class="screen hidden">
<div class="ticket-box">
<div class="ticket-subtitle">Su número de turno es</div>
<div id="tick-codigo" class="ticket-codigo">—</div>
<div id="tick-prioridad" class="ticket-prioridad"></div>
<div class="ticket-subtitle mt-2">Posición en cola</div>
<div id="tick-posicion" class="ticket-numero">—</div>
<div class="ticket-instruc">
Por favor espere ser llamado.<br>
Recuerde traer su documento de identidad y la orden médica.
</div>
</div>
<button class="btn-nuevo" onclick="reiniciar()">
<i class="fas fa-plus me-1"></i>Nuevo turno
</button>
</div>
<!-- ══ Spinner de carga ════════════════════════════════════════ -->
<div class="spinner-overlay" id="spinner">
<div class="spinner"></div>
</div>
<!-- ══ Toast de error ════════════════════════════════════════ -->
<div class="toast-error" id="toast-error"></div>
<script>
// ── Estado local ──────────────────────────────────────────
let prioCodigo = '';
let prioNombre = '';
let prioColor = '';
const API_URL = '../api/create_turno.php';
// ── Navegación entre pasos ────────────────────────────────
function mostrar(id) {
document.querySelectorAll('.screen').forEach(s => {
s.classList.toggle('visible', s.id === id);
s.classList.toggle('hidden', s.id !== id);
});
}
function seleccionarPrioridad(btn) {
prioCodigo = btn.dataset.codigo;
prioNombre = btn.dataset.nombre;
prioColor = btn.dataset.color;
document.getElementById('badge-prio').textContent = prioCodigo + ' — ' + prioNombre;
document.getElementById('badge-prio').style.background = prioColor;
mostrar('screen-datos');
document.getElementById('inp-nombre').focus();
}
function volverPrioridades() {
mostrar('screen-prio');
}
// ── Crear turno ───────────────────────────────────────────
async function confirmarTurno() {
const nombre = document.getElementById('inp-nombre').value.trim();
const cel = document.getElementById('inp-cel').value.trim();
// Validación mínima de celular
if (cel && !/^\+?\d{7,15}$/.test(cel.replace(/\s/g, ''))) {
mostrarError('Ingrese un número de celular válido (solo dígitos).');
return;
}
setSpinner(true);
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prioridad_codigo: prioCodigo,
paciente_nombre: nombre || null,
paciente_cel: cel || null,
}),
});
const json = await res.json();
if (!json.ok) {
throw new Error(json.error ?? 'Error al crear turno');
}
const t = json.data.turno;
mostrarTicket(t);
} catch (err) {
mostrarError(err.message);
} finally {
setSpinner(false);
}
}
// ── Mostrar ticket ────────────────────────────────────────
function mostrarTicket(t) {
document.getElementById('tick-codigo').textContent = t.codigo;
document.getElementById('tick-codigo').style.color = prioColor;
document.getElementById('tick-prioridad').innerHTML =
'<span style="background:' + prioColor + ';padding:.3rem .9rem;border-radius:20px;font-weight:700;">'
+ prioCodigo + ' — ' + prioNombre + '</span>';
document.getElementById('tick-posicion').textContent = '#' + t.posicion_cola;
mostrar('screen-ticket');
// Auto-reinicio tras 30 s para liberar el kiosko
clearTimeout(window._reinicioTimer);
window._reinicioTimer = setTimeout(reiniciar, 30000);
}
// ── Reinicio ──────────────────────────────────────────────
function reiniciar() {
clearTimeout(window._reinicioTimer);
document.getElementById('inp-nombre').value = '';
document.getElementById('inp-cel').value = '';
prioCodigo = prioNombre = prioColor = '';
mostrar('screen-prio');
}
// ── Utilidades ────────────────────────────────────────────
function setSpinner(on) {
document.getElementById('spinner').classList.toggle('active', on);
}
function mostrarError(msg) {
const el = document.getElementById('toast-error');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 4000);
}
</script>
</body>
</html>
+760
View File
@@ -0,0 +1,760 @@
<?php
/**
* Puesto Lugar / Estación de Servicio — Módulo Turnero
* Genérica: sirve para Toma de Muestras 1, 2, Rayos X, etc.
* Requiere login + módulo turnero.
*
* URL: /modules/turnero/views/lugar.php?lugar_id=X
* Si no se pasa lugar_id, muestra un selector al entrar.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) {
header('Location: ' . BASE_URL . 'login.php');
exit;
}
// Cargar lista de lugares activos
try {
$pdo = Database::getInstance()->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';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($lugarNombre) ?> — Turnero</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="<?= BASE_URL ?>assets/css/styles.css?v=12" rel="stylesheet">
<style>
/* ── Layout ── */
.lugar-layout {
display: grid;
grid-template-columns: 300px 1fr;
height: calc(100vh - 52px);
overflow: hidden;
}
@media (max-width: 860px) {
.lugar-layout { grid-template-columns: 1fr; }
.lugar-cola { max-height: 240px; }
}
/* ── Columna cola ── */
.lugar-cola {
background: #f8fafc;
border-right: 1px solid #e2e8f0;
display: flex; flex-direction: column; overflow: hidden;
}
.lugar-cola-hdr {
padding: .85rem 1rem .5rem;
background: #fff; border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.cola-items { flex: 1; overflow-y: auto; padding: .4rem; }
.cola-card {
background: #fff; border: 1px solid #e2e8f0; border-radius: 10px;
padding: .55rem .8rem; margin-bottom: .35rem;
display: flex; align-items: center; gap: .55rem;
}
.cola-card.activo { border-color: #6366f1; box-shadow: 0 0 0 2px #c7d2fe; }
.prio-dot {
width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1rem; font-weight: 800; color: #fff;
}
.turno-info .cod { font-size: .98rem; font-weight: 700; }
.turno-info .pac { font-size: .75rem; color: #64748b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cola-vacia { text-align: center; padding: 2.5rem 1rem; color: #94a3b8; font-size: .88rem; }
/* ── Columna ficha ── */
.lugar-ficha { overflow-y: auto; padding: 1.2rem 1.5rem; background: #fff; }
.ficha-placeholder {
height: 100%; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: #94a3b8; text-align: center;
}
/* ── Secciones ── */
.ficha-sec {
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 12px; padding: 1.1rem 1.2rem; margin-bottom: 1rem;
}
.ficha-sec h6 {
font-size: .75rem; text-transform: uppercase; letter-spacing: .08em;
color: #475569; margin-bottom: .75rem;
}
/* ── Datos paciente ── */
.pac-dato { display: flex; gap: .5rem; align-items: baseline; margin-bottom: .3rem; }
.pac-dato .lbl { font-size: .75rem; color: #94a3b8; min-width: 80px; }
.pac-dato .val { font-size: .88rem; color: #1e293b; font-weight: 500; }
/* ── Exámenes ── */
.exam-pill {
display: inline-flex; align-items: center; gap: .3rem;
background: #eff6ff; color: #1d4ed8; border-radius: 20px;
padding: .2rem .65rem; font-size: .78rem; font-weight: 600;
margin: .15rem;
}
/* ── Consentimientos ── */
.consent-row {
display: flex; align-items: center; gap: .6rem;
padding: .5rem .65rem; border-radius: 8px; margin-bottom: .35rem;
font-size: .85rem;
}
.consent-row.firmado { background: #f0fdf4; color: #166534; }
.consent-row.rechazado { background: #f1f5f9; color: #475569; }
.consent-row.enviado { background: #eff6ff; color: #1e40af; }
.consent-row.pendiente { background: #fffbeb; color: #92400e; }
.consent-row.visto { background: #faf5ff; color: #6b21a8; }
.consent-row .nom-form { flex: 1; }
.consent-row .acciones-consent { display: flex; gap: .4rem; }
/* ── Barra de acciones ── */
.ficha-acciones {
position: sticky; bottom: 0;
background: #fff; border-top: 1px solid #e2e8f0;
padding: .85rem 0 .2rem; margin-top: 1rem;
display: flex; gap: .5rem; flex-wrap: wrap; align-items: center;
}
/* ── Modal de firma ── */
.modal-firma-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.55);
z-index: 1050; display: none; align-items: center; justify-content: center;
}
.modal-firma-backdrop.open { display: flex; }
.modal-firma-box {
background: #fff; border-radius: 16px;
width: min(96vw, 800px); height: min(90vh, 700px);
display: flex; flex-direction: column;
overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,.35);
}
.modal-firma-hdr {
padding: .75rem 1rem; border-bottom: 1px solid #e2e8f0;
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0;
}
.modal-firma-box iframe {
flex: 1; border: none; width: 100%;
}
/* ── Selector de lugar (pantalla inicial) ── */
.selector-lugar-overlay {
position: fixed; inset: 0; background: #f1f5f9;
z-index: 200; display: flex; align-items: center; justify-content: center;
}
.selector-lugar-box {
background: #fff; border-radius: 20px; padding: 2.5rem;
width: min(90vw, 480px); box-shadow: 0 8px 40px rgba(0,0,0,.12); text-align: center;
}
.selector-lugar-box h2 { font-size: 1.4rem; font-weight: 700; margin-bottom: 1.5rem; }
/* ── Indicador de bloqueo ── */
.bloqueo-banner {
background: #fef2f2; border: 1px solid #fca5a5; border-radius: 10px;
padding: .65rem 1rem; font-size: .85rem; color: #991b1b;
display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem;
}
</style>
</head>
<body>
<?php
$SIDEBAR_TITLE = 'Turnero';
$SIDEBAR_ICON = 'fas fa-ticket-alt';
require_once __DIR__ . '/../../../shared/components/sidebar.php';
?>
<!-- ══ Selector inicial de lugar (si no viene en URL) ════════ -->
<div id="overlay-selector" class="selector-lugar-overlay" <?= $lugarIdParam ? 'style="display:none"' : '' ?>>
<div class="selector-lugar-box">
<i class="fas fa-map-marker-alt fa-2x text-primary mb-3"></i>
<h2>Seleccione su estación</h2>
<select id="sel-lugar-init" class="form-select mb-3">
<option value="">— Elija un lugar —</option>
<?php foreach ($lugares as $l): ?>
<option value="<?= (int)$l['id'] ?>"><?= htmlspecialchars($l['nombre']) ?></option>
<?php endforeach; ?>
</select>
<button class="btn btn-primary w-100" onclick="confirmarLugar()">
<i class="fas fa-check me-1"></i>Confirmar
</button>
</div>
</div>
<main class="main-content" style="padding:0">
<!-- ── Top bar ──────────────────────────────────────────── -->
<div class="d-flex align-items-center justify-content-between px-3 py-2 border-bottom bg-white"
style="position:sticky;top:0;z-index:100;min-height:52px">
<div class="d-flex align-items-center gap-2">
<i class="fas fa-map-marker-alt text-primary"></i>
<strong id="lbl-lugar-titulo"><?= htmlspecialchars($lugarNombre) ?></strong>
<span id="badge-turno-activo" class="badge bg-primary ms-1 d-none" id="badge-cod-activo"></span>
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="cambiarLugar()">
<i class="fas fa-exchange-alt me-1"></i>Cambiar lugar
</button>
<span class="text-muted small"><?= htmlspecialchars($adminNombre) ?></span>
<button class="btn btn-primary btn-sm" id="btn-llamar" onclick="llamarSiguiente()">
<i class="fas fa-bell me-1"></i>Llamar siguiente
</button>
</div>
</div>
<div class="lugar-layout">
<!-- ══ Cola ═══════════════════════════════════════════ -->
<div class="lugar-cola">
<div class="lugar-cola-hdr">
<div class="d-flex align-items-center justify-content-between">
<span class="fw-semibold text-secondary" style="font-size:.78rem;text-transform:uppercase;letter-spacing:.05em">
<i class="fas fa-list-ol me-1"></i>EN ESPERA
</span>
<span class="badge bg-primary rounded-pill" id="badge-count">0</span>
</div>
</div>
<div class="cola-items" id="cola-items">
<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>
Cola vacía
</div>
</div>
</div>
<!-- ══ Ficha del turno ════════════════════════════════ -->
<div class="lugar-ficha" id="lugar-ficha">
<div class="ficha-placeholder" id="ficha-placeholder">
<i class="fas fa-stethoscope fa-3x mb-3" style="color:#cbd5e1"></i>
<p class="fw-semibold mb-1">Sin turno activo</p>
<small class="text-muted">Pulse "Llamar siguiente" o espere a que llegue el turno</small>
</div>
<div id="ficha-turno" class="d-none">
<!-- Encabezado turno -->
<div class="d-flex align-items-center gap-3 mb-3">
<div id="ficha-prio-dot" class="prio-dot"
style="width:52px;height:52px;border-radius:12px;font-size:1.6rem;font-weight:900">—</div>
<div>
<div class="h4 mb-0 fw-bold" id="ficha-codigo">—</div>
<div class="text-muted small" id="ficha-prio-nombre"></div>
</div>
<span class="ms-auto badge bg-success-subtle text-success border border-success-subtle"
id="ficha-estado-badge">en espera</span>
</div>
<!-- ── Sección: Paciente ── -->
<div class="ficha-sec">
<h6><i class="fas fa-user me-1"></i>Paciente</h6>
<div id="bloque-pac-info">
<div class="pac-dato"><span class="lbl">Nombre</span><span class="val" id="pac-nombre">—</span></div>
<div class="pac-dato"><span class="lbl">Documento</span><span class="val" id="pac-doc">—</span></div>
<div class="pac-dato"><span class="lbl">Fecha nac.</span><span class="val" id="pac-fec">—</span></div>
<div class="pac-dato"><span class="lbl">Celular</span><span class="val" id="pac-cel">—</span></div>
</div>
<div id="bloque-pac-sin" class="text-muted small">
<i class="fas fa-info-circle me-1"></i>Sin paciente vinculado
</div>
</div>
<!-- ── Sección: Exámenes ── -->
<div class="ficha-sec">
<h6><i class="fas fa-vial me-1"></i>Exámenes solicitados</h6>
<div id="lista-examenes-ficha">
<span class="text-muted small">Sin exámenes registrados</span>
</div>
</div>
<!-- ── Sección: Consentimientos ── -->
<div class="ficha-sec" id="sec-consent">
<h6><i class="fas fa-file-signature me-1"></i>Consentimientos informados</h6>
<div id="bloqueo-banner" class="bloqueo-banner d-none">
<i class="fas fa-lock"></i>
<span>Hay consentimientos pendientes. No puede iniciar la atención hasta que estén <strong>firmados</strong> o <strong>rechazados</strong>.</span>
</div>
<div id="lista-consent"></div>
<div id="sin-consent" class="text-muted small">
<i class="fas fa-check-circle text-success me-1"></i>No se requieren consentimientos
</div>
</div>
<!-- ── Barra de acciones ── -->
<div class="ficha-acciones">
<button class="btn btn-success" id="btn-iniciar" onclick="iniciarAtencion()">
<i class="fas fa-play me-1"></i>Iniciar atención
</button>
<button class="btn btn-primary d-none" id="btn-finalizar" onclick="finalizarAtencion()">
<i class="fas fa-flag-checkered me-1"></i>Finalizar
</button>
<button class="btn btn-outline-warning d-none" id="btn-regresar" onclick="regresarCola()">
<i class="fas fa-undo me-1"></i>Regresar a cola
</button>
<button class="btn btn-secondary ms-auto" id="btn-ausente" onclick="marcarAusente()">
<i class="fas fa-user-slash me-1"></i>Ausente
</button>
</div>
</div><!-- /ficha-turno -->
</div><!-- /lugar-ficha -->
</div><!-- /lugar-layout -->
</main>
<!-- ══ Modal de firma presencial ══════════════════════════════ -->
<div class="modal-firma-backdrop" id="modal-firma">
<div class="modal-firma-box">
<div class="modal-firma-hdr">
<strong id="modal-firma-titulo">Firmar consentimiento</strong>
<button class="btn btn-sm btn-outline-secondary" onclick="cerrarModalFirma()">
<i class="fas fa-times"></i>
</button>
</div>
<iframe id="firma-iframe" src="" title="Formulario de consentimiento"></iframe>
</div>
</div>
<script>
// ── Estado global ─────────────────────────────────────────────
let lugarId = <?= $lugarIdParam ?: 0 ?>;
let turnoActivo = null;
let tieneConsent = false;
let hayPendientes = false;
let pollingColaId = null;
let pollingConsentId = null;
const API = '../api/';
const BASE_WA = '<?= BASE_URL ?>';
// ── Arranque ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if (lugarId) {
iniciarPuesto();
}
});
function iniciarPuesto() {
cargarCola();
pollingColaId = setInterval(cargarCola, 7000);
}
// ── Selector de lugar ─────────────────────────────────────────
function confirmarLugar() {
const sel = document.getElementById('sel-lugar-init');
const id = parseInt(sel.value);
if (!id) return alert('Seleccione un lugar.');
lugarId = id;
const txt = sel.options[sel.selectedIndex].text;
document.getElementById('lbl-lugar-titulo').textContent = txt;
document.getElementById('overlay-selector').style.display = 'none';
document.title = txt + ' — Turnero';
iniciarPuesto();
}
function cambiarLugar() {
clearInterval(pollingColaId);
clearInterval(pollingConsentId);
turnoActivo = null;
resetFicha();
document.getElementById('overlay-selector').style.display = 'flex';
}
// ── Cola ──────────────────────────────────────────────────────
async function cargarCola() {
if (!lugarId) return;
try {
const res = await fetch(`${API}get_cola.php?area=lugar&lugar_id=${lugarId}`);
const json = await res.json();
if (!json.ok) return;
renderCola(json.data);
} catch (_) {}
}
function renderCola(snap) {
const espera = (snap.cola || []).filter(t =>
t.estado === 'en_espera_lugar'
);
document.getElementById('badge-count').textContent = espera.length;
const lista = document.getElementById('cola-items');
if (!espera.length) {
lista.innerHTML = `<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>Cola vacía</div>`;
return;
}
lista.innerHTML = espera.map(t => `
<div class="cola-card ${turnoActivo?.id === t.id ? 'activo' : ''}">
<div class="prio-dot" style="background:${t.prioridad_color}">${t.prioridad_codigo}</div>
<div class="turno-info">
<div class="cod">${escHtml(t.codigo)}</div>
<div class="pac">${escHtml(t.paciente_nombre || 'Paciente')}</div>
</div>
</div>`).join('');
}
// ── Llamar siguiente ──────────────────────────────────────────
async function llamarSiguiente() {
if (!lugarId) { alert('Primero seleccione un lugar.'); return; }
const btn = document.getElementById('btn-llamar');
btn.disabled = true;
try {
const res = await fetch(API + 'llamar_turno.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ area: 'lugar', lugar_id: lugarId }),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
if (!json.data.turno) {
alert('Cola del lugar vacía.');
return;
}
await abrirFicha(json.data.turno);
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
} finally {
btn.disabled = false;
}
}
// ── Abrir ficha ───────────────────────────────────────────────
async function abrirFicha(turno) {
turnoActivo = turno;
document.getElementById('ficha-placeholder').classList.add('d-none');
document.getElementById('ficha-turno').classList.remove('d-none');
// Header
document.getElementById('ficha-codigo').textContent = turno.codigo;
document.getElementById('ficha-prio-nombre').textContent = turno.prioridad_nombre || '';
const dot = document.getElementById('ficha-prio-dot');
dot.textContent = turno.prioridad_codigo || '—';
dot.style.background = turno.prioridad_color || '#6366f1';
const badgeCod = document.getElementById('badge-turno-activo');
badgeCod.textContent = turno.codigo;
badgeCod.classList.remove('d-none');
actualizarEstadoBadge(turno.estado);
// Cargar ficha del paciente/exámenes desde la solicitud
await cargarFichaSolicitud(turno.id);
// Iniciar polling de consentimientos
clearInterval(pollingConsentId);
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
}
// ── Cargar datos de la solicitud del turno ────────────────────
async function cargarFichaSolicitud(turnoId) {
try {
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}&incluir_solicitud=1`);
const json = await res.json();
if (!json.ok) return;
const sol = json.data.solicitud;
const pac = json.data.paciente;
const exams = json.data.examenes || [];
const consts = json.data.consentimientos || [];
// Datos del paciente
if (pac) {
document.getElementById('bloque-pac-info').style.display = '';
document.getElementById('bloque-pac-sin').classList.add('d-none');
document.getElementById('pac-nombre').textContent =
(pac.full_name || (pac.nombre||'') + ' ' + (pac.apellido||'')).trim() || '—';
document.getElementById('pac-doc').textContent =
(pac.tipo_documento||'') + ' ' + (pac.documento||'') || '—';
document.getElementById('pac-fec').textContent = pac.fecha_nacimiento || '—';
document.getElementById('pac-cel').textContent = pac.celular || pac.telefono || '—';
} else {
document.getElementById('bloque-pac-info').style.display = 'none';
document.getElementById('bloque-pac-sin').classList.remove('d-none');
}
// Exámenes
const listaEx = document.getElementById('lista-examenes-ficha');
if (exams.length) {
listaEx.innerHTML = exams.map(e =>
`<span class="exam-pill"><i class="fas fa-vial"></i>${escHtml(e.codigo)} ${escHtml(e.nombre)}</span>`
).join('');
} else {
listaEx.innerHTML = '<span class="text-muted small">Sin exámenes registrados</span>';
}
// Consentimientos
renderConsentimientos(consts);
} catch (_) {}
}
// ── Consentimientos ───────────────────────────────────────────
function renderConsentimientos(lista) {
tieneConsent = lista.length > 0;
hayPendientes = lista.some(c => !['firmado', 'rechazado'].includes(c.estado));
const sec = document.getElementById('sec-consent');
const listEl = document.getElementById('lista-consent');
const sinEl = document.getElementById('sin-consent');
const banner = document.getElementById('bloqueo-banner');
const btnIni = document.getElementById('btn-iniciar');
if (!tieneConsent) {
listEl.innerHTML = '';
sinEl.classList.remove('d-none');
banner.classList.add('d-none');
btnIni.disabled = false;
return;
}
sinEl.classList.add('d-none');
banner.classList.toggle('d-none', !hayPendientes);
btnIni.disabled = hayPendientes;
listEl.innerHTML = lista.map(c => {
const cls = c.estado;
const ico = c.estado === 'firmado' ? 'fa-check-circle'
: c.estado === 'rechazado' ? 'fa-ban'
: c.estado === 'enviado' ? 'fa-envelope'
: c.estado === 'visto' ? 'fa-eye'
: 'fa-clock';
const label = c.estado === 'firmado' ? 'Firmado'
: c.estado === 'rechazado' ? 'Rechazado'
: c.estado === 'enviado' ? 'Enviado'
: c.estado === 'visto' ? 'Visto'
: 'Pendiente';
const botonesAccion = !['firmado','rechazado'].includes(c.estado) ? `
<div class="acciones-consent">
<button class="btn btn-outline-secondary btn-sm py-0" title="Reenviar WhatsApp"
onclick="reenviarConsentimiento(${c.turno_id})">
<i class="fas fa-paper-plane" style="font-size:.75rem"></i>
</button>
<button class="btn btn-outline-primary btn-sm py-0" title="Firmar presencialmente"
onclick="abrirFirmaPresencial('${c.token}', ${c.id}, ${JSON.stringify(c.formulario_nombre).replace(/"/g,'&quot;')})">
<i class="fas fa-signature" style="font-size:.75rem"></i> Firmar aquí
</button>
</div>` : '';
return `<div class="consent-row ${cls}" data-consent-id="${c.id}">
<i class="fas ${ico}"></i>
<span class="nom-form">${escHtml(c.formulario_nombre || 'Consentimiento')}</span>
<span class="badge">${escHtml(label)}</span>
${botonesAccion}
</div>`;
}).join('');
}
async function actualizarConsentimientos(turnoId) {
if (!turnoId) return;
try {
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
const json = await res.json();
if (!json.ok) return;
renderConsentimientos(json.data.consentimientos || []);
} catch (_) {}
}
async function reenviarConsentimiento(turnoId) {
try {
const res = await fetch(API + 'send_consentimiento.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoId }),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
await actualizarConsentimientos(turnoId);
} catch (err) {
alert('Error: ' + err.message);
}
}
// ── Modal de firma presencial ─────────────────────────────────
function abrirFirmaPresencial(token, consentId, nombreForm) {
const url = BASE_WA + 'ver_formulario_enviado.php?token=' + encodeURIComponent(token);
document.getElementById('modal-firma-titulo').textContent = 'Firmar: ' + nombreForm;
document.getElementById('firma-iframe').src = url;
document.getElementById('modal-firma').classList.add('open');
// Al cerrar, refrescar consentimientos
window._firmaConsentId = consentId;
}
function cerrarModalFirma() {
document.getElementById('modal-firma').classList.remove('open');
document.getElementById('firma-iframe').src = '';
if (turnoActivo) actualizarConsentimientos(turnoActivo.id);
}
// Cerrar modal al hacer clic fuera del cuadro
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('modal-firma').addEventListener('click', function(e) {
if (e.target === this) cerrarModalFirma();
});
// Escuchar mensaje del iframe (firma completada desde ver_formulario_enviado.php)
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'turneroFirmado') {
cerrarModalFirma();
}
});
});
// ── Acciones del turno ────────────────────────────────────────
async function iniciarAtencion() {
if (!turnoActivo) return;
if (hayPendientes) return; // botón ya deshabilitado, doble seguro
const btn = document.getElementById('btn-iniciar');
btn.disabled = true;
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'en_servicio' }),
});
const json = await res.json();
if (!json.ok) {
alert('Error: ' + json.error);
btn.disabled = hayPendientes;
return;
}
turnoActivo = json.data.turno;
actualizarEstadoBadge('en_servicio');
// Mostrar "Finalizar" y "Regresar", ocultar "Iniciar"
btn.classList.add('d-none');
document.getElementById('btn-finalizar').classList.remove('d-none');
document.getElementById('btn-regresar').classList.remove('d-none');
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
btn.disabled = false;
}
}
async function finalizarAtencion() {
if (!turnoActivo) return;
if (!confirm(`¿Finalizar atención del turno ${turnoActivo.codigo}?`)) return;
await cambiarEstadoTurno('finalizado');
}
async function marcarAusente() {
if (!turnoActivo) return;
if (!confirm(`¿Marcar turno ${turnoActivo.codigo} como AUSENTE?`)) return;
await cambiarEstadoTurno('ausente');
}
async function regresarCola() {
if (!turnoActivo) return;
if (!confirm(`¿Regresar turno ${turnoActivo.codigo} a la cola del lugar?`)) return;
// Regresar a en_espera_lugar
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
turno_id: turnoActivo.id,
nuevo_estado: 'en_espera_lugar',
lugar_id: lugarId,
}),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
resetFicha();
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
}
}
async function cambiarEstadoTurno(nuevoEstado) {
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: nuevoEstado }),
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + json.error); return; }
resetFicha();
cargarCola();
} catch (err) {
alert('Error: ' + err.message);
}
}
// ── Reset ─────────────────────────────────────────────────────
function resetFicha() {
clearInterval(pollingConsentId);
turnoActivo = null;
tieneConsent = false;
hayPendientes = false;
document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('ficha-placeholder').classList.remove('d-none');
document.getElementById('badge-turno-activo').classList.add('d-none');
// Botones al estado inicial
document.getElementById('btn-iniciar').classList.remove('d-none');
document.getElementById('btn-iniciar').disabled = false;
document.getElementById('btn-finalizar').classList.add('d-none');
document.getElementById('btn-regresar').classList.add('d-none');
}
// ── Helpers ───────────────────────────────────────────────────
function actualizarEstadoBadge(estado) {
const el = document.getElementById('ficha-estado-badge');
const mapa = {
'en_espera_lugar': ['bg-warning-subtle text-warning border border-warning-subtle', 'En espera'],
'en_servicio': ['bg-success-subtle text-success border border-success-subtle', 'En servicio'],
'finalizado': ['bg-secondary-subtle text-secondary border', 'Finalizado'],
'ausente': ['bg-danger-subtle text-danger border border-danger-subtle', 'Ausente'],
};
const [cls, lbl] = mapa[estado] || ['bg-secondary-subtle text-secondary border', estado];
el.className = 'ms-auto badge ' + cls;
el.textContent = lbl;
}
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str ?? '')));
return d.innerHTML;
}
</script>
</body>
</html>
+706
View File
@@ -0,0 +1,706 @@
<?php
/**
* Puesto de Recepción — Módulo Turnero
* Requiere login + módulo turnero.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) {
header('Location: ' . BASE_URL . 'login.php');
exit;
}
// Cargar catálogos para la ficha
try {
$pdo = Database::getInstance()->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';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recepción — Turnero</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="<?= BASE_URL ?>assets/css/styles.css?v=12" rel="stylesheet">
<style>
/* ── Layout de dos columnas ── */
.rec-layout {
display: grid;
grid-template-columns: 320px 1fr;
height: calc(100vh - 56px);
overflow: hidden;
}
@media (max-width: 900px) {
.rec-layout { grid-template-columns: 1fr; }
.rec-cola { max-height: 260px; }
}
/* ── Columna cola ── */
.rec-cola {
background: #f8fafc;
border-right: 1px solid #e2e8f0;
display: flex; flex-direction: column;
overflow: hidden;
}
.rec-cola-header {
padding: 1rem 1.2rem .6rem;
background: #fff; border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.cola-items { flex: 1; overflow-y: auto; padding: .5rem; }
.cola-card {
background: #fff; border: 1px solid #e2e8f0;
border-radius: 10px; padding: .65rem .9rem;
margin-bottom: .4rem; cursor: default;
display: flex; align-items: center; gap: .6rem;
transition: box-shadow .15s;
}
.cola-card.activo { border-color: #3b82f6; box-shadow: 0 0 0 2px #bfdbfe; }
.cola-card .prio-dot {
width: 36px; height: 36px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem; font-weight: 800; color: #fff; flex-shrink: 0;
}
.cola-card .turno-info { flex: 1; min-width: 0; }
.cola-card .turno-info .cod { font-size: 1rem; font-weight: 700; }
.cola-card .turno-info .pac { font-size: .78rem; color: #64748b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cola-vacia { text-align: center; padding: 3rem 1rem; color: #94a3b8; }
/* ── Columna ficha ── */
.rec-ficha {
overflow-y: auto; padding: 1.5rem;
background: #fff;
}
.ficha-placeholder {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
height: 100%; color: #94a3b8; text-align: center;
}
/* ── Secciones de la ficha ── */
.ficha-section {
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 12px; padding: 1.2rem; margin-bottom: 1rem;
}
.ficha-section h6 { color: #475569; font-size: .78rem;
text-transform: uppercase; letter-spacing: .08em; margin-bottom: .8rem; }
/* ── Búsqueda de paciente ── */
.pac-resultado {
border: 1px solid #e2e8f0; border-radius: 8px; padding: .5rem .75rem;
cursor: pointer; margin-top: .25rem; transition: background .12s;
}
.pac-resultado:hover { background: #f1f5f9; }
.pac-resultado .pac-nombre { font-weight: 600; font-size: .9rem; }
.pac-resultado .pac-doc { font-size: .78rem; color: #64748b; }
.pac-seleccionado {
background: #eff6ff; border: 1px solid #93c5fd;
border-radius: 8px; padding: .65rem .9rem;
display: flex; align-items: center; justify-content: space-between;
}
/* ── Checkboxes de exámenes ── */
.exam-group-title { font-size: .75rem; color: #94a3b8;
text-transform: uppercase; letter-spacing: .08em; margin: .6rem 0 .3rem; }
.exam-check-item { display: flex; align-items: center; gap: .5rem;
padding: .3rem .4rem; border-radius: 6px; cursor: pointer;
transition: background .1s; font-size: .88rem; }
.exam-check-item:hover { background: #f1f5f9; }
.exam-check-item input[type=checkbox] { cursor: pointer; }
/* ── Consentimientos ── */
.consent-item {
display: flex; align-items: center; gap: .6rem;
padding: .4rem .6rem; border-radius: 8px;
font-size: .85rem; margin-bottom: .3rem;
}
.consent-item.firmado { background: #f0fdf4; color: #166534; }
.consent-item.pendiente{ background: #fffbeb; color: #92400e; }
.consent-item.enviado { background: #eff6ff; color: #1e40af; }
/* ── Barra de acciones ── */
.ficha-acciones {
position: sticky; bottom: 0;
background: #fff; border-top: 1px solid #e2e8f0;
padding: 1rem 0 .25rem; margin-top: 1rem;
display: flex; gap: .5rem; flex-wrap: wrap;
}
/* ── Badge de turno activo en topbar ── */
.badge-turno-activo {
background: #eff6ff; color: #1d4ed8;
border: 1px solid #bfdbfe; border-radius: 8px;
padding: .25rem .75rem; font-size: .82rem; font-weight: 700;
}
</style>
</head>
<body>
<?php
$SIDEBAR_TITLE = 'Turnero';
$SIDEBAR_ICON = 'fas fa-ticket-alt';
require_once __DIR__ . '/../../../shared/components/sidebar.php';
?>
<main class="main-content" style="padding:0">
<!-- ── Top bar ──────────────────────────────────────────── -->
<div class="d-flex align-items-center justify-content-between px-3 py-2 border-bottom bg-white"
style="position:sticky;top:0;z-index:100">
<div class="d-flex align-items-center gap-2">
<i class="fas fa-ticket-alt text-primary"></i>
<strong>Recepción</strong>
<span id="badge-turno-activo" class="badge-turno-activo d-none">
Turno: <span id="badge-codigo">—</span>
</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted small"><?= htmlspecialchars($adminNombre) ?></span>
<button class="btn btn-primary btn-sm" id="btn-llamar" onclick="llamarSiguiente()">
<i class="fas fa-bell me-1"></i>Llamar siguiente
</button>
</div>
</div>
<div class="rec-layout">
<!-- ══ Columna izquierda: cola ════════════════════════ -->
<div class="rec-cola">
<div class="rec-cola-header">
<div class="d-flex align-items-center justify-content-between">
<span class="fw-semibold text-secondary small">
<i class="fas fa-list-ol me-1"></i>EN ESPERA
</span>
<span class="badge bg-primary rounded-pill" id="badge-count">0</span>
</div>
</div>
<div class="cola-items" id="cola-items">
<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>
Cola vacía
</div>
</div>
</div>
<!-- ══ Columna derecha: ficha del turno ═══════════════ -->
<div class="rec-ficha" id="rec-ficha">
<div class="ficha-placeholder" id="ficha-placeholder">
<i class="fas fa-ticket-alt fa-3x mb-3" style="color:#cbd5e1"></i>
<p class="mb-0 fw-semibold">Sin turno activo</p>
<small class="text-muted">Haga clic en "Llamar siguiente" o seleccione un turno de la cola</small>
</div>
<!-- Ficha de turno (oculta hasta llamar) -->
<div id="ficha-turno" class="d-none">
<!-- Encabezado del turno -->
<div class="d-flex align-items-center gap-3 mb-3">
<div id="ficha-prio-dot" class="prio-dot"
style="width:52px;height:52px;border-radius:12px;display:flex;align-items:center;
justify-content:center;font-size:1.6rem;font-weight:900;color:#fff;background:#3b82f6">
</div>
<div>
<div class="h4 mb-0 fw-bold" id="ficha-codigo">—</div>
<div class="text-muted small" id="ficha-prio-nombre">—</div>
</div>
</div>
<!-- ── Sección 1: Paciente ── -->
<div class="ficha-section">
<h6><i class="fas fa-user me-1"></i>Paciente</h6>
<!-- Si el kiosko capturó un nombre -->
<div id="bloque-nombre-kiosko" class="d-none mb-2">
<small class="text-muted">Nombre del kiosko:</small>
<div class="fw-semibold" id="lbl-nombre-kiosko"></div>
</div>
<div id="bloque-pac-no-vinculado">
<div class="input-group mb-2">
<input type="text" id="inp-buscar-pac"
class="form-control form-control-sm"
placeholder="Buscar por nombre o documento…"
autocomplete="off">
<button class="btn btn-outline-secondary btn-sm" onclick="buscarPaciente()" type="button">
<i class="fas fa-search"></i>
</button>
</div>
<div id="lista-pacientes-res"></div>
</div>
<div id="bloque-pac-seleccionado" class="d-none">
<div class="pac-seleccionado">
<div>
<div class="fw-semibold" id="lbl-pac-nombre">—</div>
<div class="text-muted small" id="lbl-pac-doc">—</div>
<div class="text-muted small" id="lbl-pac-cel"></div>
</div>
<button class="btn btn-link btn-sm text-secondary p-0" onclick="desvincularPaciente()">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- ── Sección 2: Lugar destino ── -->
<div class="ficha-section">
<h6><i class="fas fa-map-marker-alt me-1"></i>Lugar destino</h6>
<select id="sel-lugar" class="form-select form-select-sm">
<option value="">— Seleccione lugar —</option>
<?php foreach ($lugares as $l): ?>
<option value="<?= (int)$l['id'] ?>"><?= htmlspecialchars($l['nombre']) ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- ── Sección 3: Exámenes ── -->
<div class="ficha-section">
<h6><i class="fas fa-vial me-1"></i>Exámenes solicitados</h6>
<div id="lista-examenes">
<?php foreach ($examenesAgrupados as $cat => $items): ?>
<div class="exam-group-title"><?= htmlspecialchars($cat) ?></div>
<?php foreach ($items as $ex): ?>
<label class="exam-check-item">
<input type="checkbox" class="exam-chk"
value="<?= (int)$ex['id'] ?>"
data-nombre="<?= htmlspecialchars($ex['nombre']) ?>">
<span><strong><?= htmlspecialchars($ex['codigo']) ?></strong>
— <?= htmlspecialchars($ex['nombre']) ?></span>
</label>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
<div class="mt-2 d-flex gap-2 flex-wrap">
<button class="btn btn-outline-secondary btn-sm" onclick="toggleTodosExamenes(false)">
Limpiar
</button>
</div>
</div>
<!-- ── Sección 4: Pago ── -->
<div class="ficha-section">
<h6><i class="fas fa-dollar-sign me-1"></i>Cobro (opcional)</h6>
<div class="row g-2">
<div class="col-6">
<input type="number" id="inp-total" class="form-control form-control-sm"
placeholder="Total $" step="100" min="0">
</div>
<div class="col-6">
<select id="sel-pago" class="form-select form-select-sm">
<option value="">— Forma de pago —</option>
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
<option value="eps">EPS / Convenio</option>
<option value="cortesia">Cortesía</option>
</select>
</div>
</div>
<textarea id="inp-obs" class="form-control form-control-sm mt-2"
rows="2" placeholder="Observaciones…" maxlength="500"></textarea>
</div>
<!-- ── Sección 5: Consentimientos ── -->
<div class="ficha-section" id="sec-consentimientos" style="display:none!important">
<h6><i class="fas fa-file-signature me-1"></i>Consentimientos</h6>
<div id="lista-consentimientos"></div>
<button class="btn btn-outline-primary btn-sm mt-2"
id="btn-reenviar-consent" onclick="enviarConsentimientos()">
<i class="fas fa-paper-plane me-1"></i>Enviar / Reenviar por WhatsApp
</button>
</div>
<!-- ── Acciones ── -->
<div class="ficha-acciones">
<button class="btn btn-success" id="btn-guardar" onclick="guardarSolicitud()">
<i class="fas fa-save me-1"></i>Guardar solicitud
</button>
<button class="btn btn-primary d-none" id="btn-pasar-lugar" onclick="pasarALugar()">
<i class="fas fa-arrow-right me-1"></i>Pasar a lugar
</button>
<button class="btn btn-warning d-none" id="btn-enviar-consent" onclick="enviarConsentimientos()">
<i class="fas fa-paper-plane me-1"></i>Enviar consentimientos
</button>
<button class="btn btn-secondary ms-auto" onclick="marcarAusente()">
<i class="fas fa-user-slash me-1"></i>Ausente
</button>
</div>
</div><!-- /ficha-turno -->
</div><!-- /rec-ficha -->
</div><!-- /rec-layout -->
</main>
<script>
// ── Estado ────────────────────────────────────────────────────
let turnoActivo = null;
let pacienteActivo = null;
let solicitudActiva = null;
let consentimientos = [];
let pollingColaId = null;
const API = '../api/';
const API_PAC = '<?= BASE_URL ?>api/lab/get_pacientes.php';
// ── Arranque ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
cargarCola();
pollingColaId = setInterval(cargarCola, 8000);
document.getElementById('inp-buscar-pac')
.addEventListener('keydown', e => { if (e.key === 'Enter') buscarPaciente(); });
});
// ── Cola ──────────────────────────────────────────────────────
async function cargarCola() {
try {
const res = await fetch(API + 'get_cola.php?area=recepcion');
const json = await res.json();
if (!json.ok) return;
renderCola(json.data);
} catch (_) {}
}
function renderCola(snap) {
const lista = document.getElementById('cola-items');
const espera = (snap.cola || []).filter(t => t.estado === 'espera');
const badge = document.getElementById('badge-count');
badge.textContent = espera.length;
if (espera.length === 0) {
lista.innerHTML = `<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>Cola vacía</div>`;
return;
}
lista.innerHTML = espera.map(t => `
<div class="cola-card ${turnoActivo?.id === t.id ? 'activo' : ''}"
onclick="seleccionarTurno(${t.id})">
<div class="prio-dot" style="background:${t.prioridad_color}">${t.prioridad_codigo}</div>
<div class="turno-info">
<div class="cod">${escHtml(t.codigo)}</div>
<div class="pac">${escHtml(t.paciente_nombre || 'Paciente')}</div>
</div>
</div>`).join('');
}
// ── Llamar siguiente ──────────────────────────────────────────
async function llamarSiguiente() {
const btn = document.getElementById('btn-llamar');
btn.disabled = true;
try {
const res = await fetch(API + 'llamar_turno.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ area: 'recepcion' }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
if (!json.data.turno) {
mostrarAviso('Cola vacía', 'No hay turnos en espera.');
return;
}
abrirFicha(json.data.turno);
cargarCola();
} catch (err) {
mostrarError(err.message);
} finally {
btn.disabled = false;
}
}
// ── Seleccionar turno de la cola (para ver ficha de uno ya llamado) ─
async function seleccionarTurno(turnoId) {
// No abrir si ya hay uno en recepción (confuso)
if (turnoActivo && turnoActivo.estado === 'en_recepcion') return;
}
// ── Abrir ficha ───────────────────────────────────────────────
function abrirFicha(turno) {
turnoActivo = turno;
pacienteActivo = null;
solicitudActiva = null;
document.getElementById('ficha-placeholder').classList.add('d-none');
document.getElementById('ficha-turno').classList.remove('d-none');
// Datos del turno
document.getElementById('ficha-codigo').textContent = turno.codigo;
document.getElementById('ficha-prio-nombre').textContent = turno.prioridad_nombre || '';
const dot = document.getElementById('ficha-prio-dot');
dot.textContent = turno.prioridad_codigo || '—';
dot.style.background = turno.prioridad_color || '#3b82f6';
// Badge top
document.getElementById('badge-turno-activo').classList.remove('d-none');
document.getElementById('badge-codigo').textContent = turno.codigo;
// Nombre del kiosko
if (turno.paciente_nombre) {
document.getElementById('bloque-nombre-kiosko').classList.remove('d-none');
document.getElementById('lbl-nombre-kiosko').textContent = turno.paciente_nombre;
document.getElementById('inp-buscar-pac').value = turno.paciente_nombre;
}
// Reset secciones
resetCheckboxes();
document.getElementById('sec-consentimientos').style.removeProperty('display');
document.getElementById('sec-consentimientos').style.display = 'none';
document.getElementById('btn-pasar-lugar').classList.add('d-none');
document.getElementById('btn-enviar-consent').classList.add('d-none');
document.getElementById('btn-guardar').classList.remove('d-none');
document.getElementById('btn-guardar').disabled = false;
desvincularPaciente();
// Buscar consentimientos si ya tiene solicitud
verificarSolicitudExistente(turno.id);
}
async function verificarSolicitudExistente(turnoId) {
try {
const res = await fetch(API + 'get_cola.php?area=recepcion');
// La solicitud se carga solo después de guardar
} catch (_) {}
}
// ── Buscador de paciente ──────────────────────────────────────
async function buscarPaciente() {
const q = document.getElementById('inp-buscar-pac').value.trim();
if (q.length < 2) return;
try {
const res = await fetch(`${API_PAC}?busqueda=${encodeURIComponent(q)}&limit=8`);
const json = await res.json();
const lista = document.getElementById('lista-pacientes-res');
const datos = json.data || json.registros || [];
if (!datos.length) {
lista.innerHTML = `<div class="text-muted small mt-1">Sin resultados.
<a href="#" onclick="abrirNuevoPaciente('${escHtml(q)}');return false">Crear nuevo</a></div>`;
return;
}
lista.innerHTML = datos.map(p => `
<div class="pac-resultado" onclick="seleccionarPaciente(${JSON.stringify(p).replace(/"/g,'&quot;')})">
<div class="pac-nombre">${escHtml((p.full_name||p.nombre||'') + ' ' + (p.apellido||''))}</div>
<div class="pac-doc">${escHtml(p.tipo_documento||'')} ${escHtml(p.documento||p.numero_documento||'')}
${p.telefono||p.celular ? '· ' + escHtml(p.telefono||p.celular) : ''}</div>
</div>`).join('');
} catch (_) {}
}
function seleccionarPaciente(pac) {
pacienteActivo = pac;
document.getElementById('bloque-pac-no-vinculado').classList.add('d-none');
document.getElementById('bloque-pac-seleccionado').classList.remove('d-none');
document.getElementById('lbl-pac-nombre').textContent =
(pac.full_name || (pac.nombre||'') + ' ' + (pac.apellido||'')).trim();
document.getElementById('lbl-pac-doc').textContent =
(pac.tipo_documento||'') + ' ' + (pac.documento||pac.numero_documento||'');
document.getElementById('lbl-pac-cel').textContent =
pac.telefono || pac.celular || '';
document.getElementById('lista-pacientes-res').innerHTML = '';
}
function desvincularPaciente() {
pacienteActivo = null;
document.getElementById('bloque-pac-no-vinculado').classList.remove('d-none');
document.getElementById('bloque-pac-seleccionado').classList.add('d-none');
document.getElementById('lista-pacientes-res').innerHTML = '';
document.getElementById('inp-buscar-pac').value = '';
}
function abrirNuevoPaciente(nombre) {
window.open('<?= BASE_URL ?>lab_pacientes.php?nuevo=1&nombre=' + encodeURIComponent(nombre), '_blank');
}
// ── Guardar solicitud ─────────────────────────────────────────
async function guardarSolicitud() {
if (!turnoActivo) return;
if (!pacienteActivo) { mostrarError('Seleccione un paciente antes de guardar.'); return; }
const lugarId = parseInt(document.getElementById('sel-lugar').value);
if (!lugarId) { mostrarError('Seleccione el lugar destino.'); return; }
const examIds = Array.from(document.querySelectorAll('.exam-chk:checked')).map(c => parseInt(c.value));
if (!examIds.length) { mostrarError('Seleccione al menos un examen.'); return; }
const btn = document.getElementById('btn-guardar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
try {
const res = await fetch(API + 'create_solicitud.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
turno_id: turnoActivo.id,
paciente_id: pacienteActivo.id,
lugar_id: lugarId,
exam_tipo_ids: examIds,
total_cobrado: parseFloat(document.getElementById('inp-total').value) || null,
metodo_pago: document.getElementById('sel-pago').value || null,
observaciones: document.getElementById('inp-obs').value.trim() || null,
}),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
solicitudActiva = json.data.solicitud;
consentimientos = json.data.consentimientos_requeridos || [];
btn.innerHTML = '<i class="fas fa-check me-1"></i>Guardado';
// Mostrar sección de consentimientos si aplica
if (consentimientos.length > 0) {
renderConsentimientos(consentimientos);
document.getElementById('sec-consentimientos').style.display = '';
document.getElementById('btn-enviar-consent').classList.remove('d-none');
}
document.getElementById('btn-pasar-lugar').classList.remove('d-none');
document.getElementById('btn-guardar').classList.add('d-none');
} catch (err) {
mostrarError(err.message);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar solicitud';
}
}
function renderConsentimientos(lista) {
document.getElementById('lista-consentimientos').innerHTML = lista.map(c => {
const cls = c.estado === 'firmado' ? 'firmado'
: c.estado === 'enviado' ? 'enviado'
: 'pendiente';
const ico = c.estado === 'firmado' ? 'fa-check-circle'
: c.estado === 'enviado' ? 'fa-envelope'
: 'fa-clock';
return `<div class="consent-item ${cls}">
<i class="fas ${ico}"></i>
<span class="flex-1">${escHtml(c.formulario_nombre || 'Consentimiento')}</span>
<span class="badge">${escHtml(c.estado)}</span>
</div>`;
}).join('');
}
// ── Enviar consentimientos ────────────────────────────────────
async function enviarConsentimientos() {
if (!turnoActivo || !solicitudActiva) return;
const btn = document.getElementById('btn-enviar-consent');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando…';
try {
const res = await fetch(API + 'send_consentimiento.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
consentimientos = json.data.consentimientos || consentimientos;
renderConsentimientos(consentimientos);
btn.innerHTML = '<i class="fas fa-check me-1"></i>Enviado';
} catch (err) {
mostrarError(err.message);
btn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>Enviar consentimientos';
} finally {
btn.disabled = false;
}
}
// ── Pasar a lugar ─────────────────────────────────────────────
async function pasarALugar() {
if (!turnoActivo || !solicitudActiva) return;
const btn = document.getElementById('btn-pasar-lugar');
btn.disabled = true;
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
turno_id: turnoActivo.id,
nuevo_estado: 'en_espera_lugar',
lugar_id: solicitudActiva.lugar_id,
}),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); btn.disabled = false; return; }
// Reiniciar ficha
turnoActivo = null;
solicitudActiva = null;
document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('ficha-placeholder').classList.remove('d-none');
document.getElementById('badge-turno-activo').classList.add('d-none');
cargarCola();
} catch (err) {
mostrarError(err.message);
btn.disabled = false;
}
}
// ── Ausente ───────────────────────────────────────────────────
async function marcarAusente() {
if (!turnoActivo) return;
if (!confirm(`¿Marcar turno ${turnoActivo.codigo} como AUSENTE?`)) return;
await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'ausente' }),
});
turnoActivo = null;
solicitudActiva = null;
document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('ficha-placeholder').classList.remove('d-none');
document.getElementById('badge-turno-activo').classList.add('d-none');
cargarCola();
}
// ── Helpers ───────────────────────────────────────────────────
function resetCheckboxes() {
document.querySelectorAll('.exam-chk').forEach(c => c.checked = false);
}
function toggleTodosExamenes(val) {
document.querySelectorAll('.exam-chk').forEach(c => c.checked = val);
}
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str)));
return d.innerHTML;
}
function mostrarError(msg) {
alert('Error: ' + msg);
}
function mostrarAviso(titulo, msg) {
alert(titulo + '\n' + msg);
}
</script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
<?php
/**
* run_005_registro_exams.php
* Ejecuta la migración 005 — tablas de Registro de Exámenes.
*
* USO: php run_005_registro_exams.php (o abrirlo en el navegador si el servidor lo permite)
*/
require_once __DIR__ . '/config/config.php';
header('Content-Type: text/plain; charset=utf-8');
$sqlFile = __DIR__ . '/migrations/005_registro_exams.sql';
if (!file_exists($sqlFile)) {
die("ERROR: No se encontró {$sqlFile}\n");
}
$sql = file_get_contents($sqlFile);
// Filtrar comentarios de línea y dividir en sentencias
$statements = array_filter(
array_map('trim', explode(';', $sql)),
fn($s) => $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";
+237 -8
View File
@@ -1,13 +1,22 @@
<?php
/**
* ver_formulario_enviado.php — Ver respuesta de un formulario (imprimible / PDF)
* Acceso admin/enfermero: ver_formulario_enviado.php?id=ENVIO_ID (requiere sesión)
* Acceso público cliente: ver_formulario_enviado.php?t=TOKEN (sin sesión, solo firmado/completado)
* Acceso admin/enfermero: ver_formulario_enviado.php?id=ENVIO_ID (requiere sesión)
* Acceso público cliente: ver_formulario_enviado.php?t=TOKEN (sin sesión, solo firmado/completado)
* Acceso turnero (consent): ver_formulario_enviado.php?token=UUID (firma de consentimiento turnero)
*/
require_once 'config/config.php';
// ── TURNERO: Consentimiento por token UUID (detección previa al flujo normal) ──
$tokenTurnero = trim($_GET['token'] ?? '');
$modoTurnero = ($tokenTurnero !== '');
if ($modoTurnero && !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $tokenTurnero)) {
http_response_code(400); die('Token de consentimiento inválido');
}
$tokenPublico = trim($_GET['t'] ?? '');
$modoPublico = ($tokenPublico !== '');
$modoPublico = ($modoTurnero || $tokenPublico !== '');
if (!$modoPublico && !isUserLoggedIn()) {
header('Location: login.php'); exit;
@@ -16,7 +25,93 @@ if (!$modoPublico && !isUserLoggedIn()) {
// ── Cargar datos ──────────────────────────────────────────────────────
$db = Database::getInstance();
if ($modoPublico) {
if ($modoTurnero) {
// ── Turnero: cargar desde turnero_consentimientos ──────────────────
$tcRow = $db->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 {
<!-- ── Barra de acciones ──────────────────────────────────────────── -->
<div class="action-bar no-print">
<?php if (!$modoPublico): ?>
<a href="lab_formularios.php#envios"><i class="fas fa-arrow-left me-1"></i>Volver</a>
<?php else: ?>
<span style="font-size:13px;opacity:.85"><i class="fas fa-file-alt me-1"></i>Documento firmado</span>
<?php if ($modoTurnero): ?>
<span style="font-size:13px;opacity:.85">
<i class="fas fa-file-signature me-1"></i>
<?= $envio['estado'] === 'firmado' ? 'Consentimiento firmado ✓' : 'Lea el documento y firme al final' ?>
</span>
<div style="flex:1"></div>
<?php if ($envio['estado'] === 'firmado'): ?>
<button onclick="window.print()" class="btn btn-warning btn-sm fw-semibold">
<i class="fas fa-file-pdf me-2"></i>Imprimir
</button>
<?php endif; ?>
<?php elseif (!$modoPublico): ?>
<a href="lab_formularios.php#envios"><i class="fas fa-arrow-left me-1"></i>Volver</a>
<div style="flex:1"></div>
<button onclick="window.print()" class="btn btn-warning btn-sm fw-semibold">
<i class="fas fa-file-pdf me-2"></i>Descargar / Imprimir PDF
</button>
<?php else: ?>
<span style="font-size:13px;opacity:.85"><i class="fas fa-file-alt me-1"></i>Documento firmado</span>
<div style="flex:1"></div>
<button onclick="window.print()" class="btn btn-warning btn-sm fw-semibold">
<i class="fas fa-file-pdf me-2"></i>Descargar / Imprimir PDF
</button>
<?php endif; ?>
</div>
<!-- ── Documento ─────────────────────────────────────────────────── -->
@@ -466,6 +576,40 @@ function esc2(mixed $v): string {
</div>
<?php endif; ?>
<!-- ── Widget firma turnero (solo modo consentimiento) ───────── -->
<?php if ($modoTurnero && $envio['estado'] !== 'firmado'): ?>
<div class="mt-4 no-print" id="turneroFirmaWidget">
<div class="section-title" style="color:#1565c0">
<i class="fas fa-pen me-1"></i>Firma del paciente / responsable
</div>
<p class="text-muted small mb-2">
He leído y comprendido el contenido de este documento.
Por favor dibuje su firma en el recuadro:
</p>
<canvas id="turneroCv" width="500" height="160"
style="border:2px solid #1565c0;border-radius:8px;background:#f0f4ff;
cursor:crosshair;display:block;max-width:100%;touch-action:none">
</canvas>
<div class="mt-2 d-flex gap-2 flex-wrap">
<button id="turneroLimpiar" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-eraser me-1"></i>Limpiar
</button>
<button id="turneroFirmar" class="btn btn-success fw-semibold">
<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento
</button>
</div>
<div id="turneroMsg" class="mt-2 small"></div>
</div>
<?php elseif ($modoTurnero && $envio['estado'] === 'firmado'): ?>
<div class="alert alert-success mt-4 d-flex align-items-center gap-2 no-print">
<i class="fas fa-check-circle fs-4"></i>
<div>
<strong>Consentimiento firmado</strong><br>
Puede cerrar esta ventana. El personal de salud ha sido notificado.
</div>
</div>
<?php endif; ?>
</div><!-- /doc-body -->
<div class="doc-footer">
@@ -575,6 +719,91 @@ function esc2(mixed $v): string {
});
});
})();
/* ── Firma de consentimiento turnero ────────────────────────────────── */
(function () {
var widget = document.getElementById('turneroFirmaWidget');
if (!widget) return;
var canvas = document.getElementById('turneroCv');
var ctx = canvas.getContext('2d');
var btnLimp = document.getElementById('turneroLimpiar');
var btnFirm = document.getElementById('turneroFirmar');
var msgEl = document.getElementById('turneroMsg');
var drawing = false;
(function scaleCanvas() {
var ratio = window.devicePixelRatio || 1;
var w = canvas.offsetWidth || canvas.width;
var h = canvas.offsetHeight || canvas.height;
canvas.width = w * ratio;
canvas.height = h * ratio;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
ctx.scale(ratio, ratio);
ctx.strokeStyle = '#1565c0';
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
})();
function getPos(e) {
var r = canvas.getBoundingClientRect();
var src = e.touches ? e.touches[0] : e;
return { x: src.clientX - r.left, y: src.clientY - r.top };
}
canvas.addEventListener('mousedown', function(e){ drawing=true; ctx.beginPath(); var p=getPos(e); ctx.moveTo(p.x,p.y); });
canvas.addEventListener('mousemove', function(e){ if(!drawing) return; var p=getPos(e); ctx.lineTo(p.x,p.y); ctx.stroke(); });
canvas.addEventListener('mouseup', function(){ drawing=false; });
canvas.addEventListener('mouseleave', function(){ drawing=false; });
canvas.addEventListener('touchstart', function(e){ e.preventDefault(); drawing=true; ctx.beginPath(); var p=getPos(e); ctx.moveTo(p.x,p.y); }, {passive:false});
canvas.addEventListener('touchmove', function(e){ e.preventDefault(); if(!drawing) return; var p=getPos(e); ctx.lineTo(p.x,p.y); ctx.stroke(); }, {passive:false});
canvas.addEventListener('touchend', function(){ drawing=false; });
btnLimp.addEventListener('click', function() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
msgEl.textContent = '';
});
btnFirm.addEventListener('click', function() {
var png = canvas.toDataURL('image/png');
if (png.length < 1500) {
msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-exclamation-triangle me-1"></i>Por favor dibuje su firma antes de confirmar.</span>';
return;
}
btnFirm.disabled = true;
btnFirm.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando...';
msgEl.textContent = '';
fetch(window.location.href, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ firma_svg: png })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) {
widget.outerHTML =
'<div class="alert alert-success mt-4 d-flex align-items-center gap-2 no-print">' +
'<i class="fas fa-check-circle fs-4"></i>' +
'<div><strong>Consentimiento firmado correctamente.</strong><br>' +
'Puede cerrar esta ventana.</div></div>';
// Notificar ventana padre si está en iframe/modal
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
} else {
msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error || 'Error al guardar') + '</span>';
btnFirm.disabled = false;
btnFirm.innerHTML = '<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento';
}
})
.catch(function() {
msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>Error de conexión.</span>';
btnFirm.disabled = false;
btnFirm.innerHTML = '<i class="fas fa-check-circle me-1"></i>Confirmar y firmar consentimiento';
});
});
})();
</script>
</body>
</html>