fix: sidebar is tree instead of two levels only

This commit is contained in:
Ida Dittrich 2026-01-21 11:26:22 +01:00
parent 322ab7b890
commit 3827d8cc07
17 changed files with 1027 additions and 431 deletions

View file

@ -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<SidebarItemProps> = React.memo(({
@ -14,10 +13,39 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
isActive,
isMinimized
}) => {
const location = useLocation();
const Icon = item.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
const hasSubItems = item.submenu && item.submenu.length > 0;
const isDisabled = item.moduleEnabled === false;
const iconContainerRef = useRef<HTMLDivElement>(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<Record<string, boolean>>({});
// 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<SidebarItemProps> = React.memo(({
}
}, [isMinimized, isActive, item.name, hasSubItems]);
const toggleSubmenu = (e: React.MouseEvent) => {
if (isDisabled) {
e.preventDefault();
@ -138,14 +165,132 @@ const SidebarItem: React.FC<SidebarItemProps> = 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 (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.3, duration: 0.25 } }}
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
className={styles.submenuHorizontalContainer}
>
<ul className={styles.submenuHorizontalList}>
{item.submenu.map(subitem => {
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
const subIsActive = isSubmenuItemActive(subitem.link);
return (
<li key={subitem.id} className={`${styles.submenuHorizontalItem} ${subIsActive ? styles.active : ''}`}>
<Link
to={subitem.link || '#'}
title={subitem.name}
className={`${styles.submenuHorizontalLink} ${subIsActive ? styles.activeLink : ''}`}
>
{SubIcon && (
<SubIcon
className={styles.submenuHorizontalIcon}
style={{
width: '16px',
height: '16px',
color: subIsActive ? 'white' : '#181818',
display: 'block'
}}
/>
)}
</Link>
</li>
);
})}
</ul>
</motion.div>
)}
</AnimatePresence>
);
} else {
// Vertical layout for expanded sidebar
return (
<AnimatePresence>
{isOpen && (
<motion.div
layout
initial={{ height: 0, opacity: 0 }}
animate={{
height: "auto",
opacity: 1,
transition: {
height: { duration: 0.3, ease: "easeInOut", delay: 0 },
opacity: { duration: 0.25, ease: "easeInOut", delay: 0.3 }
}
}}
exit={{
height: 0,
opacity: 0,
transition: {
opacity: { duration: 0.25, ease: "easeInOut", delay: 0 },
height: { duration: 0.3, ease: "easeInOut", delay: 0.25 }
}
}}
style={{ overflow: "visible" }}
className={styles.submenu}
>
<motion.div
className={styles.submenuLineContainer}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.3, duration: 0.25 } }}
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
>
{/* 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 (
<SidebarItem
key={subitem.id}
item={sidebarItemData}
isOpen={subIsOpen}
onToggle={() => toggleNestedSubmenu(subitem.id)}
isActive={subIsActive}
isMinimized={isMinimized}
/>
);
})}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
};
return (
@ -153,47 +298,108 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
<li
className={`${isActive ? styles.active : ""} ${isDisabled ? styles.disabledItem : ""}`}
data-item-name={item.name}
style={{ display: 'flex', alignItems: 'center', width: '100%' }}
>
{/* Icon - always render, CSS handles positioning */}
{Icon && !isMinimized && (
<Icon
className={`${styles.icon} ${isDisabled ? styles.disabledIcon : ''}`}
/>
)}
{/* Text and arrow - hidden when minimized */}
{!isMinimized && (
<>
{hasSubItems ? (
// For items with submenu, make the entire area clickable to toggle
<button
onClick={toggleSubmenu}
className={`${styles.menuTextButton} ${isDisabled ? styles.disabledLink : ''}`}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : `Toggle ${item.name} submenu`}
>
<span className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}>
{/* Icon and text container with indentation - takes remaining space */}
<div
style={{
display: 'flex',
alignItems: 'center',
paddingLeft: `${indentPx}px`, // Add 20px per level for nested items (depth 0 = 0px, depth 1 = 20px, depth 2 = 40px)
flex: 1,
minWidth: 0, // Allow flex shrinking
overflow: 'hidden' // Prevent overflow
}}
>
{/* Icon - always render, CSS handles positioning */}
{Icon && !isMinimized && (
<Icon
className={`${styles.icon} ${isDisabled ? styles.disabledIcon : ''}`}
/>
)}
{/* Text - hidden when minimized */}
{!isMinimized && (
<>
{hasSubItems || !item.link ? (
// For items with submenu or navigation nodes (no link)
<span
className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{item.name}
</span>
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''} ${isDisabled ? styles.disabledArrow : ''}`} />
</button>
) : (
// For items without submenu, use normal link
<>
<Link
to={isDisabled ? "#" : (item.link || "#")}
className={`${styles.menuTextLink} ${isDisabled ? styles.disabledLink : ''}`}
onClick={handleLinkClick}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : item.name}
) : (
// For items without submenu and with a link
<span
className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
<span className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}>
{item.name}
</span>
</Link>
</>
{item.name}
</span>
)}
</>
)}
</div>
{/* Expand button - always right-aligned, not indented */}
{!isMinimized && (hasSubItems || !item.link) && (
<button
onClick={toggleSubmenu}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 8px 0 0',
background: 'none',
border: 'none',
cursor: 'pointer',
flexShrink: 0, // Don't shrink
width: 'auto',
minWidth: 'auto',
color: 'inherit'
}}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : `Toggle ${item.name} submenu`}
>
{hasSubItems && (
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''} ${isDisabled ? styles.disabledArrow : ''}`} />
)}
</>
</button>
)}
{/* Link wrapper for items with link and no submenu - spans full width but text is indented */}
{!isMinimized && !hasSubItems && item.link && (
<Link
to={isDisabled ? "#" : (item.link || "#")}
className={`${styles.menuTextLink} ${isDisabled ? styles.disabledLink : ''}`}
onClick={handleLinkClick}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : item.name}
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
paddingLeft: `${indentPx}px`, // Match indentation of text container
pointerEvents: isDisabled ? 'none' : 'auto'
}}
/>
)}
@ -227,18 +433,18 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
</div>
)}
{/* Clickable overlay for items without submenu */}
{isMinimized && !isDisabled && !hasSubItems && (
{/* Clickable overlay for items without submenu and with a link */}
{isMinimized && !isDisabled && !hasSubItems && item.link && (
<Link
to={item.link || "#"}
to={item.link}
className={styles.minimizedOverlay}
title={item.name}
onClick={handleLinkClick}
/>
)}
{/* 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 && (
<button
onClick={toggleSubmenu}
className={styles.minimizedSubmenuToggle}
@ -247,11 +453,12 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
/>
)}
</li>
{hasSubItems && !isDisabled && <SidebarSubmenu item={item} isOpen={isOpen} isMinimized={isMinimized} />}
{/* Recursive submenu rendering */}
{hasSubItems && !isDisabled && renderSubmenuItems()}
</div>
);
});
SidebarItem.displayName = 'SidebarItem';
export default SidebarItem;
export default SidebarItem;

View file

@ -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 {

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<SubmenuItemProps> = ({ subitem, isActive }) => {
const textRef = useRef<HTMLSpanElement>(null);
const containerRef = useRef<HTMLDivElement>(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<React.SVGProps<SVGSVGElement>>;
return (
<li className={isActive ? styles.active : ''}>
<Link
to={subitem.link || '#'}
title={subitem.name}
className={isActive ? styles.activeLink : ''}
>
<div
ref={containerRef}
className={styles.textContainer}
>
<motion.span
ref={textRef}
style={{
display: 'block',
whiteSpace: 'nowrap',
}}
initial={{ x: 0 }}
animate={{ x: 0 }}
{...(isOverflowing && {
whileHover: {
x: -(textRef.current?.scrollWidth || 0) + (containerRef.current?.clientWidth || 153),
transition: {
duration: 2,
ease: "linear"
}
}
})}
>
<div style={{ display: 'flex', alignItems: 'center', paddingRight: '10px' }}>
{SubIcon && <SubIcon className={styles.submenuIcon} />}
<span style={{ marginLeft: SubIcon ? '8px' : '0' }}>
{subitem.name}
</span>
</div>
</motion.span>
</div>
</Link>
</li>
);
};
const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ 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 (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{
height: "auto",
opacity: 1,
transition: {
height: { duration: 0.3, ease: "easeInOut", delay: 0 },
opacity: { duration: 0.25, ease: "easeInOut", delay: 0.3 }
}
}}
exit={{
height: 0,
opacity: 0,
transition: {
opacity: { duration: 0.25, ease: "easeInOut", delay: 0 },
height: { duration: 0.3, ease: "easeInOut", delay: 0.25 }
}
}}
style={{ overflow: "hidden" }}
className={`${styles.submenu} ${isMinimized ? styles.minimized : ''}`}
>
{isMinimized ? (
// Horizontal layout for minimized sidebar
<motion.div
className={styles.submenuHorizontalContainer}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.3, duration: 0.25 } }}
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
>
<ul className={styles.submenuHorizontalList}>
{item.submenu.map(subitem => {
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
const isActive = isSubmenuItemActive(subitem.link);
return (
<li key={subitem.id} className={`${styles.submenuHorizontalItem} ${isActive ? styles.active : ''}`}>
<Link
to={subitem.link || '#'}
title={subitem.name}
className={`${styles.submenuHorizontalLink} ${isActive ? styles.activeLink : ''}`}
>
{SubIcon && (
<SubIcon
className={styles.submenuHorizontalIcon}
style={{
width: '16px',
height: '16px',
color: isActive ? 'white' : '#181818',
display: 'block'
}}
/>
)}
</Link>
</li>
);
})}
</ul>
</motion.div>
) : (
// Vertical layout for expanded sidebar
<motion.div
className={styles.submenuLineContainer}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.3, duration: 0.25 } }}
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
>
<ul className={styles.submenuList}>
{item.submenu.map(subitem => (
<SubmenuItem
key={subitem.id}
subitem={subitem}
isActive={isSubmenuItemActive(subitem.link)}
/>
))}
</ul>
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
);
};
export default SidebarSubmenu;

View file

@ -8,14 +8,17 @@ export interface SidebarItemData {
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
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<React.SVGProps<SVGSVGElement>>;
submenu?: SidebarSubmenuItemData[]; // Recursive support for nested submenus
depth?: number; // Hierarchy depth for indentation (0 = top level)
}
// Sidebar state interface

View file

@ -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<string, {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
defaultOrder?: number;
@ -16,6 +17,14 @@ const parentGroupConfig: Record<string, {
icon: FaHome,
defaultOrder: 1
},
'start.real-estate': {
icon: FaBuilding,
defaultOrder: 1
},
'start.trustee': {
icon: FaBriefcase,
defaultOrder: 2
},
'trustee': {
icon: FaBriefcase,
defaultOrder: 2
@ -60,136 +69,270 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ 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<React.SVGProps<SVGSVGElement>>;
order: number;
page?: GenericPageData; // If this node represents an actual page
children: Map<string, NavigationNode>; // 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<React.SVGProps<SVGSVGElement>> | 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<string, NavigationNode> => {
const rootNodes = new Map<string, NavigationNode>();
// 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<SidebarSubmenuItemData | null> => {
// 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<string, NavigationNode>): Promise<SidebarItem[]> => {
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<SidebarItem[]> => {
const items: SidebarItem[] = [];
// Get all unique parent paths from pages that have subpages
const parentPaths = new Set<string>();
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<string, {
id: string;
name: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
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<SidebarProviderProps> = ({ 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<SidebarProviderProps> = ({ 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<SidebarProviderProps> = ({ 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
});
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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