|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', 'formularios', 'turnero', 'clinico', 'reportes', 'sistema']; /** Etiquetas e íconos de cada categoría */ private const CATEGORIES = [ 'bot' => ['label' => 'WhatsApp', 'icon' => 'fab fa-whatsapp'], 'lab' => ['label' => 'Domicilios', 'icon' => 'fas fa-house-medical'], 'formularios'=> ['label' => 'Formulario', 'icon' => 'fas fa-file-invoice'], '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 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. * * IMPORTANTE: consulta role_modules desde BD en tiempo real para que los * cambios hechos en lab_usuarios.php se reflejen sin necesidad de re-login. * * Excepción: superadmin y admin sin role_id tienen acceso total (retrocompat). * * @return array Indexado por slug. */ public static function forCurrentUser(): array { self::load(); $allowed = self::liveUserModules(); // null = acceso total $result = []; foreach (self::$registry as $slug => $mod) { if (!$mod['is_active']) { continue; } // null = superadmin / admin legacy → ve todo if ($allowed !== null && !in_array($slug, $allowed, true)) { continue; } $result[$slug] = $mod; } return $result; } /** * Obtiene los slugs de módulos permitidos para el usuario actual consultando * role_modules en BD (ignora la caché de sesión para ser siempre fresco). * * Retorna null si el usuario tiene acceso total (superadmin / admin legacy). * * @return string[]|null */ private static function liveUserModules(): ?array { $role = $_SESSION['admin_user']['role'] ?? 'admin'; $roleId = $_SESSION['admin_user']['role_id'] ?? null; // Superadmin → acceso total siempre if ($role === 'superadmin') { return null; } // Admin sin role_id asignado → retrocompat, acceso total if ($role === 'admin' && !$roleId) { return null; } // Sin role_id en cualquier otro rol → usar sesión como fallback if (!$roleId) { return $_SESSION['admin_user']['modules'] ?? []; } // Consulta en tiempo real: reflejan cambios en lab_usuarios.php al instante try { $db = Database::getInstance(); $pdo = $db->getConnection(); $stmt = $pdo->prepare( "SELECT module_slug FROM role_modules WHERE role_id = ?" ); $stmt->execute([(int) $roleId]); return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'module_slug'); } catch (Exception $e) { error_log("ModuleRegistry::liveUserModules error: " . $e->getMessage()); // Fallback a sesión si la BD no responde return $_SESSION['admin_user']['modules'] ?? []; } } /** * 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'] ?? [], ]; } }