up
This commit is contained in:
+574
-80
@@ -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
|
||||
id, sesion_id FK turnero_sesiones,
|
||||
numero INT, ← correlativo dentro de la sesión
|
||||
codigo VARCHAR(10), ← "A001", "B012"
|
||||
servicio_id,
|
||||
paciente_nombre VARCHAR(150),
|
||||
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.
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
]),
|
||||
]);
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ── 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function escAtt(s) { return String(s||'').replace(/"/g,'"'); }
|
||||
})();
|
||||
</script>
|
||||
<?php Layout::close(); ?>
|
||||
@@ -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,'"')})">
|
||||
<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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function escAtt(s) { return String(s||'').replace(/"/g,'"'); }
|
||||
|
||||
// ── Inicio ───────────────────────────────────────────────
|
||||
cargarOrden();
|
||||
})();
|
||||
</script>
|
||||
<?php Layout::close(); ?>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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> •
|
||||
Turnos: <strong><?= $sesionHoy['total_turnos'] ?></strong>
|
||||
<?php if ($sesionHoy['inicio_at']): ?>
|
||||
• Inicio: <strong><?= date('H:i', strtotime($sesionHoy['inicio_at'])) ?></strong>
|
||||
<?php endif; ?>
|
||||
<?php if ($sesionHoy['fin_at']): ?>
|
||||
• 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>
|
||||
@@ -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 ? ` • 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>
|
||||
@@ -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} — ${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} • ${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>
|
||||
@@ -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>
|
||||
@@ -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,'"')})">
|
||||
<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>
|
||||
@@ -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,'"')})">
|
||||
<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>
|
||||
@@ -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";
|
||||
+235
-6
@@ -3,11 +3,20 @@
|
||||
* 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 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>
|
||||
|
||||
Reference in New Issue
Block a user