[ 5 => ['type' => 'item', 'module' => 'dashboard', 'title' => 'menu.overview', 'icon' => 'fa fa-fw fa-gauge-high', 'tooltip' => 'menu.overview_tooltip', 'href' => '/dashboard/show', 'active' => ['show']], 10 => [ 'type' => 'submenu', 'id' => 'materialize', 'title' => 'menu.make', 'visual' => 'open', 'submenu' => [ 15 => ['type' => 'item', 'module' => 'tickets', 'title' => 'menu.todos', 'icon' => 'fa fa-fw fa-thumb-tack', 'tooltip' => 'menu.todos_tooltip', 'href' => '', 'hrefFunction' => 'getTicketMenu', 'active' => ['showKanban', 'showAll', 'showTicket', 'showList']], 25 => ['type' => 'item', 'module' => 'tickets', 'title' => 'menu.milestones', 'icon' => 'fa fa-fw fa-chart-gantt', 'tooltip' => 'menu.milestones_tooltip', 'href' => '', 'hrefFunction' => 'getTimelineMenu', 'active' => ['roadmap', 'showAllMilestones', 'showProjectCalendar']], 40 => ['type' => 'item', 'module' => 'goalcanvas', 'title' => 'menu.goals', 'icon' => 'fa fa-fw fa-bullseye', 'tooltip' => 'menu.goals_tooltip', 'href' => '/goalcanvas/dashboard', 'active' => ['showCanvas', 'dashboard']], ], ], 30 => [ 'type' => 'submenu', 'id' => 'understand', 'title' => 'menu.think', 'visual' => 'closed', 'submenu' => [ 30 => ['type' => 'item', 'module' => 'ideas', 'title' => 'menu.ideas', 'icon' => 'fa fa-fw fa-lightbulb', 'tooltip' => 'menu.ideas_tooltip', 'href' => '', 'hrefFunction' => 'getIdeaMenu', 'active' => ['showBoards', 'advancedBoards']], 50 => ['type' => 'item', 'module' => 'blueprints', 'title' => 'menu.blueprints', 'icon' => 'fa fa-fw fa-compass-drafting', 'tooltip' => 'menu.blueprints_tooltip', 'href' => '/blueprints/showBoards'], ], ], 40 => [ 'type' => 'submenu', 'id' => 'dataroom', 'title' => 'menu.dataroom', 'visual' => 'closed', 'submenu' => [ 55 => ['type' => 'item', 'module' => 'bom', 'title' => 'menu.bom', 'icon' => 'fa fa-fw fa-list-check', 'tooltip' => 'menu.bom_tooltip', 'href' => '/bom/show', 'active' => ['show']], 56 => ['type' => 'item', 'module' => 'bom', 'title' => 'menu.process', 'icon' => 'fa fa-fw fa-file-lines', 'tooltip' => 'menu.process_tooltip', 'href' => '/bom/show?type=process', 'active' => ['show']], 57 => ['type' => 'item', 'module' => 'bom', 'title' => 'menu.tooling', 'icon' => 'fa fa-fw fa-wrench', 'tooltip' => 'menu.tooling_tooltip', 'href' => '/bom/show?type=tooling', 'active' => ['show']], 60 => ['type' => 'item', 'module' => 'wiki', 'title' => 'menu.wiki', 'icon' => 'fa fa-fw fa-book', 'tooltip' => 'menu.wiki_tooltip', 'href' => '/wiki/show'], 70 => ['type' => 'item', 'module' => 'files', 'title' => 'menu.files', 'icon' => 'fa fa-fw fa-file', 'tooltip' => 'menu.files_tooltip', 'href' => '/files/browse'], 80 => ['type' => 'item', 'module' => 'reports', 'title' => 'menu.reports', 'icon' => 'fa fa-fw fa-chart-bar', 'tooltip' => 'menu.reports_tooltip', 'href' => '/reports/project', 'active' => ['project', 'show'], 'role' => 'editor'], ], ], ], // Display all menu items 'full_menu' => [ 10 => [ 'type' => 'submenu', 'id' => 'planning', 'title' => 'menu.planning_execution', 'visual' => 'open', 'submenu' => [ 11 => ['type' => 'item', 'module' => 'dashboard', 'title' => 'menu.dashboard', 'icon' => 'fa fa-fw fa-home', 'tooltip' => 'menu.dashboard_tooltip', 'href' => '/dashboard/show', 'active' => ['show']], 21 => ['type' => 'item', 'module' => 'tickets', 'title' => 'menu.todos', 'icon' => 'fa fa-fw fa-thumb-tack', 'tooltip' => 'menu.todos_tooltip', 'href' => '', 'hrefFunction' => 'getTicketMenu', 'active' => ['showKanban', 'showAll', 'showTicket']], 31 => ['type' => 'item', 'module' => 'tickets', 'title' => 'menu.milestones', 'icon' => 'fa fa-fw fa-sliders', 'tooltip' => 'menu.milestones_tooltip', 'href' => '/tickets/roadmap', 'active' => ['roadmap']], 40 => ['type' => 'item', 'module' => 'goalcanvas', 'title' => 'menu.goals', 'icon' => 'fa fa-fw fa-bullseye', 'tooltip' => 'menu.goals_tooltip', 'href' => '/goalcanvas/showCanvas'], ], ], 50 => [ 'type' => 'submenu', 'id' => 'dts-process', 'title' => 'menu.dts.process', 'visual' => 'closed', 'submenu' => [ 51 => ['type' => 'item', 'module' => 'blueprints', 'icon' => 'far fa-fw fa-note-sticky', 'tooltip' => 'menu.insightscanvas_tooltip', 'title' => 'menu.insightscanvas', 'href' => '/blueprints/insights/showCanvas', 'active' => ['insights']], 52 => ['type' => 'item', 'module' => 'ideas', 'icon' => 'fa fa-fw fa-lightbulb', 'tooltip' => 'menu.ideas_tooltip', 'title' => 'menu.ideation', 'href' => '/ideas/showBoards'], ], ], 60 => [ 'type' => 'submenu', 'id' => 'dts-frameworks', 'title' => 'menu.dts.frameworks', 'visual' => 'closed', 'submenu' => [ 61 => ['type' => 'header', 'title' => 'menu.dts.observe'], 62 => ['type' => 'item', 'module' => 'blueprints', 'title' => 'menu.sbcanvas', 'icon' => 'fas fa-fw fa-list-check', 'tooltip' => 'menu.sbcanvas_tooltip', 'href' => '/blueprints/sb/showCanvas', 'active' => ['sb']], 63 => ['type' => 'item', 'module' => 'blueprints', 'title' => 'menu.riskscanvas', 'icon' => 'fas fa-fw fa-person-falling', 'tooltip' => 'menu.riskscanvas_tooltip', 'href' => '/blueprints/risks/showCanvas', 'active' => ['risks']], 64 => ['type' => 'item', 'module' => 'blueprints', 'title' => 'menu.eacanvas', 'icon' => 'fas fa-fw fa-tree', 'tooltip' => 'menu.eacanvas_tooltip', 'href' => '/blueprints/ea/showCanvas', 'active' => ['ea']], 65 => ['type' => 'header', 'title' => 'menu.dts.design'], 66 => ['type' => 'item', 'module' => 'blueprints', 'title' => 'menu.lbmcanvas', 'icon' => 'fas fa-fw fa-building', 'tooltip' => 'menu.lbmcanvas_tooltip', 'href' => '/blueprints/lbm/showCanvas', 'active' => ['lbm']], 67 => ['type' => 'item', 'module' => 'blueprints', 'title' => 'menu.dbmcanvas', 'icon' => 'fas fa-fw fa-building', 'tooltip' => 'menu.dbmcanvas_tooltip', 'href' => '/blueprints/dbm/showCanvas', 'active' => ['dbm']], 68 => ['type' => 'item', 'module' => 'blueprints', 'title' => 'menu.cpcanvas', 'icon' => 'fas fa-fw fa-city', 'tooltip' => 'menu.cpcanvas_tooltip', 'href' => '/blueprints/cp/showCanvas', 'active' => ['cp']], 69 => ['type' => 'header', 'title' => 'menu.dts.validate'], 70 => ['type' => 'item', 'module' => 'blueprints', 'title' => 'menu.sqcanvas', 'icon' => 'fas fa-fw fa-chess', 'tooltip' => 'menu.sqcanvas_tooltip', 'href' => '/blueprints/sq/showCanvas', 'active' => ['sq']], 71 => ['type' => 'item', 'module' => 'blueprints', 'title' => 'menu.smcanvas', 'icon' => 'fas fa-fw fa-message', 'tooltip' => 'menu.smcanvas_tooltip', 'href' => '/blueprints/sm/showCanvas', 'active' => ['sm']], ], ], 80 => [ 'type' => 'submenu', 'id' => 'dts-admin', 'title' => 'menu.dts.admin', 'visual' => 'open', 'submenu' => [ 81 => ['type' => 'item', 'module' => 'wiki', 'title' => 'menu.wiki', 'icon' => 'fa fa-fw fa-book', 'tooltip' => 'menu.wiki_tooltip', 'href' => '/wiki/show'], 82 => ['type' => 'item', 'module' => 'blueprints', 'title' => 'menu.retroscanvas', 'icon' => 'fa fa-fw fa-hand-spock', 'tooltip' => 'menu.retroscanvas_tooltip', 'href' => '/blueprints/retros/showCanvas', 'active' => ['retros']], 83 => ['type' => 'item', 'module' => 'reports', 'title' => 'menu.reports', 'icon' => 'fa fa-fw fa-chart-bar', 'tooltip' => 'menu.reports_tooltip', 'href' => '/reports/project', 'active' => ['project', 'show'], 'role' => 'editor'], ], ], ], 'personal' => [ 5 => ['type' => 'item', 'module' => 'dashboard', 'title' => 'menu.sidemenu_home', 'icon' => 'fa fa-house', 'tooltip' => 'menu.overview_tooltip', 'href' => '/dashboard/home', 'active' => ['home']], 7 => ['type' => 'item', 'module' => 'projects', 'title' => 'menu.sidemenu_my_project_hub', 'icon' => 'fa fa-solid fa-house-flag', 'tooltip' => 'menu.projecthub_tooltip', 'href' => '/projects/showMy', 'active' => ['showMy'], 'role' => 'editor'], 15 => ['type' => 'item', 'module' => 'timesheets', 'title' => 'menu.sidemenu_my_timesheets', 'icon' => 'fa-clock', 'tooltip' => 'menu.my_timesheets_tooltip', 'href' => '/timesheets/showMy', 'active' => ['showMy']], 20 => ['type' => 'item', 'module' => 'calendar', 'title' => 'menu.sidemenu_my_calendar', 'icon' => 'fa fa-calendar', 'tooltip' => 'menu.my_calendar_tooltip', 'href' => '/calendar/showMyCalendar', 'active' => ['showMyCalendar']], ], 'projecthub' => [ 10 => ['type' => 'item', 'module' => 'projects', 'title' => 'menu.sidemenu_my_project_hub', 'icon' => 'fa-solid fa-house-flag', 'tooltip' => 'menu.my_projects_tooltip', 'href' => '/projects/showMy', 'active' => ['showMy']], ], 'company' => [ 10 => [ 'type' => 'submenu', 'id' => 'Management', 'title' => 'menu.sidemenu_management', 'visual' => 'open', 'role' => 'manager', 'submenu' => [ 5 => ['type' => 'item', 'module' => 'timesheets', 'role' => 'manager', 'title' => 'menu.all_timesheets', 'icon' => 'fa fa-fw fa-business-time', 'tooltip' => 'menu.all_timesheets_tooltip', 'href' => '/timesheets/showAll', 'active' => ['showAll']], 10 => ['type' => 'item', 'module' => 'projects', 'role' => 'manager', 'title' => 'menu.all_projects', 'icon' => 'fa fa-fw fa-briefcase', 'tooltip' => 'menu.all_projects_tooltip', 'href' => '/projects/showAll', 'active' => ['showAll']], 15 => ['type' => 'item', 'module' => 'clients', 'role' => 'admin', 'title' => 'menu.all_clients', 'icon' => 'fa fa-fw fa-address-book', 'tooltip' => 'menu.all_clients_tooltip', 'href' => '/clients/showAll', 'active' => ['showAll']], 20 => ['type' => 'item', 'module' => 'users', 'role' => 'admin', 'title' => 'menu.all_users', 'icon' => 'fa fa-fw fa-users', 'tooltip' => 'menu.all_users_tooltip', 'href' => '/users/showAll', 'active' => ['showAll']], ], ], 15 => [ 'type' => 'submenu', 'id' => 'administration', 'title' => 'menu.sidemenu_administration', 'visual' => 'open', 'role' => 'admin', 'submenu' => [ 5 => ['type' => 'item', 'module' => 'plugins', 'title' => 'menu.leantime_apps', 'icon' => 'fa fa-fw fa-puzzle-piece', 'tooltip' => 'menu.leantime_apps_tooltip', 'href' => '/plugins/marketplace', 'active' => ['marketplace', 'myapps']], 10 => ['type' => 'item', 'module' => 'connector', 'title' => 'menu.integrations', 'icon' => 'fa fa-fw fa-circle-nodes', 'tooltip' => 'menu.connector_tooltip', 'href' => '/connector/show', 'active' => ['show']], 15 => ['type' => 'item', 'module' => 'setting', 'title' => 'menu.company_settings', 'icon' => 'fa fa-fw fa-cogs', 'tooltip' => 'menu.company_settings_tooltip', 'href' => '/setting/editCompanySettings', 'active' => ['editCompanySettings']], ], ], ], ]; public function __construct( /** @var SettingRepository */ private SettingRepository $settingsRepo, /** @var LanguageCore */ private LanguageCore $language, /** @var EnvironmentCore */ private EnvironmentCore $config, /** @var TicketService */ private TicketService $ticketsService, ) { if (session()->exists('usersettings.submenuToggle') === false && session()->exists('userdata') === true) { $setting = $this->settingsRepo; session([ 'usersettings.submenuToggle' => safe_unserialize( $setting->getSetting('usersetting.'.session('userdata.id').'.submenuToggle'), [] ), ]); } } /** * getMenuTypes - Return an array of a currently supported menu types * * @return array Array of supported menu types */ public function getMenuTypes(): array { $language = $this->language; $config = $this->config; if (! isset($config->enableMenuType) || (isset($config->enableMenuType) && $config->enableMenuType === false)) { return [self::DEFAULT_MENU => $language->__('label.menu_type.'.self::DEFAULT_MENU)]; } $menuTypes = []; foreach ($this->menuStructures as $key => $menu) { $menuTypes[$key] = $language->__("label.menu_type.$key"); } return $menuTypes; } /** * setSubmenuState - Set the state of the submenu (open or closed) * * @param string $submenu Submenu identifier * @param string $state New state (open / closed) */ public function setSubmenuState(string $submenu, string $state): void { if (session()->exists('usersettings.submenuToggle') && is_array(session('usersettings.submenuToggle')) && $submenu !== false) { session(['usersettings.submenuToggle.'.$submenu => $state]); } $setting = $this->settingsRepo; $setting->saveSetting('usersetting.'.session('userdata.id').'.submenuToggle', serialize(session('usersettings.submenuToggle'))); } /** * getSubmenuState - Gets the state of the submenu (open or closed) * * @param string $submenu Submenu identifier */ public function getSubmenuState(string $submenu) { $setting = $this->settingsRepo; $subStructure = $setting->getSetting('usersetting.'.session('userdata.id').'.submenuToggle'); session(['usersettings.submenuToggle' => safe_unserialize($subStructure, [])]); return session('usersettings.submenuToggle.'.$submenu) ?? false; } /** * Builds the menu structure recursively. * * @param array &$menuStructure The menu structure to build. Passed by reference. * @param string $filter The filter to apply to the menu structure. * @return array The built menu structure. */ protected function buildMenuStructure(array &$menuStructure, string $filter): array { foreach ($menuStructure as &$menuItem) { if ($menuItem['type'] !== 'submenu') { continue; } $menuItem['submenu'] = $this->buildMenuStructure($menuItem['submenu'], $filter); $filter = $filter.'.'.$menuItem['id']; return self::dispatch_filter( hook: $filter, payload: $menuItem['submenu'], function: 'getMenuStructure' ); } return $menuStructure; } /** * getMenu - Return a specific menu structure * * @param string $menuType Menu type to return * @return array Array of menu structrue */ public function getMenuStructure(string $menuType = ''): array { if (empty($menuType)) { $menuType = self::DEFAULT_MENU; } $this->menuStructures = self::dispatch_filter( 'menuStructures', $this->menuStructures, ['menuType' => $menuType] ); // If menu structure cannot be found, don't return anything if (! isset($this->menuStructures[$menuType])) { return []; } $language = $this->language; $filter = "menuStructures.$menuType"; $this->menuStructures[$menuType] = self::dispatch_filter( $filter, $this->menuStructures[$menuType], ['menuType' => $menuType] ); $menuStructure = $this->menuStructures[$menuType]; if (session()->exists('usersettings.submenuToggle') === false || is_array(session('usersettings.submenuToggle')) === false) { session(['usersettings.submenuToggle' => []]); } ksort($menuStructure); foreach ($menuStructure as $key => $element) { if (isset($menuStructure[$key]['title'])) { $menuStructure[$key]['title'] = $language->__($element['title']); } switch ($element['type']) { case 'header': case 'separator': break; case 'item': // TO DO: Check if menu is enabled, e.g. `$moduleManagerRepo->isModuleEnabled($element['module'])` $this->processMenuItem($element, $menuStructure[$key]); break; case 'submenu': if (isset($element['submenuFunction'])) { if (method_exists($this, $this->{$element['submenuFunction']})) { $menuStructure[$key]['submenu'] = $this->{$element['submenuFunction']}(); } } // Update menu toggle if ($element['visual'] == 'always') { $submenuState = 'open'; } else { $submenuState = session('usersettings.submenuToggle.'.$element['id']) ?? $element['visual']; session(['usersettings.submenuToggle.'.$element['id'] => $submenuState]); } $menuStructure[$key]['visual'] = $submenuState; // Parse submenu foreach ($element['submenu'] as $subkey => $subelement) { ksort($menuStructure[$key]['submenu']); $menuStructure[$key]['submenu'][$subkey]['title'] = $language->__($menuStructure[$key]['submenu'][$subkey]['title']); switch ($subelement['type']) { case 'header': break; case 'item': $this->processMenuItem($subelement, $menuStructure[$key]['submenu'][$subkey]); break; default: exit("Cannot proceed due to invalid submenu element: '".$subelement['type']."'"); } } break; default: exit("Cannot proceed due to invalid menu element: '".$element['type']."'"); } } // TODO: Add menu filter here! return $menuStructure; } public function processMenuItem($element, &$structure): void { // Update security if (isset($element['role'])) { $accessGranted = AuthService::userIsAtLeast($element['role'], true); if (! $accessGranted) { $structure['type'] = 'disabled'; } } if (isset($element['hrefFunction'])) { if (method_exists($this, $element['hrefFunction'])) { $structure['href'] = $this->{$element['hrefFunction']}(); } } } /** * @return array|mixed|string|string[] */ public function getTicketMenu(): mixed { $ticketService = $this->ticketsService; // Removing base URL from here since it is being added in the menu for loop in the template $base_url = ! empty($this->config->appUrl) ? $this->config->appUrl : BASE_URL; return str_replace($base_url, '', $ticketService->getLastTicketViewUrl()); } public function getTimelineMenu(): mixed { $ticketService = $this->ticketsService; // Removing base URL from here since it is being added in the menu for loop in the template $base_url = ! empty($this->config->appUrl) ? $this->config->appUrl : BASE_URL; return str_replace($base_url, '', $ticketService->getLastTimelineViewUrl()); } public function getIdeaMenu(): string { $url = '/ideas/showBoards'; if (session()->exists('lastIdeaView')) { if (session('lastIdeaView') == 'kanban') { $url = '/ideas/advancedBoards'; } } return $url; } /** * Request-level cache for getSectionMenuType results. * Prevents redundant computation when called from multiple composers (App, Menu, HeadMenu). * Keyed by route+default since different callers may pass different defaults. * * @var array */ private static array $sectionMenuTypeCache = []; public function getSectionMenuType($currentRoute, $default = 'default') { // Cache key includes both route and default since the result depends on both. // Different composers may pass different defaults for routes not in the sections map. $cacheKey = $currentRoute.'|'.$default; if (isset(self::$sectionMenuTypeCache[$cacheKey])) { return self::$sectionMenuTypeCache[$cacheKey]; } $sections = [ 'dashboard.home' => 'personal', 'projects.showMy' => 'personal', 'timesheets.showMy' => 'personal', 'calendar.showMyCalendar' => 'personal', 'calendar.showMyList' => 'personal', 'tickets.roadmapAll' => 'personal', 'notes.showNotes' => 'personal', 'notes.showNotesList' => 'personal', 'tickets.showAllMilestonesOverview' => 'personal', 'users.editOwn' => 'personal', 'setting.editCompanySettings' => 'company', 'timesheets.showAll' => 'company', 'projects.showAll' => 'company', 'clients.showAll' => 'company', 'clients.newClient' => 'company', 'clients.showClient' => 'company', 'users.showAll' => 'company', 'users.editUser' => 'company', 'plugins.show' => 'company', 'plugins.marketplace' => 'company', 'plugins.myapps' => 'company', 'connector.show' => 'company', 'connector.integration' => 'company', 'billing.subscriptions' => 'company', 'llamadorian.statusCollector' => 'personal', ]; $sections = self::dispatch_filter('menuSections', $sections, ['currentRoute' => $currentRoute, 'default' => $default]); $result = $sections[$currentRoute] ?? $default; self::$sectionMenuTypeCache[$cacheKey] = $result; return $result; } }