fix: sidebar is tree instead of two levels only
This commit is contained in:
parent
322ab7b890
commit
3827d8cc07
17 changed files with 1027 additions and 431 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue