This commit is contained in:
Lizandro Guarnizo
2026-04-16 22:23:33 -05:00
parent 50b7f7f8ca
commit ef822dd3c7
38 changed files with 2448 additions and 0 deletions
+7
View File
@@ -11,6 +11,13 @@ DirectoryIndex index.php index.html
<IfModule mod_rewrite.c>
RewriteEngine On
# ── ERP: URLs limpias para módulos (/modulo/vista → index.php?m=modulo&v=vista)
# Solo aplica cuando el módulo está bajo /modules/ pero se accede con URL limpia.
# No redirige archivos ni carpetas que existen físicamente.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([a-zA-Z0-9_]+)/([a-zA-Z0-9_]+)/?$ erp.php?m=$1&v=$2 [QSA,L]
# Bloquear acceso a directorios de desarrollo y temporales
RewriteRule ^(dev|tmp)(/|$) - [F,L]
+574
View File
@@ -0,0 +1,574 @@
================================================================================
PLAN DE EVOLUCIÓN: BOT WHATSAPP → ERP MULTI-MÓDULO
Proyecto: Panel de Laboratorio + WhatsApp Bot Manager
Fecha: Abril 2026
================================================================================
────────────────────────────────────────────────────────────────────────────────
1. ESTADO ACTUAL — QUÉ TENEMOS
────────────────────────────────────────────────────────────────────────────────
MÓDULOS YA OPERATIVOS:
✔ WhatsApp Bot respuestas automáticas, menús, plantillas Meta
✔ Conversaciones bandeja de entrada, operadores asignados
✔ Mensajes Programados campañas por fecha/hora
✔ Pacientes ficha clínica, historial
✔ Domicilios agenda de servicios a domicilio con flujo de estados
✔ Enfermeras/Personal portal propio, agenda diaria
✔ Órdenes Médicas PDF adjunto, autorización
✔ Formularios builder drag-drop, firma digital, enlace público
✔ Reportes ingresos, rendimiento de enfermeros, pagos
✔ Actividad/Auditoría log de cambios por usuario
✔ Usuarios & Roles RBAC completo (roles + role_modules)
✔ Configuración tarifas, laboratorio, WhatsApp
ARQUITECTURA ACTUAL:
• Todos los archivos PHP en la raíz del proyecto (lab_*.php, index.php…)
• API separada en /api/ y /api/lab/
• Clases en /classes/ y /services/
• Base de datos: 34 tablas, una sola BD "usite_whatsapp_bot"
• RBAC: tabla roles + role_modules + SYSTEM_MODULES en config.php
• Sesiones PHP nativas (login en admin_users)
PROBLEMA PRINCIPAL PARA CRECER:
• Sin sistema de enrutamiento → cada módulo es un archivo suelto en raíz
• SYSTEM_MODULES hardcodeado en config.php (hay que editarlo cada vez)
• No hay separación de carpetas por módulo (todo mezclado)
• Navegación duplicada en cada .php del módulo lab
• Sin un punto de entrada único (todo camino directo al .php)
────────────────────────────────────────────────────────────────────────────────
2. VISIÓN TARGET — ERP MULTI-MÓDULO
────────────────────────────────────────────────────────────────────────────────
CONCEPTO:
Un sistema central con un panel unificado donde cada "módulo" es un paquete
autocontenido. El core gestiona: autenticación, routing, menú lateral,
RBAC, notificaciones y eventos. Los módulos se enchufan al core sin
modificar archivos existentes.
MÓDULOS PLANIFICADOS (oleadas):
OLEADA 0 Ya en producción (refactorizar para que usen la nueva estructura)
- whatsapp_bot → Bot + Conversaciones + Plantillas
- lab_domicilios → Agenda de domicilios
- lab_pacientes → Pacientes y fichas
- lab_formularios → Builder de formularios
- lab_reportes → Reportes e indicadores
- lab_ordenes → Órdenes médicas
- lab_enfermeras → Gestión de personal clínico
- sistema → Usuarios, Roles, Configuración
OLEADA 1 Nuevos módulos solicitados
- turnero → Sistema de turnos presenciales en recepción
OLEADA 2 Módulos futuros probables
- registro_exams → Registro directo de exámenes de laboratorio
(sin domicilio: paciente llega a la sede)
- facturacion → Facturación electrónica / DIAN (Colombia)
- inventario → Reactivos, insumos, stock mínimo
- citas → Agenda de citas con calendario visual
- resultados → Entrega digital de resultados (portal paciente)
- crm_pacientes → Campañas, seguimientos, cohortes
────────────────────────────────────────────────────────────────────────────────
3. NUEVA ESTRUCTURA DE CARPETAS
────────────────────────────────────────────────────────────────────────────────
/
├── core/ ← Núcleo del ERP (NO tocar por módulos)
│ ├── App.php ← Bootstrap, registro de módulos
│ ├── Router.php ← Enrutador simple (mapea URL→módulo/acción)
│ ├── Auth.php ← Login/logout/sesión (extraído de config.php)
│ ├── Rbac.php ← hasModule(), requireModule(), permisos
│ ├── ModuleRegistry.php ← Catálogo dinámico de módulos instalados
│ ├── Layout.php ← Renderiza sidebar, navbar, footer
│ └── Helpers.php ← Funciones globales (esc, jsonOk, etc.)
├── modules/ ← Un subdirectorio por módulo
│ ├── whatsapp_bot/
│ │ ├── module.php ← Descriptor: nombre, slug, icono, permisos
│ │ ├── views/ ← Las páginas (ex lab_*.php migradas)
│ │ └── api/ ← Endpoints REST propios del módulo
│ │
│ ├── domicilios/
│ │ ├── module.php
│ │ ├── views/
│ │ └── api/
│ │
│ ├── 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
│ │ └── api/
│ │ ├── create_turno.php
│ │ ├── llamar_turno.php
│ │ ├── get_estado.php
│ │ └── sse_turno.php ← Server-Sent Events para la pantalla TV
│ │
│ ├── registro_exams/ ← PENDIENTE — Oleada 2
│ │ ├── module.php
│ │ ├── views/
│ │ │ ├── registrar.php
│ │ │ ├── lista.php
│ │ │ └── detalle.php
│ │ └── api/
│ │ ├── save_examen.php
│ │ ├── get_examenes.php
│ │ ├── cambiar_estado.php
│ │ └── imprimir_etiqueta.php
│ │
│ └── sistema/
│ ├── module.php
│ ├── views/
│ │ ├── usuarios.php ← lab_usuarios.php migrado
│ │ └── configuracion.php
│ └── api/
├── shared/ ← Componentes reutilizables entre módulos
│ ├── components/
│ │ ├── sidebar.php ← Menú lateral dinámico (según módulos del rol)
│ │ ├── navbar.php
│ │ ├── page_header.php
│ │ └── modal_confirm.php
│ └── js/
│ ├── erp-core.js ← fetch wrapper, toasts, eventos globales
│ └── erp-table.js ← Tablas con filtro, paginación, export
├── classes/ ← (ya existe, mantener)
├── services/ ← (ya existe, mantener)
├── api/ ← API global (ya existe, mantener para bot)
├── config/
│ └── config.php ← Simplificado: SOLO BD y constantes base
├── public/ ← (opcional futuro) assets compilados
│ └── assets/
└── index.php ← Punto de entrada único → instancia App.php
NOTA SOBRE MIGRACIÓN SIN ROMPER NADA:
Los archivos lab_*.php actuales permanecen en raíz mientras se migran.
Se crea un alias: cada modules/X/views/Y.php incluye el legacy si aún no
se ha reescrito. No hay "big bang rewrite".
────────────────────────────────────────────────────────────────────────────────
4. SISTEMA DE ROLES Y PERMISOS (RBAC EXPANDIDO)
────────────────────────────────────────────────────────────────────────────────
MODELO ACTUAL:
roles (id, name, slug, color, is_system)
└── role_modules (role_id, module_slug) ← solo read/write implícito
MODELO PROPUESTO — RBAC con acciones:
┌─────────────────────────────────────────────────────────────┐
│ roles │
│ id | name | slug | description | color | is_system │
└──────────────────┬──────────────────────────────────────────┘
│ 1:N
┌──────────────────▼──────────────────────────────────────────┐
│ role_permissions (reemplaza role_modules con más granularidad)
│ id | role_id | module_slug | can_view | can_create │
│ can_edit | can_delete | can_export | extra_json │
└─────────────────────────────────────────────────────────────┘
extra_json ejemplos:
turnero → {"puede_llamar": true, "puede_reasignar": false}
reportes → {"rango_max_dias": 90}
whatsapp → {"puede_broadcast": true}
ROLES DEL SISTEMA A DEFINIR:
Slug Nombre Descripción
─────────────────────────────────────────────────────────────────
superadmin Super Admin Acceso total, configura el sistema
admin Administrador Acceso total sin configuración técnica
recepcionista Recepcionista Turnero + Pacientes (Oleada 1)
bacteriologo Bacteriólogo Registro exámenes + Resultados (Oleada 2)
enfermero Enfermero Portal domicilios (ya existe)
supervisor Supervisor Reportes + sin acceso a config
operador_bot Operador WhatsApp Solo bandeja de conversaciones
readonly Solo lectura Ver dashboards sin editar
REGLA: is_system=1 en superadmin y admin → no se pueden borrar.
Los demás roles son personalizables por el cliente.
FLUJO DE VERIFICACIÓN EN UNA VISTA:
1. ¿Está logueado? → sino → login
2. ¿El rol tiene module_slug? → sino → 403 sin módulo
3. ¿La acción requiere can_edit? → verificar permiso específico
4. ¿Hay restricción extra_json? → verificar campo relevante
HELPER PROPUESTO (core/Rbac.php):
hasModule($slug) → bool (ya existe en helpers)
canDo($slug, $action) → bool (nuevo, $action = view/create/edit/delete/export)
requireAction($slug, $action) → lanza 403 si no tiene permiso
────────────────────────────────────────────────────────────────────────────────
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
TABLAS BD NUEVAS:
turnero_servicios
id, nombre, prefijo (ej. "A"), color, tiempo_estimado_min, activo
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)
turnero_turnos
id, sesion_id,
numero INT, ← número correlativo de la sesión
codigo VARCHAR(10), ← "A001", "B012"
servicio_id,
paciente_nombre VARCHAR(150),
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
turnero_modulos
id, nombre (ej. "Ventanilla 1"), activo, usuario_actual INT FK admin_users
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)
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
INTEGRACIÓN CON WHATSAPP (OPCIONAL):
Cuando se llama el turno → enviar mensaje al paciente si dejó celular
Usar WhatsAppService ya existente.
────────────────────────────────────────────────────────────────────────────────
6. MÓDULO REGISTRO DE EXÁMENES — DISEÑO DETALLADO [OLEADA 2 — pendiente]
────────────────────────────────────────────────────────────────────────────────
CONCEPTO:
Registrar exámenes de análisis clínico cuando el paciente llega a la sede
(a diferencia de lab_domicilios que es a domicilio).
Incluye: recepción de muestra, trazabilidad, estado de procesamiento,
y eventualmente entrega de resultados.
TABLAS BD NUEVAS:
exam_tipos
id, codigo (ej. "HEM", "GLU"), nombre, categoria, precio_base,
requiere_ayunas TINYINT, instrucciones TEXT, activo
exam_ordenes (la "orden de trabajo" del laboratorio)
id, paciente_id FK lab_pacientes,
numero_orden VARCHAR(20) UNIQUE, ← ej. "ORD-2026-00123"
origen ENUM(presencial, domicilio, whatsapp, referido),
medico_remitente VARCHAR(150),
entidad_pagadora VARCHAR(150),
tipo_pago ENUM(particular, eps, convenio),
total_cobrado DECIMAL(10,2),
estado ENUM(pendiente, en_proceso, parcial, completado, entregado, anulado),
observaciones TEXT,
recibido_por INT FK admin_users,
creado_at, actualizado_at
exam_items (los exámenes individuales dentro de una orden)
id, orden_id FK exam_ordenes,
tipo_id FK exam_tipos,
estado ENUM(pendiente, muestra_tomada, en_analisis, resultado_listo, entregado),
muestra_tipo VARCHAR(50), ← sangre, orina, hisopado...
muestra_recibida_at,
resultado TEXT,
resultado_pdf VARCHAR(300),
procesado_por INT FK admin_users,
entregado_at
exam_etiquetas (para imprimir en los tubos)
id, item_id FK exam_items,
codigo_barras VARCHAR(50) UNIQUE,
impreso_at, impreso_por INT FK admin_users
FLUJO:
1. Recepcionista busca/crea paciente (reutiliza lab_pacientes)
2. Crea orden → agrega exámenes de la lista exam_tipos
3. Sistema genera número de orden y código de barras por tubo
4. Se imprime etiqueta (ZPL o PDF)
5. Bacteriólogo cambia estado → en_análisis → resultado_listo
6. Administrador entrega resultados (descarga PDF / enlace portal)
RELACIÓN CON MÓDULOS EXISTENTES:
• Paciente → usa lab_pacientes (ya existe)
• Si viene de un domicilio → exam_ordenes.origen = 'domicilio',
vincular con lab_domicilios (campo opcional domicilio_id)
• Si tiene orden médica adjunta → vincular con lab_ordenes_medicas
────────────────────────────────────────────────────────────────────────────────
7. BASE DE DATOS — PLAN DE MIGRACIONES
────────────────────────────────────────────────────────────────────────────────
REGLA: todas las migraciones son ADITIVAS (ALTER ADD, CREATE TABLE).
NUNCA DROP COLUMN ni RENAME en producción sin respaldo previo.
Migración 001 RBAC expandido
ALTER TABLE role_modules ADD COLUMN can_view TINYINT(1) DEFAULT 1;
ALTER TABLE role_modules ADD COLUMN can_create TINYINT(1) DEFAULT 0;
ALTER TABLE role_modules ADD COLUMN can_edit TINYINT(1) DEFAULT 0;
ALTER TABLE role_modules ADD COLUMN can_delete TINYINT(1) DEFAULT 0;
ALTER TABLE role_modules ADD COLUMN can_export TINYINT(1) DEFAULT 0;
ALTER TABLE role_modules ADD COLUMN extra_json JSON NULL;
RENAME TABLE role_modules TO role_permissions; ← (o alias)
Migración 002 Nuevos roles
INSERT INTO roles (name, slug, description, color, is_system) VALUES
('Super Admin', 'superadmin', 'Acceso total al sistema', '#dc3545', 1),
('Recepcionista', 'recepcionista', 'Turnero y recepción de muestras', '#198754', 0),
('Bacteriólogo', 'bacteriologo', 'Análisis y resultados', '#0dcaf0', 0),
('Supervisor', 'supervisor', 'Solo reportes y consultas', '#fd7e14', 0),
('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 (...)
Migración 004 SYSTEM_MODULES dinámica (mover de config.php a BD) [Oleada 1]
CREATE TABLE system_modules (
slug VARCHAR(100) PK,
name VARCHAR(150),
icon VARCHAR(50), ← "fas fa-vials"
category VARCHAR(50), ← "lab", "bot", "sistema", "clinico"
route VARCHAR(200), ← URL base del módulo
is_active TINYINT(1),
sort_order INT,
created_at TIMESTAMP
)
Poblar con los módulos actuales + turnero.
registro_exams se agrega en Migración 005 (Oleada 2).
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 (...)
CREATE TABLE exam_ordenes (...)
CREATE TABLE exam_items (...)
CREATE TABLE exam_etiquetas(...)
────────────────────────────────────────────────────────────────────────────────
8. ROUTER Y PUNTO DE ENTRADA
────────────────────────────────────────────────────────────────────────────────
OPCIÓN RECOMENDADA (sin framework, compatible con estructura actual):
index.php → incluye core/App.php
App.php → lee $_GET['m'] (módulo) y $_GET['v'] (vista)
→ verifica sesión + permisos via Rbac.php
→ incluye modules/{m}/views/{v}.php
→ si no existe → 404 amigable
URLS LIMPIAS con .htaccess:
/turnero/dashboard → ?m=turnero&v=dashboard
/turnero/display → ?m=turnero&v=display (sin login)
/lab/domicilios → ?m=domicilios&v=index
/lab/pacientes → ?m=lab_pacientes&v=index
.htaccess ya existe en el proyecto → solo agregar RewriteRules.
COMPATIBILIDAD:
Los lab_*.php en raíz siguen funcionando directamente.
El router añade una capa adicional, no reemplaza lo existente.
Migración gradual: mover módulos uno a uno al nuevo sistema.
────────────────────────────────────────────────────────────────────────────────
9. LAYOUT Y NAVEGACIÓN UNIFICADA
────────────────────────────────────────────────────────────────────────────────
PROBLEMA ACTUAL:
Cada lab_*.php repite el mismo sidebar a mano (>50 líneas duplicadas).
Si se agrega un módulo hay que editar todos los archivos.
SOLUCIÓN:
shared/components/sidebar.php → se genera dinámicamente desde:
1. La tabla system_modules (activos)
2. Filtrado por los módulos que tiene el rol del usuario logueado
3. Agrupados por "category" (Bot, Laboratorio, Clínico, Sistema)
Cada layout nueva vista incluye:
<?php include APP_ROOT . '/shared/components/sidebar.php'; ?>
El sidebar detecta la URL activa y resalta el elemento correspondiente.
GRUPOS DEL MENÚ LATERAL:
🤖 WhatsApp
Conversaciones | Bot & Menús | Plantillas | Programados
🏥 Laboratorio
Dashboard | Domicilios | Pacientes | Enfermeras | Órdenes
🧪 Clínico (Oleada 2)
Registro Exámenes | Resultados
🎟️ Turnero (Oleada 1)
Dashboard | Configurar Servicios
📊 Reportes
Ingresos | Rendimiento | Exportar
⚙️ Sistema
Usuarios & Roles | Configuración | Actividad
────────────────────────────────────────────────────────────────────────────────
10. PLAN DE IMPLEMENTACIÓN — FASES
────────────────────────────────────────────────────────────────────────────────
── BASE DEL SISTEMA ─────────────────────────────────────────────────────────
FASE 0 Preparación (1-2 días) [SIN impacto en producción] ✅ COMPLETADA
✓ Crear carpetas: core/, modules/, shared/
✓ Extraer Auth.php y Rbac.php desde config.php y _helpers.php
✓ Crear shared/components/sidebar.php unificado
✓ Migración 001: expandir role_permissions (columnas can_*)
✓ Migración 002: insertar nuevos roles
✓ Agregar nuevos slugs a SYSTEM_MODULES en config.php
FASE 1 Core: Router y estructura modular (1 semana) ✅ COMPLETADA
✓ Crear core/Router.php y core/App.php
✓ Crear core/Layout.php, core/Helpers.php (core/Rbac.php ya existía de FASE 0)
✓ Punto de entrada erp.php (coexiste con index.php legacy)
✓ Activar .htaccess con URLs limpias (/modulo/vista → erp.php?m=&v=)
✓ Mover módulos existentes a modules/ (module.php + views/index.php stubs para 11 módulos)
FASE 2 SYSTEM_MODULES dinámica (2 días) ✅ COMPLETADA
✓ Migración 004: tabla system_modules (con INSERT IGNORE, oleada, sort_order)
✓ core/ModuleRegistry.php: lee BD → module.php → SYSTEM_MODULES (fallback en cascada)
✓ Sidebar lee desde ModuleRegistry en lugar de config.php (dinámico por categoría)
✓ Interfaz /erp.php?m=usuarios&v=modulos (toggle activo + sort_order)
── 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 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)
□ Facturación, Inventario, CRM, Portal Paciente…
□ API pública con tokens (para integraciones)
□ App móvil (portal enfermero actual → Progressive Web App)
────────────────────────────────────────────────────────────────────────────────
11. CONVENCIONES TÉCNICAS A SEGUIR
────────────────────────────────────────────────────────────────────────────────
NOMENCLATURA:
• Tablas BD: snake_case, prefijadas por módulo (turnero_, exam_, lab_)
• Archivos PHP: snake_case (save_turno.php, get_examenes.php)
• Clases PHP: PascalCase (TurneroService.php, ExamOrder.php)
• Slugs: siempre lowercase con guiones (turnero, registro-exams)
• API endpoints: REST-ish, verbos en el nombre (get_, save_, delete_)
SEGURIDAD:
• Toda API: requireMethod() + verificar sesión + verificar permiso
• Parámetros: siempre sanitizar y tipificar antes de usar en SQL
• Queries: siempre PDO preparado (ya implementado en Database.php)
• Pantallas sin login (kiosko, display): NUNCA exponer datos sensibles,
solo número de turno y servicio.
• Subida de archivos: misma lógica de /upload.php (validar MIME + ext)
• CSRF: para formularios de mutación, incluir token en sesión
ESTILO DE CÓDIGO:
• Frontend: Bootstrap 5 + Font Awesome 6 (ya en uso, mantener)
• Sin jQuery nuevo; usar fetch() nativo (ya en uso)
• Toasts de notificación: usar patrón ya existente en index.php
• Responsive: mobile-first (portal enfermero se usa desde celular)
API RESPONSE FORMAT (ya establecido, mantener):
Éxito: { "ok": true, "data": {...}, "message": "..." }
Error: { "ok": false, "error": "...", "code": 4XX }
────────────────────────────────────────────────────────────────────────────────
12. DECISIONES CLAVE A CONFIRMAR CON EL CLIENTE
────────────────────────────────────────────────────────────────────────────────
1. ¿El turnero es para UNA sede o múltiples sedes?
(Multi-sede requiere añadir sede_id a las tablas)
2. ¿Facturación electrónica (DIAN) es obligatorio desde el inicio
o puede dejarse para Oleada 2?
3. ¿El portal de entrega de resultados es para los pacientes directamente
(requiere login/token para pacientes) o solo descarga interna?
4. ¿Se quiere app móvil nativa o es suficiente con PWA/responsive?
5. ¿Los exámenes tienen que integrar con algún analizador automático
(interfaz LIS) o el resultado se digita manual?
6. ¿El turno se puede tomar ANTES de llegar (turno virtual por WhatsApp)?
Esto conecta el módulo Turnero con el Bot.
7. ¿El sistema será multi-tenant (varios laboratorios/clientes en el mismo
servidor) o siempre una instalación por cliente?
────────────────────────────────────────────────────────────────────────────────
13. RESUMEN EJECUTIVO
────────────────────────────────────────────────────────────────────────────────
Lo que TENEMOS es una base sólida:
✓ BD bien estructurada con 34 tablas
✓ RBAC funcional (roles + módulos)
✓ API consistente con helpers reutilizables
✓ Autenticación robusta con bcrypt
✓ Integración WhatsApp activa
✓ 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
→ 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 Registro de Exámenes — Oleada 2 (5-7 días, pendiente)
Estrategia: EVOLUCIÓN INCREMENTAL, no reescritura.
El sistema sigue funcionando en producción mientras se construyen
los nuevos módulos en paralelo. Solo se migra lo viejo al nuevo
sistema cuando el nuevo está validado y estable.
================================================================================
Documento preparado por GitHub Copilot — Abril 2026
Próxima revisión: tras confirmar decisiones de la sección 12
================================================================================
+11
View File
@@ -22,7 +22,9 @@ define('ADMIN_PASSWORD', '$2y$10$IXCY8Sm1xFkfhC6Y67Ahn.QLHxE.sjWfmTEOKFZdCN2a9s9
// Catálogo de todos los módulos disponibles para asignar a los roles.
// Clave: slug interno · Valor: etiqueta legible para la UI.
define('SYSTEM_MODULES', [
// ── Bot ──────────────────────────────────────────────────────────────────
'whatsapp' => 'WhatsApp Bot',
// ── Laboratorio (Oleada 0) ───────────────────────────────────────────────
'lab_dashboard' => 'Dashboard Lab',
'lab_ordenes' => 'Órdenes Médicas',
'lab_pacientes' => 'Pacientes',
@@ -31,8 +33,17 @@ define('SYSTEM_MODULES', [
'lab_formularios' => 'Formularios',
'lab_reportes' => 'Reportes',
'lab_configuracion' => 'Configuración Lab',
// ── Sistema ──────────────────────────────────────────────────────────────
'usuarios' => 'Gestión de Usuarios',
'enfermero_portal' => 'Portal Enfermero',
// ── Oleada 1 — Turnero ───────────────────────────────────────────────────
'turnero' => 'Turnero',
// ── Oleada 2 — pendiente ─────────────────────────────────────────────────
'registro_exams' => 'Registro de Exámenes',
'facturacion' => 'Facturación',
'inventario' => 'Inventario',
'citas' => 'Citas',
'resultados' => 'Resultados',
]);
// Función para cargar archivo .env
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* core/App.php
* Bootstrap y punto de entrada del ERP.
*
* Uso desde index.php (punto de entrada del módulo ERP):
*
* define('APP_ROOT', __DIR__);
* require_once __DIR__ . '/core/App.php';
* App::run();
*
* Los archivos lab_*.php legacy en raíz siguen funcionando directamente
* sin pasar por App — compatibilidad total garantizada.
*/
// ─── Constante de raíz del proyecto ─────────────────────────────────────────
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__));
}
// ─── Dependencias del core ───────────────────────────────────────────────────
require_once APP_ROOT . '/config/config.php';
require_once APP_ROOT . '/core/Helpers.php';
require_once APP_ROOT . '/core/Auth.php';
require_once APP_ROOT . '/core/Rbac.php';
require_once APP_ROOT . '/core/Router.php';
require_once APP_ROOT . '/core/Layout.php';
class App
{
// ─── Punto de entrada ────────────────────────────────────────────────────
public static function run(): void
{
self::boot();
$router = new Router();
// Rutas públicas: sin verificación de sesión
if (!$router->isPublic()) {
Auth::requireLogin();
// Enfermeros van a su portal propio, no al panel ERP
if (Auth::isEnfermero()) {
header('Location: ' . APP_ROOT . '/../enfermero_portal.php');
exit;
}
}
self::dispatch($router);
}
// ─── Bootstrap ──────────────────────────────────────────────────────────
/**
* Inicializa la sesión y la zona horaria.
* Se puede llamar también desde archivos legacy para obtener sesión lista.
*/
public static function boot(): void
{
if (session_status() === PHP_SESSION_NONE) {
// Cookies de sesión seguras
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
date_default_timezone_set(defined('TIMEZONE') ? TIMEZONE : 'America/Bogota');
}
// ─── Dispatch ───────────────────────────────────────────────────────────
private static function dispatch(Router $router): void
{
try {
$viewFile = $router->resolveFile();
} catch (RuntimeException $e) {
self::render404($router->getModule(), $router->getView());
return;
}
// Verificar permiso de módulo (solo en rutas protegidas)
if (!$router->isPublic()) {
$module = $router->getModule();
// Si el módulo está registrado en SYSTEM_MODULES, verificar acceso
if (defined('SYSTEM_MODULES') && array_key_exists($module, SYSTEM_MODULES)) {
if (!Rbac::hasModule($module)) {
self::render403($module);
return;
}
}
}
// Ejecutar la vista del módulo
include $viewFile;
}
// ─── Páginas de error ────────────────────────────────────────────────────
private static function render404(string $module, string $view): void
{
http_response_code(404);
Layout::open('Página no encontrada', 'fas fa-exclamation-triangle');
echo '<div class="container-fluid py-5 text-center">';
echo '<i class="fas fa-exclamation-triangle fa-4x text-warning mb-3"></i>';
echo '<h2>404 — Módulo no encontrado</h2>';
echo '<p class="text-muted">El módulo <code>' . esc($module) . '</code> / vista <code>' . esc($view) . '</code> no existe.</p>';
echo '<a href="' . esc(APP_URL ?? '/') . '" class="btn btn-primary"><i class="fas fa-home me-1"></i>Ir al inicio</a>';
echo '</div>';
Layout::close();
}
private static function render403(string $module): void
{
http_response_code(403);
Layout::open('Acceso denegado', 'fas fa-lock');
echo '<div class="container-fluid py-5 text-center">';
echo '<i class="fas fa-lock fa-4x text-danger mb-3"></i>';
echo '<h2>403 — Sin permiso</h2>';
echo '<p class="text-muted">Tu rol no tiene acceso al módulo <code>' . esc($module) . '</code>.</p>';
echo '<a href="' . esc(APP_URL ?? '/') . '" class="btn btn-secondary"><i class="fas fa-arrow-left me-1"></i>Volver</a>';
echo '</div>';
Layout::close();
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* core/Auth.php
* Gestión de sesión y autenticación de usuarios del panel.
* Extraído de config/config.php para separar responsabilidades.
*
* USO:
* require_once __DIR__ . '/../core/Auth.php';
* Auth::requireLogin(); // en cualquier vista protegida
* Auth::requireNotEnfermero(); // excluir enfermeros del panel admin
*/
class Auth
{
// ─── Verificación de sesión ─────────────────────────────────────────────
public static function isLoggedIn(): bool
{
return isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true;
}
public static function requireLogin(string $redirect = 'login.php'): void
{
if (!self::isLoggedIn()) {
header("Location: $redirect");
exit;
}
}
public static function requireNotEnfermero(string $redirect = 'enfermero_portal.php'): void
{
if (self::isEnfermero()) {
header("Location: $redirect");
exit;
}
}
// ─── Datos del usuario en sesión ────────────────────────────────────────
public static function id(): ?int
{
return isset($_SESSION['admin_user']['id'])
? (int) $_SESSION['admin_user']['id']
: null;
}
public static function role(): string
{
return $_SESSION['admin_user']['role'] ?? 'admin';
}
public static function fullName(): string
{
return $_SESSION['admin_user']['full_name']
?? $_SESSION['admin_user']['username']
?? 'Usuario';
}
public static function roleId(): ?int
{
$id = $_SESSION['admin_user']['role_id'] ?? null;
return $id ? (int) $id : null;
}
public static function isEnfermero(): bool
{
return self::role() === 'enfermero';
}
public static function isAdmin(): bool
{
return self::role() === 'admin';
}
public static function isSuperAdmin(): bool
{
return self::role() === 'superadmin';
}
// ─── Login / Logout ─────────────────────────────────────────────────────
/**
* Intenta autenticar usuario contra la BD.
* Devuelve array con datos del usuario o false si falla.
*/
public static function attempt(string $username, string $password): array|false
{
// Delegar en la función legacy de config.php mientras coexistan
// (authenticateUser está definida en config/config.php)
return authenticateUser($username, $password);
}
/**
* Establece la sesión tras un login exitoso.
*/
public static function login(array $userData): void
{
session_regenerate_id(true);
$_SESSION['admin_logged_in'] = true;
$_SESSION['admin_user'] = $userData;
$_SESSION['login_time'] = time();
}
public static function logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(
session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']
);
}
session_destroy();
}
// ─── Tiempo de sesión ───────────────────────────────────────────────────
public static function isSessionExpired(): bool
{
if (!isset($_SESSION['login_time'])) return false;
$timeout = defined('SESSION_TIMEOUT') ? SESSION_TIMEOUT : 1800;
return (time() - $_SESSION['login_time']) > $timeout;
}
public static function refreshSession(): void
{
$_SESSION['login_time'] = time();
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* core/Helpers.php
* Funciones de utilidad globales del ERP.
* Consolida helpers duplicados de api/lab/_helpers.php y config/config.php.
*
* REQUIERE: config/config.php ya cargado (para APP_ROOT si se usa).
*/
// ─── Seguridad / Output ─────────────────────────────────────────────────────
/**
* Escapa una cadena para salida HTML segura.
*/
if (!function_exists('esc')) {
function esc(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
// ─── Respuestas JSON ────────────────────────────────────────────────────────
/**
* Envía respuesta JSON de éxito y termina la ejecución.
*
* @param array $payload Datos adicionales a fusionar en la respuesta.
* @param string $message Mensaje descriptivo opcional.
*/
if (!function_exists('jsonOk')) {
function jsonOk(array $payload = [], string $message = ''): void
{
if (ob_get_level() > 0) {
ob_clean();
}
header('Content-Type: application/json; charset=utf-8');
$resp = ['ok' => true, 'success' => true];
if ($message !== '') {
$resp['message'] = $message;
}
echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE);
exit;
}
}
/**
* Envía respuesta JSON de error con código HTTP y termina la ejecución.
*
* @param string $message Descripción del error.
* @param int $code Código HTTP (400, 401, 403, 404, 500…).
*/
if (!function_exists('jsonError')) {
function jsonError(string $message, int $code = 400): void
{
if (ob_get_level() > 0) {
ob_clean();
}
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(
['ok' => false, 'success' => false, 'error' => $message, 'code' => $code],
JSON_UNESCAPED_UNICODE
);
exit;
}
}
// ─── Request ────────────────────────────────────────────────────────────────
/**
* Detiene la ejecución con 405 JSON si el método HTTP no coincide.
*
* @param string $method Método esperado: 'GET', 'POST', etc.
*/
if (!function_exists('requireMethod')) {
function requireMethod(string $method): void
{
if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) {
jsonError('Método no permitido', 405);
}
}
}
/**
* Lee y decodifica el body JSON del request actual.
* Se cachea en la misma petición.
*
* @return array Datos decodificados o array vacío si el body no es JSON válido.
*/
if (!function_exists('inputJson')) {
function inputJson(): array
{
static $parsed = null;
if ($parsed === null) {
$raw = file_get_contents('php://input');
$parsed = json_decode($raw ?: '{}', true) ?? [];
}
return $parsed;
}
}
// ─── Texto / Formato ────────────────────────────────────────────────────────
/**
* Convierte bytes a representación legible (KB, MB, GB).
*/
if (!function_exists('formatBytes')) {
function formatBytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = $bytes > 0 ? floor(log($bytes) / log(1024)) : 0;
$pow = min($pow, count($units) - 1);
return round($bytes / (1024 ** $pow), $precision) . ' ' . $units[$pow];
}
}
/**
* Formatea una fecha a formato legible en español.
*
* @param string|null $date Fecha en cualquier formato reconocible por strtotime.
* @param bool $time Si true incluye la hora.
*/
if (!function_exists('formatDate')) {
function formatDate(?string $date, bool $time = false): string
{
if (!$date) {
return '—';
}
$ts = strtotime($date);
$format = $time ? 'd/m/Y H:i' : 'd/m/Y';
return $ts ? date($format, $ts) : esc($date);
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
/**
* core/Layout.php
* Renderiza el HTML envolvente de cada vista del ERP.
*
* USO en una vista de módulo (modules/{m}/views/{v}.php):
*
* Layout::open('Dashboard Turnero', 'fas fa-ticket-alt');
* // ... contenido de la vista ...
* Layout::close();
*
* Layout::open() emite DOCTYPE, <head>, Bootstrap, sidebar y abre <main>.
* Layout::close() cierra </main>, agrega scripts y </body></html>.
*/
class Layout
{
// ─── Apertura ────────────────────────────────────────────────────────────
/**
* Emite el HTML inicial de la página (head + sidebar + apertura de main).
*
* @param string $title Título de la pestaña y del encabezado.
* @param string $icon Clase FontAwesome del ícono (ej. 'fas fa-ticket-alt').
*/
public static function open(string $title = 'Panel', string $icon = 'fas fa-th-large'): void
{
$appName = defined('APP_NAME') ? APP_NAME : 'ERP System';
$safeTitle = htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeIcon = htmlspecialchars($icon, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// Exponer variables para sidebar.php
$GLOBALS['_LAYOUT_TITLE'] = $title;
$GLOBALS['_LAYOUT_ICON'] = $icon;
// Detectar base URL (para assets relativos)
$base = defined('APP_URL') ? rtrim(APP_URL, '/') : '';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $safeTitle ?> — <?= htmlspecialchars($appName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></title>
<!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome 6 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- Estilos del sistema -->
<link href="<?= $base ?>/assets/css/styles.css?v=12" rel="stylesheet">
<style>
/* Variables del tema ERP */
:root {
--sidebar-width: 260px;
--header-height: 56px;
}
body {
overflow-x: hidden;
}
.main-content {
margin-left: var(--sidebar-width);
min-height: 100vh;
background: #f8f9fa;
}
@media (max-width: 768px) {
.main-content { margin-left: 0; }
}
/* Toast container */
#toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1090;
}
</style>
</head>
<body>
<!-- Toast container global -->
<div id="toast-container" aria-live="polite" aria-atomic="true"></div>
<?php
// Sidebar dinámico
$sidebarFile = APP_ROOT . '/shared/components/sidebar.php';
if (file_exists($sidebarFile)) {
$SIDEBAR_TITLE = $title;
$SIDEBAR_ICON = $icon;
include $sidebarFile;
}
?>
<!-- Contenido principal -->
<main class="main-content">
<!-- Barra de título de módulo -->
<div class="bg-white border-bottom px-4 py-3 d-flex align-items-center justify-content-between">
<h5 class="mb-0 fw-semibold">
<i class="<?= $safeIcon ?> me-2 text-primary"></i><?= $safeTitle ?>
</h5>
<small class="text-muted d-none d-sm-block">
<?= htmlspecialchars(Auth::fullName(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>
<span class="badge bg-secondary ms-1"><?= htmlspecialchars(Auth::role(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></span>
</small>
</div>
<div class="p-4">
<?php
}
// ─── Cierre ──────────────────────────────────────────────────────────────
/**
* Cierra la sección de contenido y emite los scripts finales.
*/
public static function close(): void
{
$base = defined('APP_URL') ? rtrim(APP_URL, '/') : '';
?>
</div><!-- /.p-4 -->
</main><!-- /.main-content -->
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Helper JS global: toasts y fetch wrapper -->
<script>
/**
* erp.toast(message, type)
* Muestra un Bootstrap Toast en la esquina superior derecha.
* type: 'success' | 'danger' | 'warning' | 'info'
*/
window.erp = window.erp || {};
erp.toast = function(message, type = 'success') {
const id = 'toast-' + Date.now();
const icons = { success: 'fa-check-circle', danger: 'fa-times-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle' };
const icon = icons[type] || icons.info;
const html = `
<div id="${id}" class="toast align-items-center text-bg-${type} border-0 mb-2" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="4000">
<div class="d-flex">
<div class="toast-body"><i class="fas ${icon} me-2"></i>${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Cerrar"></button>
</div>
</div>`;
document.getElementById('toast-container').insertAdjacentHTML('beforeend', html);
const el = document.getElementById(id);
const toast = new bootstrap.Toast(el);
toast.show();
el.addEventListener('hidden.bs.toast', () => el.remove());
};
/**
* erp.fetch(url, options)
* Wrapper de fetch que muestra toast en caso de error y devuelve el JSON parseado.
* Lanza un Error si ok === false.
*/
erp.fetch = async function(url, options = {}) {
try {
const res = await fetch(url, options);
const data = await res.json();
if (!data.ok) {
throw new Error(data.error || 'Error desconocido');
}
return data;
} catch (err) {
erp.toast(err.message, 'danger');
throw err;
}
};
</script>
</body>
</html>
<?php
}
}
+312
View File
@@ -0,0 +1,312 @@
<?php
/**
* core/ModuleRegistry.php
* Catálogo dinámico de módulos del ERP.
*
* Fuentes de datos (prioridad en orden):
* 1. Base de datos — tabla system_modules (si la migración 004 se ejecutó)
* 2. Archivos modules/{slug}/module.php (escaneo de la carpeta modules/)
* 3. Constante SYSTEM_MODULES de config.php (fallback mínimo)
*
* Los datos de BD prevalecen sobre los descriptores locales para los campos
* is_active y sort_order (el administrador puede cambiarlos via UI).
*
* CACHÉ: estática por request (no Redis necesario).
*/
class ModuleRegistry
{
/** @var array<string, array>|null Cache del registro completo */
private static ?array $registry = null;
/** Orden de visualización de categorías en el sidebar */
private const CATEGORY_ORDER = ['bot', 'lab', 'turnero', 'clinico', 'reportes', 'sistema'];
/** Etiquetas e íconos de cada categoría */
private const CATEGORIES = [
'bot' => ['label' => 'WhatsApp', 'icon' => 'fab fa-whatsapp'],
'lab' => ['label' => 'Laboratorio', 'icon' => 'fas fa-flask'],
'turnero' => ['label' => 'Turnero', 'icon' => 'fas fa-ticket-alt'],
'clinico' => ['label' => 'Clínico', 'icon' => 'fas fa-vials'],
'reportes'=> ['label' => 'Reportes', 'icon' => 'fas fa-chart-bar'],
'sistema' => ['label' => 'Sistema', 'icon' => 'fas fa-cog'],
];
// ─── API pública ────────────────────────────────────────────────────────
/**
* Retorna todos los módulos registrados (activos e inactivos).
*
* @return array<string, array> Indexado por slug.
*/
public static function getAll(): array
{
self::load();
return self::$registry;
}
/**
* Retorna solo los módulos ACTIVOS que el usuario actual puede ver.
* Si el usuario es admin sin role_id → todos los activos.
*
* @return array<string, array> Indexado por slug.
*/
public static function forCurrentUser(): array
{
self::load();
$result = [];
foreach (self::$registry as $slug => $mod) {
if (!$mod['is_active']) {
continue;
}
// hasModule() definida en config.php / core/Rbac.php (retrocompat)
if (function_exists('hasModule') && !hasModule($slug)) {
continue;
}
$result[$slug] = $mod;
}
return $result;
}
/**
* Retorna módulos activos agrupados por categoría para el sidebar.
* Solo incluye módulos accesibles por el usuario actual.
*
* @return array ['bot' => ['label'=>'WhatsApp','icon'=>'...','modules'=>[...]], ...]
*/
public static function forSidebar(): array
{
$userModules = self::forCurrentUser();
// Agrupar por categoría
$grouped = [];
foreach ($userModules as $slug => $mod) {
$cat = $mod['category'] ?? 'sistema';
if (!isset($grouped[$cat])) {
$catMeta = self::CATEGORIES[$cat] ?? ['label' => ucfirst($cat), 'icon' => 'fas fa-cube'];
$grouped[$cat] = [
'label' => $catMeta['label'],
'icon' => $catMeta['icon'],
'modules' => [],
];
}
$grouped[$cat]['modules'][$slug] = $mod;
}
// Ordenar grupos según CATEGORY_ORDER
$ordered = [];
foreach (self::CATEGORY_ORDER as $cat) {
if (isset($grouped[$cat])) {
// Ordenar módulos dentro de la categoría por sort_order
uasort($grouped[$cat]['modules'], fn($a, $b) => $a['sort_order'] <=> $b['sort_order']);
$ordered[$cat] = $grouped[$cat];
}
}
// Categorías extra no definidas en CATEGORY_ORDER al final
foreach ($grouped as $cat => $data) {
if (!isset($ordered[$cat])) {
$ordered[$cat] = $data;
}
}
return $ordered;
}
/**
* Retorna el descriptor de un módulo por slug, o null si no existe.
*/
public static function get(string $slug): ?array
{
self::load();
return self::$registry[$slug] ?? null;
}
/**
* Activa o desactiva un módulo en la base de datos.
*
* @throws RuntimeException Si no se puede conectar a la BD.
*/
public static function setActive(string $slug, bool $active): bool
{
try {
$db = Database::getInstance();
$pdo = $db->getConnection();
$stmt = $pdo->prepare(
"UPDATE system_modules SET is_active = ? WHERE slug = ?"
);
$ok = $stmt->execute([(int) $active, $slug]);
if ($ok) {
self::$registry = null; // invalidar caché
}
return $ok;
} catch (Exception $e) {
error_log("ModuleRegistry::setActive error: " . $e->getMessage());
return false;
}
}
/**
* Actualiza el sort_order de un módulo.
*/
public static function setSortOrder(string $slug, int $order): bool
{
try {
$db = Database::getInstance();
$pdo = $db->getConnection();
$stmt = $pdo->prepare(
"UPDATE system_modules SET sort_order = ? WHERE slug = ?"
);
$ok = $stmt->execute([$order, $slug]);
if ($ok) {
self::$registry = null;
}
return $ok;
} catch (Exception $e) {
error_log("ModuleRegistry::setSortOrder error: " . $e->getMessage());
return false;
}
}
/**
* Invalida la caché para forzar recarga en la próxima llamada.
* Útil después de cambios por el administrador.
*/
public static function flush(): void
{
self::$registry = null;
}
// ─── Carga interna ──────────────────────────────────────────────────────
private static function load(): void
{
if (self::$registry !== null) {
return;
}
self::$registry = [];
// 1. Escanear module.php locales para obtener metadatos base
self::loadFromFiles();
// 2. Sobreescribir is_active / sort_order desde la BD (si existe)
self::mergeFromDatabase();
// 3. Fallback: añadir slugs de SYSTEM_MODULES que no tengan module.php
self::fillFromConstant();
}
/**
* Escanea modules/{slug}/module.php y carga sus descriptores.
*/
private static function loadFromFiles(): void
{
if (!defined('APP_ROOT')) {
return;
}
$pattern = APP_ROOT . '/modules/*/module.php';
foreach (glob($pattern) ?: [] as $file) {
$data = @include $file;
if (is_array($data) && isset($data['slug'])) {
self::$registry[$data['slug']] = self::normalize($data);
}
}
}
/**
* Lee la tabla system_modules y sobreescribe campos controlados por el admin.
*/
private static function mergeFromDatabase(): void
{
try {
$db = Database::getInstance();
$pdo = $db->getConnection();
// Verificar si existe la tabla
$check = $pdo->query("SHOW TABLES LIKE 'system_modules'");
if (!$check || $check->rowCount() === 0) {
return;
}
$rows = $pdo->query(
"SELECT slug, name, icon, category, route, is_active, sort_order, oleada, description
FROM system_modules ORDER BY sort_order ASC"
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$slug = $row['slug'];
if (isset(self::$registry[$slug])) {
// Actualizar solo los campos que maneja el admin
self::$registry[$slug]['is_active'] = (bool)(int)$row['is_active'];
self::$registry[$slug]['sort_order'] = (int)$row['sort_order'];
// Si en BD hay datos más completos, usar los de BD
if (!empty($row['name'])) self::$registry[$slug]['name'] = $row['name'];
if (!empty($row['icon'])) self::$registry[$slug]['icon'] = $row['icon'];
if (!empty($row['category'])) self::$registry[$slug]['category'] = $row['category'];
if (!empty($row['route'])) self::$registry[$slug]['route'] = $row['route'];
if (!empty($row['description'])) self::$registry[$slug]['description'] = $row['description'];
} else {
// Módulo solo en BD (sin module.php local)
self::$registry[$slug] = self::normalize([
'slug' => $slug,
'name' => $row['name'],
'icon' => $row['icon'],
'category' => $row['category'],
'route' => $row['route'],
'is_active' => (bool)(int)$row['is_active'],
'sort_order' => (int)$row['sort_order'],
'oleada' => (int)$row['oleada'],
'description' => $row['description'],
]);
}
}
} catch (Exception $e) {
// BD no disponible → seguir con datos de archivos
error_log("ModuleRegistry DB merge error: " . $e->getMessage());
}
}
/**
* Agrega módulos del array SYSTEM_MODULES que no estén ya registrados.
* Garantiza compatibilidad si no se ejecutó la migración 004.
*/
private static function fillFromConstant(): void
{
if (!defined('SYSTEM_MODULES')) {
return;
}
foreach (SYSTEM_MODULES as $slug => $name) {
if (!isset(self::$registry[$slug])) {
self::$registry[$slug] = self::normalize([
'slug' => $slug,
'name' => $name,
'icon' => 'fas fa-cube',
'category' => 'sistema',
'route' => null,
'is_active' => true,
'sort_order' => 99,
'oleada' => 0,
]);
}
}
}
/**
* Normaliza un descriptor garantizando que todos los campos existen.
*/
private static function normalize(array $data): array
{
return [
'slug' => $data['slug'] ?? '',
'name' => $data['name'] ?? ucfirst($data['slug'] ?? ''),
'icon' => $data['icon'] ?? 'fas fa-cube',
'category' => $data['category'] ?? 'sistema',
'route' => $data['route'] ?? null,
'is_active' => (bool)($data['is_active'] ?? true),
'sort_order' => (int)($data['sort_order'] ?? 99),
'oleada' => (int)($data['oleada'] ?? 0),
'description' => $data['description'] ?? '',
'links' => $data['links'] ?? [],
];
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
/**
* core/Rbac.php
* Control de acceso basado en roles (RBAC) con granularidad de acciones.
* Consolida la lógica de hasModule() / requireAdmin() dispersa en _helpers.php
* y config.php con soporte para los nuevos campos can_* (Migración 001).
*
* USO EN VISTAS:
* Rbac::requireLogin();
* Rbac::requireModule('lab_domicilios');
* if (Rbac::can('lab_reportes', 'export')) { ... }
*
* USO EN API (reemplaza funciones sueltas de _helpers.php):
* Rbac::requireAdmin();
* Rbac::requireModule('turnero', 'create');
*/
class Rbac
{
// ─── Consulta básica de módulo ───────────────────────────────────────────
/**
* Comprueba si el usuario en sesión tiene acceso (cualquier nivel) al módulo.
* Retrocompatible: admin sin role_id tiene acceso total.
*/
public static function hasModule(string $slug): bool
{
$modules = $_SESSION['admin_user']['modules'] ?? null;
if ($modules === null) {
// Sesión legacy: solo admins pasan
return ($_SESSION['admin_user']['role'] ?? '') === 'admin'
|| ($_SESSION['admin_user']['role'] ?? '') === 'superadmin';
}
return in_array($slug, $modules, true);
}
/**
* Comprueba si el usuario tiene una acción específica sobre el módulo.
*
* $action: 'view' | 'create' | 'edit' | 'delete' | 'export'
*
* Los roles admin y superadmin tienen acceso total a todo.
*/
public static function can(string $slug, string $action = 'view'): bool
{
$role = $_SESSION['admin_user']['role'] ?? 'admin';
// Superadmin y admin tienen acceso total
if (in_array($role, ['superadmin', 'admin'], true)) {
return true;
}
// Primero verificar que tiene el módulo
if (!self::hasModule($slug)) {
return false;
}
// Leer permisos granulares desde sesión (cargados en authenticateUser)
$perms = $_SESSION['admin_user']['module_permissions'] ?? [];
// Formato anterior compatible: 'write' / 'read'
if (isset($perms[$slug]) && is_string($perms[$slug])) {
$legacy = $perms[$slug];
if ($action === 'view') return true; // read implica view
return $legacy === 'write';
}
// Formato nuevo: array con can_* (post Migración 001)
if (isset($perms[$slug]) && is_array($perms[$slug])) {
return (bool) ($perms[$slug]["can_{$action}"] ?? false);
}
// Sin permisos granulares → view por defecto
return $action === 'view';
}
// ─── Guardas / Requisitos ────────────────────────────────────────────────
/**
* Detiene con 403 JSON si el usuario no está autenticado.
* Para uso en vistas, redirige al login.
*/
public static function requireLogin(string $redirect = null): void
{
if (!isset($_SESSION['admin_logged_in']) || $_SESSION['admin_logged_in'] !== true) {
if ($redirect !== null) {
header("Location: $redirect");
exit;
}
self::jsonForbidden('No autenticado', 401);
}
}
/**
* Detiene con 403 si el usuario no es admin ni superadmin.
*/
public static function requireAdmin(): void
{
$role = $_SESSION['admin_user']['role'] ?? '';
if (!in_array($role, ['admin', 'superadmin'], true)) {
self::jsonForbidden('Acceso restringido a administradores', 403);
}
}
/**
* Detiene con 403 si el usuario no tiene acceso al módulo.
* Opcionalmente exige una acción específica.
*/
public static function requireModule(string $slug, string $action = 'view'): void
{
if (!self::can($slug, $action)) {
self::jsonForbidden("Sin permiso en módulo '{$slug}' ({$action})", 403);
}
}
/**
* Alias semántico: detiene si no tiene permiso de escritura en el módulo.
*/
public static function requireWrite(string $slug): void
{
self::requireModule($slug, 'edit');
}
// ─── Helpers internos ───────────────────────────────────────────────────
private static function jsonForbidden(string $msg, int $code = 403): void
{
// Si ya enviamos headers JSON (contexto API), responder JSON
if (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
// Contexto HTML: página de error simple
http_response_code($code);
echo "<h1>{$code}</h1><p>" . htmlspecialchars($msg) . "</p>";
exit;
}
// ─── Utilidades de sesión ────────────────────────────────────────────────
public static function currentRole(): string
{
return $_SESSION['admin_user']['role'] ?? 'admin';
}
public static function currentModules(): array
{
return $_SESSION['admin_user']['modules'] ?? [];
}
/**
* Carga en sesión los permisos granulares (can_*) desde la BD.
* Se llama desde authenticateUser() después de la Migración 001.
*/
public static function loadGranularPermissions(int $roleId): array
{
try {
$db = Database::getInstance();
$rows = $db->fetchAll(
"SELECT module_slug, can_view, can_create, can_edit, can_delete, can_export, extra_json
FROM role_modules
WHERE role_id = ?",
[$roleId]
);
$perms = [];
foreach ($rows as $row) {
$perms[$row['module_slug']] = [
'can_view' => (bool) $row['can_view'],
'can_create' => (bool) $row['can_create'],
'can_edit' => (bool) $row['can_edit'],
'can_delete' => (bool) $row['can_delete'],
'can_export' => (bool) $row['can_export'],
'extra' => $row['extra_json'] ? json_decode($row['extra_json'], true) : [],
];
}
return $perms;
} catch (Exception $e) {
error_log("[Rbac] Error cargando permisos: " . $e->getMessage());
return [];
}
}
}
// ─── Funciones globales de retrocompatibilidad ──────────────────────────────
// Las vistas y APIs legacy que llaman hasModule(), isEnfermero(), etc.
// siguen funcionando sin cambios apuntando a la clase Rbac.
if (!function_exists('hasModule')) {
function hasModule(string $slug): bool { return Rbac::hasModule($slug); }
}
if (!function_exists('canDo')) {
function canDo(string $slug, string $action): bool { return Rbac::can($slug, $action); }
}
+145
View File
@@ -0,0 +1,145 @@
<?php
/**
* core/Router.php
* Enrutador central del ERP.
*
* Mapea la URL entrante a modules/{m}/views/{v}.php.
* Soporta rutas limpias (/turnero/dashboard) y parámetros explícitos (?m=&v=).
*
* RUTAS PÚBLICAS (sin autenticación):
* turnero/display → pantalla TV para sala de espera
* turnero/kiosko → pantalla táctil para tomar turno
*/
class Router
{
/** Módulo por defecto cuando no se especifica ?m= */
private const DEFAULT_MODULE = 'dashboard';
/** Vista por defecto cuando no se especifica ?v= */
private const DEFAULT_VIEW = 'index';
/** Rutas que NO requieren sesión iniciada. Formato: 'modulo/vista' */
private const PUBLIC_ROUTES = [
'turnero/display',
'turnero/kiosko',
];
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
private const SLUG_PATTERN = '/^[a-zA-Z0-9_]{1,64}$/';
private string $module;
private string $view;
// ─── Constructor ────────────────────────────────────────────────────────
public function __construct()
{
[$this->module, $this->view] = $this->resolveRoute();
}
// ─── API pública ────────────────────────────────────────────────────────
public function getModule(): string { return $this->module; }
public function getView(): string { return $this->view; }
/**
* Indica si la ruta actual es pública (no requiere login).
*/
public function isPublic(): bool
{
return in_array($this->module . '/' . $this->view, self::PUBLIC_ROUTES, true);
}
/**
* Resuelve la ruta y devuelve el archivo de vista correspondiente.
* Lanza una excepción si el archivo no existe.
*
* @return string Ruta absoluta al archivo de vista.
* @throws RuntimeException Si el módulo/vista no se encuentran.
*/
public function resolveFile(): string
{
$file = APP_ROOT . "/modules/{$this->module}/views/{$this->view}.php";
if (!file_exists($file)) {
throw new RuntimeException(
"Vista no encontrada: {$this->module}/{$this->view}",
404
);
}
return $file;
}
// ─── Internos ───────────────────────────────────────────────────────────
/**
* Determina módulo y vista a partir de la URL.
* Prioridad: PATH_INFO limpio → parámetros GET ?m= ?v=
*
* @return array{string, string}
*/
private function resolveRoute(): array
{
// 1. Intentar ruta limpia desde PATH_INFO o REQUEST_URI
// Formato: /modulo/vista → e.g. /turnero/dashboard
$path = $this->cleanPath();
if ($path !== null) {
[$m, $v] = $path;
if ($this->isValidSlug($m) && $this->isValidSlug($v)) {
return [$m, $v];
}
}
// 2. Parámetros GET ?m=module&v=view
$m = $_GET['m'] ?? self::DEFAULT_MODULE;
$v = $_GET['v'] ?? self::DEFAULT_VIEW;
// Sanitizar: solo caracteres seguros
$m = preg_replace('/[^a-zA-Z0-9_]/', '', $m);
$v = preg_replace('/[^a-zA-Z0-9_]/', '', $v);
if (!$this->isValidSlug($m)) $m = self::DEFAULT_MODULE;
if (!$this->isValidSlug($v)) $v = self::DEFAULT_VIEW;
return [$m, $v];
}
/**
* Extrae módulo y vista de la REQUEST_URI limpia.
* Retorna null si la URI no corresponde al formato /m/v.
*
* @return array{string,string}|null
*/
private function cleanPath(): ?array
{
$uri = $_SERVER['REQUEST_URI'] ?? '';
// Eliminar query string
$pos = strpos($uri, '?');
if ($pos !== false) {
$uri = substr($uri, 0, $pos);
}
// Eliminar el subdirectorio base si el proyecto no está en la raíz
$scriptDir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/');
if ($scriptDir !== '' && str_starts_with($uri, $scriptDir)) {
$uri = substr($uri, strlen($scriptDir));
}
// Normalizar y dividir
$parts = array_values(array_filter(explode('/', trim($uri, '/'))));
if (count($parts) >= 2) {
return [$parts[0], $parts[1]];
}
return null;
}
private function isValidSlug(string $slug): bool
{
return (bool) preg_match(self::SLUG_PATTERN, $slug);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* erp.php
* Punto de entrada del ERP multi-módulo.
*
* Acceso:
* - URL limpia: /turnero/dashboard (via .htaccess RewriteRule)
* - Parámetros: /erp.php?m=turnero&v=dashboard
*
* Los archivos originales lab_*.php e index.php siguen funcionando sin cambios.
* Este archivo coexiste con ellos durante la migración gradual.
*/
define('APP_ROOT', __DIR__);
require_once __DIR__ . '/core/App.php';
App::run();
+27
View File
@@ -0,0 +1,27 @@
-- =============================================================================
-- Migración 001 — RBAC expandido
-- Agrega columnas de permisos granulares a role_modules.
-- Proyecto: WhatsApp ERP Multi-módulo
-- Fecha: 2026-04-16
-- IMPORTANTE: Migración ADITIVA. No elimina ni renombra columnas existentes.
-- =============================================================================
-- Ampliar table role_modules con permisos por acción
ALTER TABLE role_modules
ADD COLUMN IF NOT EXISTS can_view TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Puede ver el módulo',
ADD COLUMN IF NOT EXISTS can_create TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Puede crear registros',
ADD COLUMN IF NOT EXISTS can_edit TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Puede editar registros',
ADD COLUMN IF NOT EXISTS can_delete TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Puede eliminar registros',
ADD COLUMN IF NOT EXISTS can_export TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Puede exportar datos',
ADD COLUMN IF NOT EXISTS extra_json JSON NULL COMMENT 'Permisos extras específicos del módulo (JSON)';
-- Retrocompatibilidad: los registros existentes con permission='write' reciben can_create/edit/delete = 1
UPDATE role_modules
SET can_create = 1, can_edit = 1, can_delete = 1, can_export = 1
WHERE permission = 'write' OR permission IS NULL;
-- Los que tienen permission='read' solo tienen can_view = 1 (ya es el DEFAULT, no tocar)
-- Registrar migración
INSERT IGNORE INTO migrations (migration, executed_at)
VALUES ('001_rbac_expandido', NOW());
+43
View File
@@ -0,0 +1,43 @@
-- =============================================================================
-- Migración 002 — Nuevos roles del sistema
-- Inserta los roles necesarios para el ERP multi-módulo.
-- Proyecto: WhatsApp ERP Multi-módulo
-- Fecha: 2026-04-16
-- IMPORTANTE: Usa INSERT IGNORE para no duplicar si ya existen.
-- =============================================================================
INSERT IGNORE INTO roles (name, slug, description, color, is_system, created_at, updated_at) VALUES
('Super Admin', 'superadmin', 'Acceso total al sistema incluyendo configuración técnica', '#dc3545', 1, NOW(), NOW()),
('Recepcionista', 'recepcionista', 'Turnero, recepción de pacientes y agenda', '#198754', 0, NOW(), NOW()),
('Bacteriólogo', 'bacteriologo', 'Análisis de muestras y carga de resultados (Oleada 2)', '#0dcaf0', 0, NOW(), NOW()),
('Supervisor', 'supervisor', 'Acceso de solo lectura a reportes y dashboards', '#fd7e14', 0, NOW(), NOW()),
('Operador Bot', 'operador_bot', 'Gestión de conversaciones WhatsApp', '#6f42c1', 0, NOW(), NOW()),
('Solo Lectura', 'readonly', 'Visualización de dashboards sin modificar datos', '#6c757d', 0, NOW(), NOW());
-- Asignar permisos básicos al rol recepcionista
-- (se ajustan cuando el módulo Turnero esté disponible — Migración 003)
INSERT IGNORE INTO role_modules (role_id, module_slug, can_view, can_create, can_edit, can_delete, can_export)
SELECT r.id, 'lab_pacientes', 1, 1, 1, 0, 0
FROM roles r WHERE r.slug = 'recepcionista';
INSERT IGNORE INTO role_modules (role_id, module_slug, can_view, can_create, can_edit, can_delete, can_export)
SELECT r.id, 'lab_domicilios', 1, 0, 0, 0, 0
FROM roles r WHERE r.slug = 'recepcionista';
-- Supervisor: solo lectura en reportes y dashboard
INSERT IGNORE INTO role_modules (role_id, module_slug, can_view, can_create, can_edit, can_delete, can_export)
SELECT r.id, 'lab_dashboard', 1, 0, 0, 0, 0
FROM roles r WHERE r.slug = 'supervisor';
INSERT IGNORE INTO role_modules (role_id, module_slug, can_view, can_create, can_edit, can_delete, can_export)
SELECT r.id, 'lab_reportes', 1, 0, 0, 0, 1
FROM roles r WHERE r.slug = 'supervisor';
-- Operador Bot: solo WhatsApp
INSERT IGNORE INTO role_modules (role_id, module_slug, can_view, can_create, can_edit, can_delete, can_export)
SELECT r.id, 'whatsapp', 1, 1, 1, 0, 1
FROM roles r WHERE r.slug = 'operador_bot';
-- Registrar migración
INSERT IGNORE INTO migrations (migration, executed_at)
VALUES ('002_nuevos_roles', NOW());
+58
View File
@@ -0,0 +1,58 @@
-- ============================================================
-- Migración 004 SYSTEM_MODULES dinámica
-- Convierte el array hardcodeado en config.php a tabla de BD.
-- FASE 2 del plan ERP multi-módulo.
-- Segura para re-ejecutar (IF NOT EXISTS + INSERT IGNORE).
-- ============================================================
CREATE TABLE IF NOT EXISTS system_modules (
slug VARCHAR(100) NOT NULL,
name VARCHAR(150) NOT NULL,
icon VARCHAR(80) NOT NULL DEFAULT 'fas fa-cube',
category VARCHAR(50) NOT NULL DEFAULT 'sistema',
route VARCHAR(300) DEFAULT NULL COMMENT 'URL principal del módulo (legacy o nueva)',
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '0=desactivado, 1=activo',
sort_order INT NOT NULL DEFAULT 99,
oleada TINYINT NOT NULL DEFAULT 0 COMMENT '0=existente, 1=oleada1, 2=oleada2',
description TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (slug),
INDEX idx_category (category),
INDEX idx_is_active (is_active),
INDEX idx_sort (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ── Bot ──────────────────────────────────────────────────────────────────────
INSERT IGNORE INTO system_modules (slug, name, icon, category, route, is_active, sort_order, oleada, description) VALUES
('whatsapp', 'WhatsApp Bot', 'fab fa-whatsapp', 'bot', '/index.php', 1, 1, 0, 'Bot, conversaciones, plantillas y menús de WhatsApp');
-- ── Laboratorio (Oleada 0) ───────────────────────────────────────────────────
INSERT IGNORE INTO system_modules (slug, name, icon, category, route, is_active, sort_order, oleada, description) VALUES
('lab_dashboard', 'Dashboard Lab', 'fas fa-tachometer-alt','lab', '/lab_dashboard.php', 1, 10, 0, 'Panel principal del módulo de laboratorio'),
('lab_domicilios', 'Domicilios', 'fas fa-house-medical', 'lab', '/lab_domicilios.php', 1, 20, 0, 'Agenda y gestión de servicios a domicilio'),
('lab_pacientes', 'Pacientes', 'fas fa-users', 'lab', '/lab_pacientes.php', 1, 21, 0, 'Fichas clínicas e historial de pacientes'),
('lab_enfermeras', 'Enfermeros', 'fas fa-user-nurse', 'lab', '/lab_enfermeras.php', 1, 22, 0, 'Gestión de personal clínico y enfermeros'),
('lab_ordenes', 'Órdenes Médicas', 'fas fa-file-medical', 'lab', '/lab_ordenes.php', 1, 23, 0, 'Gestión de órdenes médicas con PDF y autorización'),
('lab_formularios', 'Formularios', 'fas fa-wpforms', 'lab', '/lab_formularios.php', 1, 24, 0, 'Builder de formularios drag-drop con firma digital');
-- ── Reportes ─────────────────────────────────────────────────────────────────
INSERT IGNORE INTO system_modules (slug, name, icon, category, route, is_active, sort_order, oleada, description) VALUES
('lab_reportes', 'Reportes', 'fas fa-chart-bar', 'reportes', '/lab_reportes.php', 1, 50, 0, 'Reportes de ingresos, rendimiento y pagos');
-- ── Sistema ───────────────────────────────────────────────────────────────────
INSERT IGNORE INTO system_modules (slug, name, icon, category, route, is_active, sort_order, oleada, description) VALUES
('usuarios', 'Usuarios & Roles', 'fas fa-users-cog', 'sistema', '/lab_usuarios.php', 1, 80, 0, 'Gestión de usuarios del sistema y roles'),
('lab_configuracion', 'Configuración Lab', 'fas fa-sliders-h', 'sistema', '/lab_configuracion.php',1, 81, 0, 'Tarifas, laboratorio y ajustes del sistema'),
('enfermero_portal', 'Portal Enfermero', 'fas fa-stethoscope', 'sistema', '/enfermero_portal.php', 1, 90, 0, 'Portal exclusivo para el personal de enfermería');
-- ── Oleada 1 — Turnero ───────────────────────────────────────────────────────
INSERT IGNORE INTO system_modules (slug, name, icon, category, route, is_active, sort_order, oleada, description) VALUES
('turnero', 'Turnero', 'fas fa-ticket-alt', 'turnero', '/erp.php?m=turnero&v=dashboard', 0, 40, 1, 'Sistema de turnos presenciales en recepción');
-- ── Oleada 2 — Pendientes ────────────────────────────────────────────────────
INSERT IGNORE INTO system_modules (slug, name, icon, category, route, is_active, sort_order, oleada, description) VALUES
('registro_exams', 'Registro Exámenes', 'fas fa-vials', 'clinico', '/erp.php?m=registro_exams&v=lista', 0, 60, 2, 'Registro de exámenes de laboratorio en sede'),
('facturacion', 'Facturación', 'fas fa-file-invoice', 'clinico', NULL, 0, 61, 2, 'Facturación electrónica DIAN'),
('inventario', 'Inventario', 'fas fa-boxes', 'clinico', NULL, 0, 62, 2, 'Reactivos, insumos y stock mínimo'),
('citas', 'Citas', 'fas fa-calendar-alt', 'clinico', NULL, 0, 63, 2, 'Agenda de citas con calendario visual'),
('resultados', 'Resultados', 'fas fa-file-waveform', 'clinico', NULL, 0, 64, 2, 'Entrega digital de resultados al paciente');
+1
View File
@@ -0,0 +1 @@
<?php return ['slug'=>'enfermero_portal','name'=>'Portal Enfermero','icon'=>'fas fa-stethoscope','category'=>'sistema','route'=>'/enfermero_portal.php','is_active'=>true,'sort_order'=>90,'oleada'=>0,'description'=>'Portal exclusivo para el personal de enfermería','links'=>[['name'=>'Portal Enfermero','icon'=>'fas fa-stethoscope','route'=>'/enfermero_portal.php']]];
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* modules/enfermero_portal/views/index.php
* Bridge al archivo legacy mientras se migra al nuevo sistema ERP.
* Acceso via: /erp.php?m=enfermero_portal&v=index o /enfermero_portal/index
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/enfermero_portal.php';
+1
View File
@@ -0,0 +1 @@
<?php return ['slug'=>'lab_configuracion','name'=>'Configuración Lab','icon'=>'fas fa-sliders-h','category'=>'sistema','route'=>'/lab_configuracion.php','is_active'=>true,'sort_order'=>80,'oleada'=>0,'description'=>'Tarifas, laboratorio y configuraciones del sistema','links'=>[['name'=>'Configuración','icon'=>'fas fa-sliders-h','route'=>'/lab_configuracion.php']]];
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* modules/lab_configuracion/views/index.php
* Bridge al archivo legacy mientras se migra al nuevo sistema ERP.
* Acceso via: /erp.php?m=lab_configuracion&v=index o /lab_configuracion/index
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/lab_configuracion.php';
+1
View File
@@ -0,0 +1 @@
<?php return ['slug'=>'lab_dashboard','name'=>'Dashboard Lab','icon'=>'fas fa-tachometer-alt','category'=>'lab','route'=>'/lab_dashboard.php','is_active'=>true,'sort_order'=>10,'oleada'=>0,'description'=>'Panel principal del módulo de laboratorio','links'=>[['name'=>'Dashboard','icon'=>'fas fa-tachometer-alt','route'=>'/lab_dashboard.php']]];
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* modules/lab_dashboard/views/index.php
* Bridge al archivo legacy mientras se migra al nuevo sistema ERP.
* Acceso via: /erp.php?m=lab_dashboard&v=index o /lab_dashboard/index
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/lab_dashboard.php';
+1
View File
@@ -0,0 +1 @@
<?php return ['slug'=>'lab_domicilios','name'=>'Domicilios','icon'=>'fas fa-house-medical','category'=>'lab','route'=>'/lab_domicilios.php','is_active'=>true,'sort_order'=>20,'oleada'=>0,'description'=>'Agenda y gestión de servicios a domicilio','links'=>[['name'=>'Domicilios','icon'=>'fas fa-house-medical','route'=>'/lab_domicilios.php']]];
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* modules/lab_domicilios/views/index.php
* Bridge al archivo legacy mientras se migra al nuevo sistema ERP.
* Acceso via: /erp.php?m=lab_domicilios&v=index o /lab_domicilios/index
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/lab_domicilios.php';
+1
View File
@@ -0,0 +1 @@
<?php return ['slug'=>'lab_enfermeras','name'=>'Enfermeros','icon'=>'fas fa-user-nurse','category'=>'lab','route'=>'/lab_enfermeras.php','is_active'=>true,'sort_order'=>22,'oleada'=>0,'description'=>'Gestión de personal clínico y enfermeros','links'=>[['name'=>'Enfermeros','icon'=>'fas fa-user-nurse','route'=>'/lab_enfermeras.php']]];
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* modules/lab_enfermeras/views/index.php
* Bridge al archivo legacy mientras se migra al nuevo sistema ERP.
* Acceso via: /erp.php?m=lab_enfermeras&v=index o /lab_enfermeras/index
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/lab_enfermeras.php';
+1
View File
@@ -0,0 +1 @@
<?php return ['slug'=>'lab_formularios','name'=>'Formularios','icon'=>'fas fa-wpforms','category'=>'lab','route'=>'/lab_formularios.php','is_active'=>true,'sort_order'=>24,'oleada'=>0,'description'=>'Builder de formularios drag-drop con firma digital','links'=>[['name'=>'Formularios','icon'=>'fas fa-wpforms','route'=>'/lab_formularios.php']]];
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* modules/lab_formularios/views/index.php
* Bridge al archivo legacy mientras se migra al nuevo sistema ERP.
* Acceso via: /erp.php?m=lab_formularios&v=index o /lab_formularios/index
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/lab_formularios.php';
+1
View File
@@ -0,0 +1 @@
<?php return ['slug'=>'lab_ordenes','name'=>'Órdenes Médicas','icon'=>'fas fa-file-medical','category'=>'lab','route'=>'/lab_ordenes.php','is_active'=>true,'sort_order'=>23,'oleada'=>0,'description'=>'Gestión de órdenes médicas con PDF y autorización','links'=>[['name'=>'Órdenes','icon'=>'fas fa-file-medical','route'=>'/lab_ordenes.php']]];
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* modules/lab_ordenes/views/index.php
* Bridge al archivo legacy mientras se migra al nuevo sistema ERP.
* Acceso via: /erp.php?m=lab_ordenes&v=index o /lab_ordenes/index
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/lab_ordenes.php';
+1
View File
@@ -0,0 +1 @@
<?php return ['slug'=>'lab_pacientes','name'=>'Pacientes','icon'=>'fas fa-users','category'=>'lab','route'=>'/lab_pacientes.php','is_active'=>true,'sort_order'=>21,'oleada'=>0,'description'=>'Fichas clínicas e historial de pacientes','links'=>[['name'=>'Pacientes','icon'=>'fas fa-users','route'=>'/lab_pacientes.php']]];
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* modules/lab_pacientes/views/index.php
* Bridge al archivo legacy mientras se migra al nuevo sistema ERP.
* Acceso via: /erp.php?m=lab_pacientes&v=index o /lab_pacientes/index
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/lab_pacientes.php';
+1
View File
@@ -0,0 +1 @@
<?php return ['slug'=>'lab_reportes','name'=>'Reportes','icon'=>'fas fa-chart-bar','category'=>'reportes','route'=>'/lab_reportes.php','is_active'=>true,'sort_order'=>50,'oleada'=>0,'description'=>'Reportes de ingresos, rendimiento y pagos','links'=>[['name'=>'Reportes','icon'=>'fas fa-chart-bar','route'=>'/lab_reportes.php'],['name'=>'Actividad','icon'=>'fas fa-history','route'=>'/lab_actividad.php']]];
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* modules/lab_reportes/views/index.php
* Bridge al archivo legacy mientras se migra al nuevo sistema ERP.
* Acceso via: /erp.php?m=lab_reportes&v=index o /lab_reportes/index
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/lab_reportes.php';
+1
View File
@@ -0,0 +1 @@
<?php return ['slug'=>'usuarios','name'=>'Usuarios & Roles','icon'=>'fas fa-users-cog','category'=>'sistema','route'=>'/lab_usuarios.php','is_active'=>true,'sort_order'=>81,'oleada'=>0,'description'=>'Gestión de usuarios del sistema y asignación de roles','links'=>[['name'=>'Usuarios & Roles','icon'=>'fas fa-users-cog','route'=>'/lab_usuarios.php'],['name'=>'Módulos','icon'=>'fas fa-th-large','route'=>'/erp.php?m=usuarios&v=modulos']]];
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* modules/usuarios/views/index.php
* Bridge al archivo legacy mientras se migra al nuevo sistema ERP.
* Acceso via: /erp.php?m=usuarios&v=index o /usuarios/index
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/lab_usuarios.php';
+190
View File
@@ -0,0 +1,190 @@
<?php
/**
* modules/usuarios/views/modulos.php
* Interfaz de administración de módulos del ERP.
* Acceso: /erp.php?m=usuarios&v=modulos
*
* Permite al superadmin / admin:
* - Ver todos los módulos registrados
* - Activar / desactivar módulos con toggle
* - Re-ordenar sort_order
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/config/config.php';
require_once APP_ROOT . '/core/Auth.php';
require_once APP_ROOT . '/core/Rbac.php';
require_once APP_ROOT . '/core/Helpers.php';
require_once APP_ROOT . '/core/ModuleRegistry.php';
require_once APP_ROOT . '/core/Layout.php';
Auth::requireLogin();
Rbac::requireModule('usuarios');
// ── Manejador de acciones AJAX ────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json; charset=utf-8');
$body = inputJson();
$action = $body['action'] ?? '';
switch ($action) {
case 'toggle':
$slug = preg_replace('/[^a-z0-9_]/', '', $body['slug'] ?? '');
$active = (bool)($body['active'] ?? false);
if (!$slug) { jsonError('Slug inválido'); }
$ok = ModuleRegistry::setActive($slug, $active);
$ok ? jsonOk([], $active ? 'Módulo activado' : 'Módulo desactivado') : jsonError('No se pudo actualizar', 500);
break;
case 'reorder':
$slug = preg_replace('/[^a-z0-9_]/', '', $body['slug'] ?? '');
$order = (int)($body['order'] ?? 99);
if (!$slug) { jsonError('Slug inválido'); }
$ok = ModuleRegistry::setSortOrder($slug, $order);
$ok ? jsonOk([], 'Orden actualizado') : jsonError('No se pudo actualizar', 500);
break;
default:
jsonError('Acción desconocida');
}
exit;
}
// ── Vista HTML ────────────────────────────────────────────────────────────────
$modules = ModuleRegistry::getAll();
// Etiquetas de oleada
$oleadaLabel = [0 => 'Producción', 1 => 'Oleada 1', 2 => 'Oleada 2'];
$oleadaBadge = [0 => 'bg-success', 1 => 'bg-primary', 2 => 'bg-secondary'];
Layout::open('Gestión de Módulos', 'fas fa-th-large');
?>
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="text-muted mb-0">
Activa o desactiva módulos del ERP. Los módulos desactivados no aparecen en el sidebar
ni son accesibles para ningún usuario.
</p>
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
<i class="fas fa-sync-alt me-1"></i>Recargar
</button>
</div>
<!-- Tabla de módulos -->
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Módulo</th>
<th>Slug</th>
<th>Categoría</th>
<th>Oleada</th>
<th class="text-center">Orden</th>
<th class="text-center">Activo</th>
</tr>
</thead>
<tbody>
<?php
$sorted = $modules;
uasort($sorted, fn($a, $b) => [$a['oleada'], $a['sort_order']] <=> [$b['oleada'], $b['sort_order']]);
foreach ($sorted as $slug => $mod):
$active = $mod['is_active'];
$oleadaIdx = (int)($mod['oleada'] ?? 0);
$badgeClass = $oleadaBadge[$oleadaIdx] ?? 'bg-secondary';
$badgeText = $oleadaLabel[$oleadaIdx] ?? "Oleada $oleadaIdx";
?>
<tr data-slug="<?= esc($slug) ?>">
<td>
<i class="<?= esc($mod['icon']) ?> me-2 text-muted"></i>
<strong><?= esc($mod['name']) ?></strong>
<?php if ($mod['description']): ?>
<br><small class="text-muted"><?= esc($mod['description']) ?></small>
<?php endif; ?>
</td>
<td><code class="small"><?= esc($slug) ?></code></td>
<td><span class="badge bg-light text-dark border"><?= esc($mod['category']) ?></span></td>
<td><span class="badge <?= $badgeClass ?>"><?= esc($badgeText) ?></span></td>
<td class="text-center">
<input type="number" class="form-control form-control-sm text-center order-input"
value="<?= (int)$mod['sort_order'] ?>" min="1" max="999"
data-slug="<?= esc($slug) ?>" style="width:70px;margin:auto;">
</td>
<td class="text-center">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input toggle-module" type="checkbox"
role="switch"
id="mod-<?= esc($slug) ?>"
data-slug="<?= esc($slug) ?>"
<?= $active ? 'checked' : '' ?>
style="cursor:pointer;width:2.5rem;height:1.25rem;">
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<p class="text-muted small mt-3">
<i class="fas fa-info-circle me-1"></i>
Los cambios se aplican de inmediato. Si un módulo desactivado tiene permisos asignados en roles,
esos permisos se conservan en BD pero el módulo no será accesible hasta reactivarlo.
</p>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Toggle activo/inactivo
document.querySelectorAll('.toggle-module').forEach(toggle => {
toggle.addEventListener('change', async function () {
const slug = this.dataset.slug;
const active = this.checked;
this.disabled = true;
try {
await erp.fetch('', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'toggle', slug, active})
});
erp.toast(active ? `Módulo "${slug}" activado` : `Módulo "${slug}" desactivado`,
active ? 'success' : 'warning');
} catch (e) {
this.checked = !active; // revertir
erp.toast('Error al actualizar: ' + e.message, 'danger');
} finally {
this.disabled = false;
}
});
});
// Cambiar orden (al perder el foco)
document.querySelectorAll('.order-input').forEach(input => {
input.addEventListener('change', async function () {
const slug = this.dataset.slug;
const order = parseInt(this.value, 10);
if (isNaN(order) || order < 1) return;
try {
await erp.fetch('', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'reorder', slug, order})
});
erp.toast(`Orden de "${slug}" actualizado`, 'info');
} catch (e) {
erp.toast('Error al actualizar: ' + e.message, 'danger');
}
});
});
});
</script>
<?php
Layout::close();
+22
View File
@@ -0,0 +1,22 @@
<?php
/**
* modules/whatsapp/module.php
* Descriptor del módulo WhatsApp Bot.
*/
return [
'slug' => 'whatsapp',
'name' => 'WhatsApp Bot',
'icon' => 'fab fa-whatsapp',
'category' => 'bot',
'route' => '/index.php',
'is_active' => true,
'sort_order' => 1,
'oleada' => 0,
'description' => 'Bot, conversaciones, plantillas y menús de WhatsApp',
'links' => [
['name' => 'Conversaciones', 'icon' => 'fas fa-comments', 'route' => '/conversations.php'],
['name' => 'Bot & Menús', 'icon' => 'fas fa-robot', 'route' => '/index.php'],
['name' => 'Plantillas', 'icon' => 'fas fa-file-alt', 'route' => '/index.php#templates'],
['name' => 'Programados', 'icon' => 'fas fa-clock', 'route' => '/scheduled_messages.php'],
],
];
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* modules/whatsapp/views/index.php
* Bridge al archivo legacy mientras se migra al nuevo sistema ERP.
* Acceso via: /erp.php?m=whatsapp&v=index o /whatsapp/index
*/
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__, 3));
}
require_once APP_ROOT . '/index.php';
+154
View File
@@ -0,0 +1,154 @@
<?php
/**
* shared/components/sidebar.php
* Menú lateral dinámico del ERP generado desde ModuleRegistry.
*
* DEPENDENCIAS:
* - config/config.php (sesión iniciada, SYSTEM_MODULES disponible)
* - core/Rbac.php (hasModule())
* - core/ModuleRegistry.php (forSidebar())
*
* Carga ModuleRegistry si aún no está disponible (compatibilidad con
* archivos lab_*.php que incluyen este sidebar directamente).
*/
// Cargar ModuleRegistry si no está ya (cuando el sidebar se incluye desde legacy files)
if (!class_exists('ModuleRegistry', false)) {
$__mrFile = defined('APP_ROOT')
? APP_ROOT . '/core/ModuleRegistry.php'
: __DIR__ . '/../../core/ModuleRegistry.php';
if (file_exists($__mrFile)) {
require_once $__mrFile;
}
}
// Ruta del script que está siendo cargado actualmente
$_current_script = basename($_SERVER['PHP_SELF']);
/**
* Retorna 'active' si la URL o script actual coincide con la ruta dada.
*/
$_isActive = function (string $route) use ($_current_script): string {
if ($route === '') return '';
// Para rutas como /lab_domicilios.php: comparar con basename
$routeBase = basename(parse_url($route, PHP_URL_PATH) ?? '');
if ($routeBase === $_current_script) {
return ' active';
}
// Para rutas con parámetros ERP (?m=&v=): comparar con REQUEST_URI
$uri = $_SERVER['REQUEST_URI'] ?? '';
if ($route !== '/' && str_contains($uri, ltrim($route, '/'))) {
return ' active';
}
return '';
};
// Título del sidebar: prioridad al nombre definido por el módulo, luego el genérico
$_sidebar_title = $SIDEBAR_TITLE ?? $GLOBALS['_LAYOUT_TITLE'] ?? 'Panel ERP';
$_sidebar_icon = $SIDEBAR_ICON ?? $GLOBALS['_LAYOUT_ICON'] ?? 'fas fa-th-large';
// Nombre del usuario para el pie del sidebar
$_user_name = $_SESSION['admin_user']['full_name']
?? $_SESSION['admin_user']['username']
?? 'Usuario';
$_user_role = $_SESSION['admin_user']['role'] ?? 'admin';
// Mapa de roles a etiqueta legible
$_role_label = [
'superadmin' => 'Super Admin',
'admin' => 'Administrador',
'recepcionista' => 'Recepcionista',
'bacteriologo' => 'Bacteriólogo',
'enfermero' => 'Enfermero',
'supervisor' => 'Supervisor',
'operador_bot' => 'Operador Bot',
'readonly' => 'Solo Lectura',
][$_user_role] ?? ucfirst($_user_role);
// Base URL para rutas relativas
$_base = defined('APP_URL') ? rtrim(APP_URL, '/') : '';
?>
<nav class="sidebar" id="erp-sidebar">
<div class="sidebar-header">
<h4><i class="<?= htmlspecialchars($_sidebar_icon, ENT_QUOTES, 'UTF-8') ?>"></i>
<?= htmlspecialchars($_sidebar_title, ENT_QUOTES, 'UTF-8') ?></h4>
</div>
<ul class="sidebar-menu">
<?php
// ── Renderizado dinámico desde ModuleRegistry ───────────────────────────────
if (class_exists('ModuleRegistry', false)) {
$sidebarGroups = ModuleRegistry::forSidebar();
foreach ($sidebarGroups as $catKey => $catData):
if (empty($catData['modules'])) continue;
?>
<!-- ══ <?= htmlspecialchars($catData['label'], ENT_QUOTES, 'UTF-8') ?> ══ -->
<li class="sidebar-section">
<i class="<?= htmlspecialchars($catData['icon'], ENT_QUOTES, 'UTF-8') ?> me-1"></i>
<?= htmlspecialchars($catData['label'], ENT_QUOTES, 'UTF-8') ?>
</li>
<?php foreach ($catData['modules'] as $slug => $mod):
$links = $mod['links'] ?? [];
// Si el módulo declara links propios, mostrarlos todos
if (!empty($links)):
foreach ($links as $lnk):
$href = $_base . $lnk['route'];
$active = $_isActive($lnk['route']);
?>
<li><a href="<?= htmlspecialchars($href, ENT_QUOTES, 'UTF-8') ?>"
class="nav-link<?= $active ?>">
<i class="<?= htmlspecialchars($lnk['icon'], ENT_QUOTES, 'UTF-8') ?>"></i>
<?= htmlspecialchars($lnk['name'], ENT_QUOTES, 'UTF-8') ?></a></li>
<?php endforeach;
else:
// Solo la ruta principal del módulo
$route = $mod['route'] ?? '';
$href = $route ? $_base . $route : '#';
$active = $route ? $_isActive($route) : '';
?>
<li><a href="<?= htmlspecialchars($href, ENT_QUOTES, 'UTF-8') ?>"
class="nav-link<?= $active ?>">
<i class="<?= htmlspecialchars($mod['icon'], ENT_QUOTES, 'UTF-8') ?>"></i>
<?= htmlspecialchars($mod['name'], ENT_QUOTES, 'UTF-8') ?></a></li>
<?php endif;
endforeach;
endforeach;
} else {
// ── Fallback estático (cuando ModuleRegistry no está disponible) ──────────
// Solo muestra los módulos básicos de laboratorio sin lógica dinámica.
?>
<li class="sidebar-section"><i class="fas fa-flask me-1"></i> Laboratorio</li>
<?php if (function_exists('hasModule') && hasModule('lab_dashboard')): ?>
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
<?php endif; ?>
<?php if (function_exists('hasModule') && hasModule('lab_domicilios')): ?>
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
<?php endif; ?>
<?php if (function_exists('hasModule') && hasModule('lab_pacientes')): ?>
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
<?php endif; ?>
<?php } ?>
<!-- ══ Pie del sidebar ══════════════════════════════════ -->
<li class="sidebar-divider"></li>
<li><a href="<?= $_base ?>/logout.php" class="nav-link logout-link"
onclick="return confirm('¿Cerrar sesión?')">
<i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
</ul>
<!-- Información del usuario -->
<div class="sidebar-user">
<div class="sidebar-user-name">
<i class="fas fa-user-circle me-1"></i>
<?= htmlspecialchars($_user_name, ENT_QUOTES, 'UTF-8') ?>
</div>
<div class="sidebar-user-role"><?= htmlspecialchars($_role_label, ENT_QUOTES, 'UTF-8') ?></div>
</div>
</nav>