up
This commit is contained in:
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
================================================================================
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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');
|
||||
@@ -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']]];
|
||||
@@ -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';
|
||||
@@ -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']]];
|
||||
@@ -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';
|
||||
@@ -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']]];
|
||||
@@ -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';
|
||||
@@ -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']]];
|
||||
@@ -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';
|
||||
@@ -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']]];
|
||||
@@ -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';
|
||||
@@ -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']]];
|
||||
@@ -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';
|
||||
@@ -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']]];
|
||||
@@ -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';
|
||||
@@ -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']]];
|
||||
@@ -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';
|
||||
@@ -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']]];
|
||||
@@ -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';
|
||||
@@ -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']]];
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user