From 3827d8cc07b9b42a261f52ce93856c0dbe172eb5 Mon Sep 17 00:00:00 2001 From: Ida Dittrich Date: Wed, 21 Jan 2026 11:26:22 +0100 Subject: [PATCH] fix: sidebar is tree instead of two levels only --- src/components/Sidebar/SidebarItem.tsx | 309 ++++++++++-- .../Sidebar/SidebarStyles/Sidebar.module.css | 3 + .../SidebarStyles/SidebarItem.module.css | 368 ++++++++++++++ .../SidebarStyles/SidebarSubmenu.module.css | 96 +++- src/components/Sidebar/SidebarSubmenu.tsx | 182 ------- src/components/Sidebar/sidebarTypes.ts | 7 +- src/core/PageManager/SidebarProvider.tsx | 467 +++++++++++------- src/core/PageManager/data/pages/index.ts | 3 - src/core/PageManager/data/pages/pek-tables.ts | 4 +- src/core/PageManager/data/pages/pek.ts | 4 +- .../PageManager/data/pages/trustee/access.ts | 2 +- .../data/pages/trustee/contracts.ts | 2 +- .../data/pages/trustee/documents.ts | 2 +- .../data/pages/trustee/organisations.ts | 2 +- .../data/pages/trustee/positions.ts | 2 +- .../PageManager/data/pages/trustee/roles.ts | 2 +- src/core/PageManager/pageInterface.ts | 3 + 17 files changed, 1027 insertions(+), 431 deletions(-) delete mode 100644 src/components/Sidebar/SidebarSubmenu.tsx diff --git a/src/components/Sidebar/SidebarItem.tsx b/src/components/Sidebar/SidebarItem.tsx index c60f5fd..322fb5e 100644 --- a/src/components/Sidebar/SidebarItem.tsx +++ b/src/components/Sidebar/SidebarItem.tsx @@ -1,10 +1,9 @@ -import React, { useEffect, useRef } from "react"; -import { Link } from "react-router-dom"; +import React, { useEffect, useRef, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; import { IoIosArrowDown } from "react-icons/io"; - +import { motion, AnimatePresence } from "framer-motion"; import styles from './SidebarStyles/SidebarItem.module.css'; -import SidebarSubmenu from "./SidebarSubmenu"; import { SidebarItemProps } from "./sidebarTypes"; const SidebarItem: React.FC = React.memo(({ @@ -14,10 +13,39 @@ const SidebarItem: React.FC = React.memo(({ isActive, isMinimized }) => { + const location = useLocation(); const Icon = item.icon as React.ComponentType>; const hasSubItems = item.submenu && item.submenu.length > 0; const isDisabled = item.moduleEnabled === false; const iconContainerRef = useRef(null); + const depth = item.depth || 0; // Get depth from item, default to 0 + // CSS already has 27px padding on .menu li, so we only add extra for nested items + // Depth 0: 0px extra (uses CSS default 27px), Depth 1: 20px extra, Depth 2: 40px extra + const indentPx = depth === 0 ? 0 : (depth * 20); + + // Local state for nested submenus (not tracked by sidebar logic) + const [nestedOpenStates, setNestedOpenStates] = useState>({}); + + // Check if a submenu item is active + const isSubmenuItemActive = (itemPath?: string) => { + if (!itemPath) return false; + const currentPath = location.pathname; + // Exact match or prefix match at path segment boundary + if (currentPath === itemPath) return true; + if (currentPath.startsWith(itemPath)) { + const nextChar = currentPath[itemPath.length]; + if (nextChar === '/' || nextChar === undefined) return true; + } + return false; + }; + + // Toggle nested submenu + const toggleNestedSubmenu = (itemId: string) => { + setNestedOpenStates(prev => ({ + ...prev, + [itemId]: !prev[itemId] + })); + }; // Fix SVG dimensions when minimized - react-icons uses 1em which can be invisible useEffect(() => { @@ -122,7 +150,6 @@ const SidebarItem: React.FC = React.memo(({ } }, [isMinimized, isActive, item.name, hasSubItems]); - const toggleSubmenu = (e: React.MouseEvent) => { if (isDisabled) { e.preventDefault(); @@ -138,14 +165,132 @@ const SidebarItem: React.FC = React.memo(({ e.preventDefault(); return; } - // If item has submenu, prevent navigation and only toggle submenu - if (hasSubItems) { + // If item has submenu or no link (navigation node), prevent navigation and only toggle submenu + if (hasSubItems || !item.link) { e.preventDefault(); e.stopPropagation(); onToggle(); return; } - // Allow normal navigation for items without submenu + // Allow normal navigation for items without submenu and with a link + }; + + // Render recursive submenu items + const renderSubmenuItems = () => { + if (!hasSubItems || !item.submenu) return null; + + if (isMinimized) { + // Horizontal layout for minimized sidebar + return ( + + {isOpen && ( + +
    + {item.submenu.map(subitem => { + const SubIcon = subitem.icon as React.ComponentType>; + const subIsActive = isSubmenuItemActive(subitem.link); + + return ( +
  • + + {SubIcon && ( + + )} + +
  • + ); + })} +
+
+ )} +
+ ); + } else { + // Vertical layout for expanded sidebar + return ( + + {isOpen && ( + + + {/* Render items directly as flex column children - same structure as first level */} + {/* This allows nested submenus to push siblings down just like first level */} + {item.submenu.map(subitem => { + const subIsActive = isSubmenuItemActive(subitem.link); + const subIsOpen = nestedOpenStates[subitem.id] || false; + const subDepth = subitem.depth !== undefined ? subitem.depth : (depth === 0 ? 1 : depth + 1); + + const sidebarItemData = { + id: subitem.id, + name: subitem.name, + link: subitem.link, + icon: subitem.icon, + submenu: subitem.submenu, + moduleEnabled: true, + depth: subDepth + }; + + // Render ALL items as recursive SidebarItem - direct children of flex column + // This matches the first-level structure where SidebarItems are direct siblings + return ( + toggleNestedSubmenu(subitem.id)} + isActive={subIsActive} + isMinimized={isMinimized} + /> + ); + })} + + + )} + + ); + } }; return ( @@ -153,47 +298,108 @@ const SidebarItem: React.FC = React.memo(({
  • - {/* Icon - always render, CSS handles positioning */} - {Icon && !isMinimized && ( - - )} - - {/* Text and arrow - hidden when minimized */} - {!isMinimized && ( - <> - {hasSubItems ? ( - // For items with submenu, make the entire area clickable to toggle - - ) : ( - // For items without submenu, use normal link - <> - - - {item.name} - - - + {item.name} + + )} + + )} + + + {/* Expand button - always right-aligned, not indented */} + {!isMinimized && (hasSubItems || !item.link) && ( + + )} + + {/* Link wrapper for items with link and no submenu - spans full width but text is indented */} + {!isMinimized && !hasSubItems && item.link && ( + )} @@ -227,18 +433,18 @@ const SidebarItem: React.FC = React.memo(({ )} - {/* Clickable overlay for items without submenu */} - {isMinimized && !isDisabled && !hasSubItems && ( + {/* Clickable overlay for items without submenu and with a link */} + {isMinimized && !isDisabled && !hasSubItems && item.link && ( )} - {/* Clickable overlay for items with submenu */} - {isMinimized && hasSubItems && !isDisabled && ( + {/* Clickable overlay for items with submenu or navigation nodes (no link) */} + {isMinimized && (hasSubItems || !item.link) && !isDisabled && (
  • - {hasSubItems && !isDisabled && } + {/* Recursive submenu rendering */} + {hasSubItems && !isDisabled && renderSubmenuItems()} ); }); SidebarItem.displayName = 'SidebarItem'; -export default SidebarItem; \ No newline at end of file +export default SidebarItem; diff --git a/src/components/Sidebar/SidebarStyles/Sidebar.module.css b/src/components/Sidebar/SidebarStyles/Sidebar.module.css index 017471e..0bf7b94 100644 --- a/src/components/Sidebar/SidebarStyles/Sidebar.module.css +++ b/src/components/Sidebar/SidebarStyles/Sidebar.module.css @@ -35,6 +35,9 @@ overflow-x: hidden; /* Disable horizontal scrolling */ padding: 0; margin: 0; + /* Ensure sidebar can scroll when content exceeds container */ + /* flex: 1 makes it take available space, min-height: 0 allows it to shrink */ + /* Content will expand naturally and trigger scrolling */ } .sidebarContainer.minimized .sidebar { diff --git a/src/components/Sidebar/SidebarStyles/SidebarItem.module.css b/src/components/Sidebar/SidebarStyles/SidebarItem.module.css index 84f8128..9a334a8 100644 --- a/src/components/Sidebar/SidebarStyles/SidebarItem.module.css +++ b/src/components/Sidebar/SidebarStyles/SidebarItem.module.css @@ -8,6 +8,11 @@ width: 250px; min-width: 250px; max-width: 250px; + overflow: visible; /* Allow nested submenus to expand */ + /* Ensure menu expands to fit content - critical for proper sidebar expansion */ + /* Don't set height - let it be determined by content */ + flex-shrink: 0; /* Prevent menu from shrinking */ + flex-grow: 0; /* Don't grow beyond content */ } .menu.minimized { @@ -361,4 +366,367 @@ opacity: 0.6 !important; } +/* ============================================ + Submenu Styles (merged from SidebarSubmenu.module.css) + ============================================ */ + +.submenu { + position: relative; + border: none; + border-top-right-radius: 25px; + border-bottom-right-radius: 25px; + margin: 0; + overflow: visible; /* Allow nested submenus to expand beyond this container */ + width: 250px !important; + min-width: 250px !important; + max-width: 250px !important; + box-sizing: border-box; + flex-shrink: 0; + flex-grow: 0; + display: block; /* Ensure it's part of the document flow */ + /* Ensure submenu expands parent container */ + height: auto; + min-height: 0; +} + +/* Motion div inside submenu - allow nested submenus to expand */ +.submenu > div[style*="overflow"] { + overflow: visible !important; /* Override inline overflow:hidden for nested submenus */ +} + +/* For nested submenus specifically, ensure they can expand */ +.submenuList li .menu .submenu { + overflow: visible !important; +} + +.submenuList li .menu .submenu > div { + overflow: visible !important; +} + +.submenuLineContainer { + display: flex; /* Flex column - same as .sidebar for consistent behavior */ + flex-direction: column; /* Stack items vertically */ + align-items: flex-start; /* Match .sidebar alignment */ + padding: 0; + margin: 0; + overflow: visible; /* Allow nested submenus to expand */ + width: 100%; /* Full width */ +} + +.submenuList { + margin: 0; + flex-grow: 1; + list-style: none; + padding: 0; + overflow: visible; /* Allow nested submenus to expand beyond container */ +} + +.submenuList li { + width: 100%; + color: #181818; + margin: 0; + position: relative; + overflow: visible; /* Allow nested submenus to expand */ + display: block; /* Block display allows nested submenus to extend properly */ +} + +.submenuList li a { + width: 100%; + height: 100%; + padding: 0 3px 0 27px; /* Base padding, indentation is added via inline styles on parent div */ + background: none; + border: none; + text-align: left; + cursor: pointer; + font-family: var(--font-family); + font-size: 0.9rem; + color: #181818; + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + transition: background-color 0.2s ease; +} + +.submenuList li a:hover { + background-color: var(--color-hover, rgba(0, 0, 0, 0.05)); +} + +.textContainer { + position: relative; + width: 100%; + overflow: hidden; + white-space: nowrap; +} + +.submenuIcon { + width: 16px; + height: 16px; + color: #181818; + flex-shrink: 0; +} + +/* Horizontal layout for minimized sidebar submenus */ +.submenu.minimized { + width: 100% !important; + margin: 0; + padding: 4px 0; + position: relative; + border-radius: 0; + overflow: hidden !important; + display: flex !important; + align-items: center; + justify-content: center; + opacity: 1 !important; + visibility: visible !important; + z-index: 10 !important; +} + +.submenuHorizontalContainer { + display: flex !important; + align-items: center; + justify-content: center; + width: 100%; + padding: 0; + margin: 0; + box-sizing: border-box; + opacity: 1 !important; + visibility: visible !important; +} + +.submenuHorizontalList { + display: flex !important; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 6px; + list-style: none; + padding: 0; + margin: 0; + flex-wrap: wrap; + opacity: 1 !important; + visibility: visible !important; +} + +.submenuHorizontalItem { + display: flex !important; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + width: auto; + height: auto; + list-style: none; + opacity: 1 !important; + visibility: visible !important; +} + +.submenuHorizontalLink { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 40px; + height: 40px; + border-radius: 12px; + background: none; + border: none; + cursor: pointer; + transition: background-color 0.2s ease; + text-decoration: none; + color: #181818 !important; + padding: 0; + margin: 0; + box-sizing: border-box; + position: relative; + overflow: visible; + opacity: 1 !important; + visibility: visible !important; +} + +.submenuHorizontalLink:hover { + background-color: var(--color-secondary); + color: white; +} + +.submenuHorizontalLink:hover .submenuHorizontalIcon { + color: white !important; +} + +.submenuHorizontalLink:hover .submenuHorizontalIcon svg { + color: white !important; +} + +.submenuHorizontalLink:hover .submenuHorizontalIcon svg path { + fill: currentColor !important; + stroke: currentColor !important; +} + +.submenuHorizontalIcon { + display: block !important; + width: 16px !important; + height: 16px !important; + padding: 0; + flex-shrink: 0; + flex-grow: 0; + color: #181818 !important; + margin: 0; + box-sizing: border-box; + position: relative !important; + top: auto !important; + left: auto !important; + transform: none !important; + opacity: 1 !important; + visibility: visible !important; +} + +.submenuHorizontalIcon svg { + width: 16px !important; + height: 16px !important; + min-width: 16px !important; + min-height: 16px !important; + max-width: 16px !important; + max-height: 16px !important; + display: block !important; + margin: 0; + padding: 0; + opacity: 1 !important; + visibility: visible !important; + color: #181818 !important; +} + +.submenuHorizontalIcon svg path { + color: inherit !important; + fill: currentColor !important; + stroke: currentColor !important; +} + +/* Ensure submenu content is visible when sidebar is minimized */ +.menu.minimized .submenu.minimized, +.menu.minimized .submenu.minimized * { + opacity: 1 !important; + visibility: visible !important; + color: #181818 !important; +} + +.menu.minimized .submenuHorizontalLink, +.menu.minimized .submenuHorizontalLink * { + opacity: 1 !important; + visibility: visible !important; + color: #181818 !important; +} + +/* Active state for submenu items */ +.submenuList li.active { + background-color: var(--color-secondary); + border-radius: 0 25px 25px 0; +} + +.submenuList li.active a, +.submenuList li.active a span, +.submenuList li a.activeLink { + color: white !important; +} + +.submenuList li.active .submenuIcon { + color: white !important; +} + +/* Active state for horizontal (minimized) submenu items */ +.submenuHorizontalItem.active .submenuHorizontalLink, +.submenuHorizontalLink.activeLink { + background-color: var(--color-secondary); + color: white !important; +} + +.submenuHorizontalItem.active .submenuHorizontalIcon, +.submenuHorizontalLink.activeLink .submenuHorizontalIcon { + color: white !important; +} + +.submenuHorizontalItem.active .submenuHorizontalIcon svg, +.submenuHorizontalLink.activeLink .submenuHorizontalIcon svg { + color: white !important; +} + +.submenuHorizontalItem.active .submenuHorizontalIcon svg path, +.submenuHorizontalLink.activeLink .submenuHorizontalIcon svg path { + fill: white !important; + stroke: white !important; +} + +/* Nested submenu toggle button (for navigation nodes without links) */ +.nestedToggleButton { + width: 100%; + height: 100%; + padding: 0 3px 0 27px; + background: none; + border: none; + text-align: left; + cursor: pointer; + font-family: var(--font-family); + font-size: 0.9rem; + color: #181818; + display: flex; + align-items: center; + gap: 8px; + transition: background-color 0.2s ease; +} + +.nestedToggleButton:hover { + background-color: var(--color-hover, rgba(0, 0, 0, 0.05)); +} + +.nestedToggleButton:focus { + outline: none; +} + +/* Nested arrow icon */ +.nestedArrow { + transition: transform 0.2s ease; + flex-shrink: 0; + color: #181818; +} + +.nestedArrow.rotated { + transform: rotate(180deg); +} + +/* Active state for nested toggle button */ +.submenuList li.active .nestedToggleButton { + color: white !important; +} + +.submenuList li.active .nestedArrow { + color: white !important; +} + +/* Nested SidebarItem within submenu - ensure full width and allow submenu to extend */ +.submenuList li .menu { + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; + overflow: visible !important; /* Allow nested submenus to extend */ +} + +.submenuList li .menu li { + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; + height: 44px; /* Set height only on the actual item li, not the wrapper */ + display: flex !important; /* Ensure the item content displays correctly */ + align-items: center !important; +} + +/* Ensure nested submenus can expand and are visible */ +.submenuList li .menu .submenu { + position: relative; + overflow: visible !important; + z-index: 10; /* Ensure nested submenu appears above other content */ +} + +/* Allow the sidebar container to expand when nested items are open */ +.sidebar { + overflow-y: auto; + overflow-x: hidden; +} diff --git a/src/components/Sidebar/SidebarStyles/SidebarSubmenu.module.css b/src/components/Sidebar/SidebarStyles/SidebarSubmenu.module.css index 4a26c0e..fbaa951 100644 --- a/src/components/Sidebar/SidebarStyles/SidebarSubmenu.module.css +++ b/src/components/Sidebar/SidebarStyles/SidebarSubmenu.module.css @@ -4,7 +4,7 @@ border-top-right-radius: 25px; border-bottom-right-radius: 25px; margin: 0; - overflow: hidden; + overflow: visible; /* Allow nested submenus to expand beyond this container */ width: 250px !important; min-width: 250px !important; max-width: 250px !important; @@ -13,6 +13,20 @@ flex-grow: 0; } +/* Motion div inside submenu - allow nested submenus to expand */ +.submenu > div[style*="overflow"] { + overflow: visible !important; /* Override inline overflow:hidden for nested submenus */ +} + +/* For nested submenus specifically, ensure they can expand */ +.submenuList li .menu .submenu { + overflow: visible !important; +} + +.submenuList li .menu .submenu > div { + overflow: visible !important; +} + .submenuLineContainer { display: flex; @@ -20,6 +34,7 @@ gap: 0; padding: 0; margin: 0; + overflow: visible; /* Allow nested submenus to expand */ } .submenuList { @@ -27,6 +42,7 @@ flex-grow: 1; list-style: none; padding: 0; + overflow: visible; /* Allow nested submenus to expand beyond container */ } .submenuList li { @@ -35,13 +51,15 @@ color: #181818; margin: 0; position: relative; - overflow: hidden; + overflow: visible; /* Allow nested submenus to expand */ + display: flex; + align-items: center; } .submenuList li a { width: 100%; height: 100%; - padding: 0 3px 0 27px; + padding: 0 3px 0 27px; /* Base padding, indentation is added via inline styles on parent div */ background: none; border: none; text-align: left; @@ -260,4 +278,76 @@ .submenuHorizontalLink.activeLink .submenuHorizontalIcon svg path { fill: white !important; stroke: white !important; +} + +/* Nested submenu toggle button (for navigation nodes without links) */ +.nestedToggleButton { + width: 100%; + height: 100%; + padding: 0 3px 0 27px; + background: none; + border: none; + text-align: left; + cursor: pointer; + font-family: var(--font-family); + font-size: 0.9rem; + color: #181818; + display: flex; + align-items: center; + gap: 8px; + transition: background-color 0.2s ease; +} + +.nestedToggleButton:hover { + background-color: var(--color-hover, rgba(0, 0, 0, 0.05)); +} + +.nestedToggleButton:focus { + outline: none; +} + +/* Nested arrow icon */ +.nestedArrow { + transition: transform 0.2s ease; + flex-shrink: 0; + color: #181818; +} + +.nestedArrow.rotated { + transform: rotate(180deg); +} + +/* Active state for nested toggle button */ +.submenuList li.active .nestedToggleButton { + color: white !important; +} + +.submenuList li.active .nestedArrow { + color: white !important; +} + +/* Nested SidebarItem within submenu - ensure full width */ +.submenuList li .menu { + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; +} + +.submenuList li .menu li { + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; +} + +/* Ensure nested submenus can expand and are visible */ +.submenuList li .menu .submenu { + position: relative; + overflow: visible !important; + z-index: 10; /* Ensure nested submenu appears above other content */ +} + +/* Allow the sidebar container to expand when nested items are open */ +.sidebar { + overflow-y: auto; + overflow-x: hidden; } \ No newline at end of file diff --git a/src/components/Sidebar/SidebarSubmenu.tsx b/src/components/Sidebar/SidebarSubmenu.tsx deleted file mode 100644 index 67357b9..0000000 --- a/src/components/Sidebar/SidebarSubmenu.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import styles from './SidebarStyles/SidebarSubmenu.module.css'; -import { Link, useLocation } from 'react-router-dom'; -import { motion, AnimatePresence } from 'framer-motion'; -import React, { useRef, useEffect, useState } from 'react'; -import { SidebarSubmenuProps, SidebarSubmenuItemData } from './sidebarTypes'; - -// Separate component for submenu item to properly use hooks -interface SubmenuItemProps { - subitem: SidebarSubmenuItemData; - isActive: boolean; -} - -const SubmenuItem: React.FC = ({ subitem, isActive }) => { - const textRef = useRef(null); - const containerRef = useRef(null); - const [isOverflowing, setIsOverflowing] = useState(false); - - useEffect(() => { - const checkOverflow = () => { - if (textRef.current && containerRef.current) { - const textWidth = textRef.current.scrollWidth; - const containerWidth = containerRef.current.clientWidth; - setIsOverflowing(textWidth > containerWidth); - } - }; - - checkOverflow(); - // Also check on window resize - window.addEventListener('resize', checkOverflow); - return () => window.removeEventListener('resize', checkOverflow); - }, [subitem.name]); - - const SubIcon = subitem.icon as React.ComponentType>; - - return ( -
  • - -
    - -
    - {SubIcon && } - - {subitem.name} - -
    -
    -
    - -
  • - ); -}; - -const SidebarSubmenu: React.FC = ({ item, isOpen, isMinimized = false }) => { - const location = useLocation(); - - // Check if a submenu item is active - const isSubmenuItemActive = (itemPath?: string) => { - if (!itemPath) return false; - const currentPath = location.pathname; - // Exact match or prefix match at path segment boundary - if (currentPath === itemPath) return true; - if (currentPath.startsWith(itemPath)) { - const nextChar = currentPath[itemPath.length]; - if (nextChar === '/' || nextChar === undefined) return true; - } - return false; - }; - - if (!item.submenu) return null; - - return ( - - {isOpen && ( - - {isMinimized ? ( - // Horizontal layout for minimized sidebar - -
      - {item.submenu.map(subitem => { - const SubIcon = subitem.icon as React.ComponentType>; - const isActive = isSubmenuItemActive(subitem.link); - - return ( -
    • - - {SubIcon && ( - - )} - - -
    • - ); - })} -
    -
    - ) : ( - // Vertical layout for expanded sidebar - -
      - {item.submenu.map(subitem => ( - - ))} -
    -
    - )} -
    - )} -
    - ); -}; - -export default SidebarSubmenu; \ No newline at end of file diff --git a/src/components/Sidebar/sidebarTypes.ts b/src/components/Sidebar/sidebarTypes.ts index dd2a5b9..a09ead2 100644 --- a/src/components/Sidebar/sidebarTypes.ts +++ b/src/components/Sidebar/sidebarTypes.ts @@ -8,14 +8,17 @@ export interface SidebarItemData { icon?: React.ComponentType>; submenu?: SidebarSubmenuItemData[]; moduleEnabled?: boolean; // New property for module state + depth?: number; // Hierarchy depth for indentation (0 = top level) } -// Submenu item interface +// Submenu item interface - supports recursive nesting export interface SidebarSubmenuItemData { id: string; name: string; - link?: string; + link?: string; // Optional - if undefined, it's a navigation node (not a page) icon?: React.ComponentType>; + submenu?: SidebarSubmenuItemData[]; // Recursive support for nested submenus + depth?: number; // Hierarchy depth for indentation (0 = top level) } // Sidebar state interface diff --git a/src/core/PageManager/SidebarProvider.tsx b/src/core/PageManager/SidebarProvider.tsx index 31aaf52..4748a80 100644 --- a/src/core/PageManager/SidebarProvider.tsx +++ b/src/core/PageManager/SidebarProvider.tsx @@ -1,13 +1,14 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; import { allPageData, SidebarItem } from './data'; import { useLanguage } from '../../providers/language/LanguageContext'; -import { resolveLanguageText } from './pageInterface'; +import { resolveLanguageText, GenericPageData } from './pageInterface'; import { usePermissions } from '../../hooks/usePermissions'; -import { FaHome, FaHatWizard, FaBriefcase } from 'react-icons/fa'; +import { FaHome, FaHatWizard, FaBriefcase, FaBuilding } from 'react-icons/fa'; import { RiFolderSettingsFill } from 'react-icons/ri'; +import { SidebarSubmenuItemData } from '../../components/Sidebar/sidebarTypes'; // Configuration for parent groups that don't have a page definition -// Maps parentPath to icon and default order +// Maps parentPath (can be nested like "start.real-estate") to icon and default order const parentGroupConfig: Record>; defaultOrder?: number; @@ -16,6 +17,14 @@ const parentGroupConfig: Record = ({ children }) => const { t } = useLanguage(); const { canView, preloadUiPermissions } = usePermissions(); + // Helper type for navigation tree nodes + interface NavigationNode { + id: string; + pathSegment: string; + fullPath: string; // Full dot-notation path (e.g., "start.real-estate") + name: string; + icon?: React.ComponentType>; + order: number; + page?: GenericPageData; // If this node represents an actual page + children: Map; // Keyed by path segment + pages: GenericPageData[]; // Direct child pages + } + + // Helper function to resolve node name + const resolveNodeName = (pathSegment: string, fullPath: string, page?: GenericPageData): string => { + if (page) { + return resolveLanguageText(page.name, t); + } + // Try translation key (e.g., "start.real-estate.title") + const translationKey = `${fullPath}.title`; + const translated = t(translationKey); + if (translated !== translationKey) { + return translated; + } + // Try just the segment (e.g., "real-estate.title") + const segmentKey = `${pathSegment}.title`; + const segmentTranslated = t(segmentKey); + if (segmentTranslated !== segmentKey) { + return segmentTranslated; + } + // Fallback to capitalized segment + return pathSegment.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' '); + }; + + // Helper function to resolve node icon + const resolveNodeIcon = (pathSegment: string, fullPath: string, page?: GenericPageData): React.ComponentType> | undefined => { + if (page?.icon) { + return page.icon; + } + // Check parentGroupConfig for nested paths first (e.g., "start.real-estate") + if (parentGroupConfig[fullPath]?.icon) { + return parentGroupConfig[fullPath].icon; + } + // Check parentGroupConfig for top-level segments (e.g., "start") + if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.icon) { + return parentGroupConfig[pathSegment].icon; + } + return undefined; + }; + + // Helper function to resolve node order + const resolveNodeOrder = (pathSegment: string, fullPath: string, page?: GenericPageData, childPages: GenericPageData[] = []): number => { + if (page?.order !== undefined) { + return page.order; + } + // Check parentGroupConfig for top-level segments + if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.defaultOrder !== undefined) { + return parentGroupConfig[pathSegment].defaultOrder!; + } + // Use minimum order of child pages + if (childPages.length > 0) { + const childOrders = childPages.map(p => p.order ?? 0); + return Math.min(...childOrders); + } + return 0; + }; + + // Build navigation tree from page data + const buildNavigationTree = (): Map => { + const rootNodes = new Map(); + + // Process all pages with parent paths + const pagesWithParents = allPageData.filter( + page => page.parentPath && !page.hide && page.showInSidebar !== false + ); + + for (const page of pagesWithParents) { + if (!page.parentPath) continue; + + // Parse parent path segments (e.g., "start.real-estate" -> ["start", "real-estate"]) + const pathSegments = page.parentPath.split('.'); + + // Build path to root, creating nodes as needed + let currentMap = rootNodes; + let currentFullPath = ''; + + for (let i = 0; i < pathSegments.length; i++) { + const segment = pathSegments[i]; + currentFullPath = currentFullPath ? `${currentFullPath}.${segment}` : segment; + + // Get or create node for this segment + if (!currentMap.has(segment)) { + // Check if there's a page for this path segment + const segmentPage = allPageData.find( + p => p.path === currentFullPath && !p.hide + ); + + const node: NavigationNode = { + id: segmentPage?.id || currentFullPath, + pathSegment: segment, + fullPath: currentFullPath, + name: '', // Will be resolved later + icon: undefined, // Will be resolved later + order: 0, // Will be resolved later + page: segmentPage, + children: new Map(), + pages: [] + }; + + currentMap.set(segment, node); + } + + const node = currentMap.get(segment)!; + + // If this is the last segment, add the page as a child page + if (i === pathSegments.length - 1) { + node.pages.push(page); + } + + // Move to next level + currentMap = node.children; + } + } + + // Resolve names, icons, and orders for all nodes + const resolveNode = (node: NavigationNode): void => { + // Resolve children first (bottom-up) + for (const childNode of node.children.values()) { + resolveNode(childNode); + } + + // Resolve this node + node.name = resolveNodeName(node.pathSegment, node.fullPath, node.page); + node.icon = resolveNodeIcon(node.pathSegment, node.fullPath, node.page); + + // Collect all child pages (from direct pages and nested children) + const allChildPages = [...node.pages]; + for (const childNode of node.children.values()) { + if (childNode.page) { + allChildPages.push(childNode.page); + } + allChildPages.push(...childNode.pages); + } + + node.order = resolveNodeOrder(node.pathSegment, node.fullPath, node.page, allChildPages); + }; + + // Resolve all root nodes + for (const node of rootNodes.values()) { + resolveNode(node); + } + + return rootNodes; + }; + + // Convert navigation tree node to sidebar submenu item (recursive) + const nodeToSubmenuItem = async (node: NavigationNode, depth: number = 0): Promise => { + // Filter child pages by RBAC and privilegeChecker + const accessiblePages: GenericPageData[] = []; + for (const page of node.pages) { + try { + const hasRBACAccess = await canView('UI', page.path); + if (!hasRBACAccess) continue; + + if (page.privilegeChecker) { + try { + const hasPrivilege = await page.privilegeChecker(); + if (!hasPrivilege) continue; + } catch (error) { + console.error(`Error checking privilegeChecker for page ${page.path}:`, error); + continue; + } + } + + accessiblePages.push(page); + } catch (error) { + console.error(`Error checking RBAC access for page ${page.path}:`, error); + } + } + + // Process child nodes recursively (increment depth) + const accessibleChildren: SidebarSubmenuItemData[] = []; + for (const childNode of node.children.values()) { + const childItem = await nodeToSubmenuItem(childNode, depth + 1); + if (childItem) { + accessibleChildren.push(childItem); + } + } + + // Combine pages and child nodes, assigning depth + const allChildren: SidebarSubmenuItemData[] = [ + ...accessiblePages.map(page => ({ + id: page.id, + name: resolveLanguageText(page.name, t), + link: `/${page.path}`, + icon: page.icon, + depth: depth + 1 // Child pages are one level deeper + })), + ...accessibleChildren + ]; + + // If no accessible children, don't create this node + if (allChildren.length === 0) { + return null; + } + + // If this node has a page itself, it shouldn't be a navigation node + // But according to requirements: if it has subpages, it is NOT a page itself + // So we create a navigation node without a link + return { + id: node.id, + name: node.name, + link: undefined, // Navigation node - not a clickable page + icon: node.icon, + submenu: allChildren.length > 0 ? allChildren : undefined, + depth: depth // Current depth level + }; + }; + + // Convert navigation tree to sidebar items + const treeToSidebarItems = async (tree: Map): Promise => { + const items: SidebarItem[] = []; + + // Process each root node (depth 0 for top-level items) + for (const node of tree.values()) { + const submenuItem = await nodeToSubmenuItem(node, 0); + if (submenuItem && submenuItem.submenu && submenuItem.submenu.length > 0) { + items.push({ + id: node.id, + name: node.name, + link: undefined, // Navigation node - not a clickable page + icon: node.icon, + moduleEnabled: true, + order: node.order, + submenu: submenuItem.submenu, + depth: 0 // Top-level items have depth 0 + }); + } + } + + return items; + }; + // Get sidebar items from page data const getSidebarItems = async (): Promise => { const items: SidebarItem[] = []; - // Get all unique parent paths from pages that have subpages - const parentPaths = new Set(); - allPageData.forEach(page => { - if (page.parentPath && !page.hide && page.showInSidebar !== false) { - parentPaths.add(page.parentPath); - } - }); + // Build navigation tree + const navigationTree = buildNavigationTree(); - // Create parent groups for each parentPath (even if no page exists for that path) - const parentGroups = new Map>; - order: number; - subpages: typeof allPageData; - }>(); - - for (const parentPath of parentPaths) { - // Check if a page exists for this parent path - const parentPage = allPageData.find(p => p.path === parentPath && !p.hide); - - // Get all subpages for this parent - const subpages = allPageData.filter(p => - p.parentPath === parentPath && - !p.hide && - p.showInSidebar !== false - ); - - if (subpages.length > 0) { - // Use parent page data if it exists, otherwise create a virtual parent - // Try to resolve name from translation key (e.g., "start.title") or use capitalized path - let parentName: string; - if (parentPage) { - parentName = resolveLanguageText(parentPage.name, t); - } else { - // Try to resolve as translation key first (e.g., "start.title") - const translationKey = `${parentPath}.title`; - const translated = t(translationKey); - parentName = translated !== translationKey ? translated : parentPath.charAt(0).toUpperCase() + parentPath.slice(1); - } - - // Get icon: use parent page icon if exists, otherwise use config, or undefined - const parentIcon = parentPage?.icon || parentGroupConfig[parentPath]?.icon; - - // Determine order: use parent page order if exists, otherwise use config default, - // then minimum order of subpages, or default to 0 - let parentOrder = parentPage?.order; - if (parentOrder === undefined) { - parentOrder = parentGroupConfig[parentPath]?.defaultOrder; - if (parentOrder === undefined) { - const subpageOrders = subpages.map(s => s.order ?? 0); - parentOrder = subpageOrders.length > 0 ? Math.min(...subpageOrders) : 0; - } - } - - parentGroups.set(parentPath, { - id: parentPage?.id || parentPath, - name: parentName, - icon: parentIcon, - order: parentOrder, - subpages: subpages - }); - } - } - - // Process parent groups - for (const [_parentPath, parentGroup] of parentGroups.entries()) { - // Filter subpages by RBAC access and privilegeChecker - const accessibleSubpages = []; - for (const subpage of parentGroup.subpages) { - try { - // Check RBAC access - const hasSubpageRBACAccess = await canView('UI', subpage.path); - if (!hasSubpageRBACAccess) { - continue; - } - - // Check client-side privilegeChecker if provided - if (subpage.privilegeChecker) { - try { - const hasPrivilege = await subpage.privilegeChecker(); - if (!hasPrivilege) { - continue; - } - } catch (error) { - console.error(`Error checking privilegeChecker for subpage ${subpage.path}:`, error); - continue; - } - } - - accessibleSubpages.push(subpage); - } catch (error) { - console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error); - } - } - - if (accessibleSubpages.length > 0) { - // Create parent item with submenu (no link since it's not a real page) - items.push({ - id: parentGroup.id, - name: parentGroup.name, - link: undefined, // No link - parent is not a clickable page - icon: parentGroup.icon, - moduleEnabled: true, - order: parentGroup.order, - submenu: accessibleSubpages.map(subpage => ({ - id: subpage.id, - name: resolveLanguageText(subpage.name, t), - link: `/${subpage.path}`, - icon: subpage.icon - })) - }); - } - } + // Convert tree to sidebar items + const treeItems = await treeToSidebarItems(navigationTree); + items.push(...treeItems); // Get main pages (no parent path) const mainPages = allPageData .filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false) .sort((a, b) => (a.order || 0) - (b.order || 0)); - // Process each main page (permissions already bulk-loaded) + // Process each main page for (const pageData of mainPages) { - // Check RBAC permissions (from cache - no API call) + // Check RBAC permissions try { const hasRBACAccess = await canView('UI', pageData.path); - if (!hasRBACAccess) { continue; } @@ -211,7 +354,7 @@ export const SidebarProvider: React.FC = ({ children }) => continue; } - // Check if this page has subpages + // Check if this page has subpages (legacy support) if (pageData.hasSubpages) { // Find all subpages for this parent const allSubpages = allPageData.filter(p => @@ -221,66 +364,52 @@ export const SidebarProvider: React.FC = ({ children }) => ); // Filter subpages by RBAC access - const accessibleSubpages = []; - console.log('📋 SidebarProvider: Checking subpages for:', { - parentPath: pageData.path, - totalSubpages: allSubpages.length - }); - + const accessibleSubpages: GenericPageData[] = []; for (const subpage of allSubpages) { try { - console.log('🔍 SidebarProvider: Checking subpage access:', { - parentPath: pageData.path, - subpagePath: subpage.path, - subpageName: subpage.name - }); - const hasSubpageRBACAccess = await canView('UI', subpage.path); - console.log('🔍 SidebarProvider: Subpage RBAC result:', { - subpagePath: subpage.path, - hasAccess: hasSubpageRBACAccess - }); - if (!hasSubpageRBACAccess) { - console.log('⛔ SidebarProvider: Subpage hidden due to RBAC:', subpage.path); continue; } - // Check client-side privilegeChecker if provided if (subpage.privilegeChecker) { try { const hasPrivilege = await subpage.privilegeChecker(); if (!hasPrivilege) { - console.log('⛔ SidebarProvider: Subpage hidden due to privilegeChecker:', subpage.path); continue; } } catch (error) { - console.error(`❌ SidebarProvider: Error checking privilegeChecker for subpage ${subpage.path}:`, error); + console.error(`Error checking privilegeChecker for subpage ${subpage.path}:`, error); continue; } } accessibleSubpages.push(subpage); - console.log('✅ SidebarProvider: Subpage added:', subpage.path); } catch (error) { - console.error(`❌ SidebarProvider: Error checking RBAC access for subpage ${subpage.path}:`, error); + console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error); } } - console.log('📋 SidebarProvider: Subpage filtering complete:', { - parentPath: pageData.path, - totalSubpages: allSubpages.length, - accessibleSubpages: accessibleSubpages.length, - accessiblePaths: accessibleSubpages.map(s => s.path) - }); - if (accessibleSubpages.length > 0) { - console.log('✅ SidebarProvider: Adding parent page with subpages:', { - path: pageData.path, - name: pageData.name, - subpagesCount: accessibleSubpages.length + // Create item with submenu (no link since it has subpages) + items.push({ + id: pageData.id, + name: resolveLanguageText(pageData.name, t), + link: undefined, // No link - has subpages, so it's a navigation node + icon: pageData.icon, + moduleEnabled: pageData.moduleEnabled ?? true, + order: pageData.order || 0, + depth: 0, // Top-level items have depth 0 + submenu: accessibleSubpages.map(subpage => ({ + id: subpage.id, + name: resolveLanguageText(subpage.name, t), + link: `/${subpage.path}`, + icon: subpage.icon, + depth: 1 // First level of submenu + })) }); - // Create expandable item with submenu + } else { + // No accessible subpages, show as regular item items.push({ id: pageData.id, name: resolveLanguageText(pageData.name, t), @@ -288,41 +417,19 @@ export const SidebarProvider: React.FC = ({ children }) => icon: pageData.icon, moduleEnabled: pageData.moduleEnabled ?? true, order: pageData.order || 0, - submenu: accessibleSubpages.map(subpage => ({ - id: subpage.id, - name: resolveLanguageText(subpage.name, t), - link: `/${subpage.path}`, - icon: subpage.icon - })) - }); - } else { - // No accessible subpages, show as regular item - console.log('✅ SidebarProvider: Adding parent page without accessible subpages:', { - path: pageData.path, - name: pageData.name - }); - items.push({ - id: pageData.id, - name: resolveLanguageText(pageData.name, t), - link: `/${pageData.path}`, - icon: pageData.icon, - moduleEnabled: pageData.moduleEnabled ?? true, - order: pageData.order || 0 + depth: 0 // Top-level items have depth 0 }); } } else { // Regular items without subpages - console.log('✅ SidebarProvider: Adding regular page:', { - path: pageData.path, - name: pageData.name - }); items.push({ id: pageData.id, name: resolveLanguageText(pageData.name, t), link: `/${pageData.path}`, icon: pageData.icon, moduleEnabled: pageData.moduleEnabled ?? true, - order: pageData.order || 0 + order: pageData.order || 0, + depth: 0 // Top-level items have depth 0 }); } } diff --git a/src/core/PageManager/data/pages/index.ts b/src/core/PageManager/data/pages/index.ts index e4c4043..2e83a11 100644 --- a/src/core/PageManager/data/pages/index.ts +++ b/src/core/PageManager/data/pages/index.ts @@ -5,7 +5,6 @@ export { workflowsPageData } from './workflows'; export { connectionsPageData } from './connections'; export { teamMembersPageData } from './admin/team-members'; export { promptsPageData } from './prompts'; -export { speechPageData } from './speech'; export { settingsPageData } from './settings'; export { pekPageData } from './pek'; export { pekTablesPageData } from './pek-tables'; @@ -31,7 +30,6 @@ import { workflowsPageData } from './workflows'; import { connectionsPageData } from './connections'; import { teamMembersPageData } from './admin/team-members'; import { promptsPageData } from './prompts'; -import { speechPageData } from './speech'; import { settingsPageData } from './settings'; import { pekPageData } from './pek'; import { pekTablesPageData } from './pek-tables'; @@ -48,7 +46,6 @@ export const allPageData = [ workflowsPageData, connectionsPageData, promptsPageData, - speechPageData, settingsPageData, pekPageData, pekTablesPageData, diff --git a/src/core/PageManager/data/pages/pek-tables.ts b/src/core/PageManager/data/pages/pek-tables.ts index 1f5870d..45e2cfe 100644 --- a/src/core/PageManager/data/pages/pek-tables.ts +++ b/src/core/PageManager/data/pages/pek-tables.ts @@ -5,12 +5,12 @@ import { getUserDataCache } from '../../../../utils/userCache'; export const pekTablesPageData: GenericPageData = { id: 'pek-tables', - path: 'start/pek-tables', + path: 'start/real-estate/pek-tables', name: 'Projektmanagement', description: 'Projektmanagement mit Tabellen', // Parent page - parentPath: 'start', + parentPath: 'start.real-estate', // Visual icon: FaTable, diff --git a/src/core/PageManager/data/pages/pek.ts b/src/core/PageManager/data/pages/pek.ts index e113eaf..b46c6f2 100644 --- a/src/core/PageManager/data/pages/pek.ts +++ b/src/core/PageManager/data/pages/pek.ts @@ -35,12 +35,12 @@ const createPekHook = () => { export const pekPageData: GenericPageData = { id: 'pek', - path: 'start/pek', + path: 'start/real-estate/pek', name: 'projects.title', description: 'projects.description', // Parent page - parentPath: 'start', + parentPath: 'start.real-estate', // Visual icon: FaBuilding, diff --git a/src/core/PageManager/data/pages/trustee/access.ts b/src/core/PageManager/data/pages/trustee/access.ts index 5a8f7be..52fe0c9 100644 --- a/src/core/PageManager/data/pages/trustee/access.ts +++ b/src/core/PageManager/data/pages/trustee/access.ts @@ -108,7 +108,7 @@ export const trusteeAccessPageData: GenericPageData = { path: 'trustee/access', name: 'trustee.access.title', description: 'trustee.access.description', - parentPath: 'trustee', + parentPath: 'start.trustee', icon: FaKey, title: 'trustee.access.title', diff --git a/src/core/PageManager/data/pages/trustee/contracts.ts b/src/core/PageManager/data/pages/trustee/contracts.ts index f540e12..4078b0e 100644 --- a/src/core/PageManager/data/pages/trustee/contracts.ts +++ b/src/core/PageManager/data/pages/trustee/contracts.ts @@ -108,7 +108,7 @@ export const trusteeContractsPageData: GenericPageData = { path: 'trustee/contracts', name: 'trustee.contracts.title', description: 'trustee.contracts.description', - parentPath: 'trustee', + parentPath: 'start.trustee', icon: FaFileContract, title: 'trustee.contracts.title', diff --git a/src/core/PageManager/data/pages/trustee/documents.ts b/src/core/PageManager/data/pages/trustee/documents.ts index 4ce603d..ab8a97d 100644 --- a/src/core/PageManager/data/pages/trustee/documents.ts +++ b/src/core/PageManager/data/pages/trustee/documents.ts @@ -113,7 +113,7 @@ export const trusteeDocumentsPageData: GenericPageData = { path: 'trustee/documents', name: 'trustee.documents.title', description: 'trustee.documents.description', - parentPath: 'trustee', + parentPath: 'start.trustee', icon: FaFile, title: 'trustee.documents.title', diff --git a/src/core/PageManager/data/pages/trustee/organisations.ts b/src/core/PageManager/data/pages/trustee/organisations.ts index e28ec2c..a5f4a94 100644 --- a/src/core/PageManager/data/pages/trustee/organisations.ts +++ b/src/core/PageManager/data/pages/trustee/organisations.ts @@ -110,7 +110,7 @@ export const trusteeOrganisationsPageData: GenericPageData = { path: 'trustee/organisations', name: 'trustee.organisations.title', description: 'trustee.organisations.description', - parentPath: 'trustee', + parentPath: 'start.trustee', icon: FaBuilding, title: 'trustee.organisations.title', diff --git a/src/core/PageManager/data/pages/trustee/positions.ts b/src/core/PageManager/data/pages/trustee/positions.ts index aebf24c..af49339 100644 --- a/src/core/PageManager/data/pages/trustee/positions.ts +++ b/src/core/PageManager/data/pages/trustee/positions.ts @@ -112,7 +112,7 @@ export const trusteePositionsPageData: GenericPageData = { path: 'trustee/positions', name: 'trustee.positions.title', description: 'trustee.positions.description', - parentPath: 'trustee', + parentPath: 'start.trustee', icon: FaReceipt, title: 'trustee.positions.title', diff --git a/src/core/PageManager/data/pages/trustee/roles.ts b/src/core/PageManager/data/pages/trustee/roles.ts index 2a20d20..6e37d4e 100644 --- a/src/core/PageManager/data/pages/trustee/roles.ts +++ b/src/core/PageManager/data/pages/trustee/roles.ts @@ -108,7 +108,7 @@ export const trusteeRolesPageData: GenericPageData = { path: 'trustee/roles', name: 'trustee.roles.title', description: 'trustee.roles.description', - parentPath: 'trustee', + parentPath: 'start.trustee', icon: FaUserTag, title: 'trustee.roles.title', diff --git a/src/core/PageManager/pageInterface.ts b/src/core/PageManager/pageInterface.ts index 18d8447..e6c438e 100644 --- a/src/core/PageManager/pageInterface.ts +++ b/src/core/PageManager/pageInterface.ts @@ -402,6 +402,7 @@ export interface SidebarItem { moduleEnabled: boolean; order: number; submenu?: SidebarSubmenuItemData[]; + depth?: number; // Hierarchy depth for indentation (0 = top level) } // Sidebar submenu item data interface @@ -410,6 +411,8 @@ export interface SidebarSubmenuItemData { name: string; link: string; icon?: IconType; + submenu?: SidebarSubmenuItemData[]; // Recursive support for nested submenus + depth?: number; // Hierarchy depth for indentation (0 = top level) } // Page instance for PageManager