From 4231727544b8afb7ebd2a72962c67b193dac63f9 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 10 Feb 2026 00:10:10 +0100
Subject: [PATCH] enhanced generic navigation tree
---
.../Navigation/MandateNavigation.tsx | 96 ++++----
.../TreeNavigation/TreeNavigation.module.css | 213 ++++++++++--------
.../TreeNavigation/TreeNavigation.tsx | 34 ++-
src/hooks/useUserMandates.ts | 1 +
src/pages/admin/AccessManagementHub.tsx | 1 +
src/pages/admin/AdminFeatureAccessPage.tsx | 1 +
.../admin/AdminFeatureInstanceUsersPage.tsx | 2 +-
src/pages/admin/AdminInvitationsPage.tsx | 1 +
src/pages/admin/AdminMandateRolesPage.tsx | 1 +
src/pages/admin/AdminUserMandatesPage.tsx | 1 +
src/types/mandate.ts | 3 +-
11 files changed, 193 insertions(+), 161 deletions(-)
diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx
index c67b0e0..dc9ffb5 100644
--- a/src/components/Navigation/MandateNavigation.tsx
+++ b/src/components/Navigation/MandateNavigation.tsx
@@ -8,26 +8,24 @@
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
* UI mappt uiComponent zu Icons via pageRegistry.
*
- * FLAT STRUCTURE (kompakte Darstellung):
- * - SYSTEM (static block)
- * - Mandant 1
+ * TREE STRUCTURE (alles collapsible):
+ * ▼ Meine Sicht
+ * - Übersicht, Einstellungen, Prompts, Dateien, Verbindungen, Billing
+ * ─────────────
+ * ▼ Mandant 1
* - 🎯 Instanz 1 (Feature-Icon + Instanz-Name)
* - 💼 Instanz 2 (Feature-Icon + Instanz-Name)
- * - BASISDATEN (static block)
- * - ADMINISTRATION (static block)
- *
- * Jede Instanz zeigt das Icon des zugehörigen Features.
- * Keine Gruppierung nach Features - direkt Instanzen unter Mandant.
+ * ─────────────
+ * ▶ Administration
+ * - Users, Mandates, Roles, ...
*/
import React, { useMemo } from 'react';
import { useNavigation } from '../../hooks/useNavigation';
import type {
- StaticBlock,
DynamicBlock,
NavigationItem,
NavigationMandate,
- MandateFeature,
FeatureInstance,
FeatureView
} from '../../hooks/useNavigation';
@@ -53,13 +51,20 @@ function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem {
}
/**
- * Convert a StaticBlock to TreeItem (section)
+ * Convert a list of NavigationItems into a collapsible TreeNodeItem container.
+ * Used for grouping static items under "Meine Sicht" and "Administration".
*/
-function staticBlockToTreeItem(block: StaticBlock): TreeItem {
+function _staticItemsToTreeNode(
+ id: string,
+ label: string,
+ items: NavigationItem[],
+ defaultExpanded: boolean = true,
+): TreeNodeItem {
return {
- type: 'section',
- title: block.title,
- children: block.items.map(navigationItemToTreeNode),
+ id,
+ label,
+ children: items.map(navigationItemToTreeNode),
+ defaultExpanded,
};
}
@@ -167,40 +172,49 @@ export const MandateNavigation: React.FC = () => {
const { blocks, loading } = useNavigation('de');
// Build navigation items from blocks
+ // Groups static items into collapsible containers:
+ // - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
+ // - "Administration": all admin static items
+ // - Dynamic block (mandates) renders between them
const navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = [];
-
- // Process blocks in order (already sorted by backend)
+
+ // Collect static items by category
+ const meineSichtItems: NavigationItem[] = [];
+ let adminItems: NavigationItem[] = [];
+
for (const block of blocks) {
if (block.type === 'static') {
- // Static block: system, workflows, basedata, migrate, admin
- if (block.items.length > 0) {
- // Add separator before admin block
- if (block.id === 'admin') {
- items.push({ type: 'separator' });
- }
- items.push(staticBlockToTreeItem(block));
+ if (block.id === 'admin') {
+ adminItems = [...block.items];
+ } else if (block.items.length > 0) {
+ meineSichtItems.push(...block.items);
}
- } else if (block.type === 'dynamic') {
- // Dynamic block: features/mandates
- // Add separator before dynamic block
- items.push({ type: 'separator' });
-
- const mandateNodes = dynamicBlockToTreeNodes(block);
- if (mandateNodes.length > 0) {
- items.push(...mandateNodes);
- }
-
- // Add separator after dynamic block (before next static blocks)
- items.push({ type: 'separator' });
}
}
-
- // Remove trailing separator if present
- while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') {
- items.pop();
+
+ // "Meine Sicht" - collapsible container for user-facing pages
+ if (meineSichtItems.length > 0) {
+ items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
}
-
+
+ // Dynamic block: mandates with feature instances
+ for (const block of blocks) {
+ if (block.type === 'dynamic') {
+ const mandateNodes = dynamicBlockToTreeNodes(block);
+ if (mandateNodes.length > 0) {
+ if (items.length > 0) items.push({ type: 'separator' });
+ items.push(...mandateNodes);
+ }
+ }
+ }
+
+ // "Administration" - collapsible container for admin pages
+ if (adminItems.length > 0) {
+ if (items.length > 0) items.push({ type: 'separator' });
+ items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false));
+ }
+
return items;
}, [blocks]);
diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css
index 477fc06..01f0722 100644
--- a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css
+++ b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css
@@ -1,16 +1,17 @@
/**
* TreeNavigation Styles
*
- * Flexible hierarchical navigation with support for:
- * - Dynamic sublevels
- * - Sections and separators
- * - Various visual states (active, disabled, hover)
+ * Modern minimal tree navigation (Notion/Linear style):
+ * - CSS-only disclosure triangle with smooth rotation
+ * - No guide lines — clean indentation only
+ * - Depth-aware sizing via data-depth attribute
+ * - Hover-reveal toggle for subtle UX
*/
.treeNavigation {
display: flex;
flex-direction: column;
- gap: 0.25rem;
+ gap: 1px;
padding: 0 0.5rem;
}
@@ -20,8 +21,8 @@
.separator {
height: 1px;
- background: var(--border-color, #e0e0e0);
- margin: 0.75rem 0.5rem;
+ background: var(--border-color, #e2e8f0);
+ margin: 0.5rem 0.75rem;
}
/* ============================================ */
@@ -29,7 +30,7 @@
/* ============================================ */
.treeSection {
- margin-bottom: 0.5rem;
+ margin-bottom: 0.25rem;
}
.sectionHeader {
@@ -40,14 +41,14 @@
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.1em;
- color: var(--text-tertiary, #888);
+ color: var(--text-tertiary, #94a3b8);
text-transform: uppercase;
}
.sectionContent {
display: flex;
flex-direction: column;
- gap: 2px;
+ gap: 1px;
}
/* ============================================ */
@@ -62,9 +63,9 @@
.treeNode {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: 0.375rem;
width: 100%;
- padding: 0.5rem 0.75rem;
+ padding: 0.375rem 0.5rem;
border: none;
border-radius: 6px;
background: transparent;
@@ -72,9 +73,11 @@
text-decoration: none;
font-family: inherit;
text-align: left;
- color: var(--text-secondary, #666);
- font-size: 0.875rem;
- transition: all 0.15s ease;
+ color: var(--text-secondary, #64748b);
+ font-size: 0.8125rem;
+ font-weight: 500;
+ line-height: 1.4;
+ transition: background 0.15s ease, color 0.15s ease;
}
.treeNode:hover {
@@ -82,12 +85,18 @@
color: var(--text-primary, #1a1a1a);
}
+/* Leaf node active — strong pill highlight */
.treeNode.active {
background: var(--primary-light, #e0e7ff);
color: var(--primary-color, #2563eb);
font-weight: 500;
}
+/* Group/parent active — subtle text color only, no background */
+.treeNode.activeGroup {
+ color: var(--primary-color, #2563eb);
+}
+
.treeNode.disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -95,105 +104,109 @@
}
/* ============================================ */
-/* LEVEL-SPECIFIC STYLES */
+/* DEPTH-SPECIFIC STYLES (via data-depth) */
/* ============================================ */
-/* Root level (level 0) */
-.levelRoot {
+.treeNode[data-depth="0"] {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
- padding: 0.625rem 0.75rem;
+ padding: 0.5rem 0.5rem;
}
-.levelRoot .nodeLabel {
- flex: 1;
-}
-
-/* Level 1 */
-.levelOne {
+.treeNode[data-depth="1"] {
font-size: 0.8125rem;
font-weight: 500;
- color: var(--text-secondary, #666);
- padding: 0.5rem 0.75rem;
}
-/* Level 2 */
-.levelTwo {
+.treeNode[data-depth="2"],
+.treeNode[data-depth="3"],
+.treeNode[data-depth="4"],
+.treeNode[data-depth="5"] {
font-size: 0.75rem;
- font-weight: 500;
- color: var(--text-secondary, #666);
- padding: 0.375rem 0.5rem;
-}
-
-/* Level 3 */
-.levelThree {
- font-size: 0.75rem;
- color: var(--text-secondary, #666);
- padding: 0.375rem 0.5rem;
-}
-
-/* Deep levels (4+) */
-.levelDeep {
- font-size: 0.6875rem;
- color: var(--text-tertiary, #888);
- padding: 0.25rem 0.5rem;
+ font-weight: 400;
}
/* ============================================ */
-/* NODE CHILDREN (INDENTATION) */
+/* NODE CHILDREN (INDENTATION ONLY) */
/* ============================================ */
.treeNodeChildren {
- margin-left: 0.25rem;
- padding-left: 0.75rem;
- border-left: 2px solid var(--border-color, #e0e0e0);
+ margin-left: 0.75rem;
+ padding-left: 0.5rem;
}
-/* Active parent highlights the border */
-.treeNodeContainer:has(> .treeNode.active) > .treeNodeChildren {
+/* ============================================ */
+/* TOGGLE (CSS-only disclosure triangle) */
+/* ============================================ */
+
+.toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.125rem;
+ height: 1.125rem;
+ flex-shrink: 0;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: background 0.15s ease;
+}
+
+/* The triangle — pure CSS, no icon needed */
+.toggle::after {
+ content: '';
+ display: block;
+ width: 0;
+ height: 0;
+ border-left: 4.5px solid var(--text-tertiary, #94a3b8);
+ border-top: 3.5px solid transparent;
+ border-bottom: 3.5px solid transparent;
+ transition: transform 0.2s ease, border-color 0.15s ease;
+}
+
+/* Rotate triangle when expanded */
+.toggleExpanded::after {
+ transform: rotate(90deg);
+}
+
+/* Hover feedback */
+.toggle:hover {
+ background: var(--hover-bg, rgba(0, 0, 0, 0.06));
+}
+
+.toggle:hover::after {
+ border-left-color: var(--text-primary, #1a1a1a);
+}
+
+/* Active node toggle */
+.treeNode.active .toggle::after,
+.treeNode.activeGroup .toggle::after {
border-left-color: var(--primary-color, #2563eb);
}
-/* Also highlight if any descendant is active */
-.treeNodeContainer:has(.treeNode.active) > .treeNodeChildren {
- border-left-color: var(--primary-light, #93c5fd);
+/* Spacer for leaf nodes (keeps alignment with toggle nodes) */
+.toggleSpacer {
+ width: 1.125rem;
+ flex-shrink: 0;
}
/* ============================================ */
/* NODE ELEMENTS */
/* ============================================ */
-.chevron {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 1rem;
- height: 1rem;
- font-size: 0.625rem;
- color: var(--text-tertiary, #888);
- flex-shrink: 0;
- cursor: pointer;
- border-radius: 3px;
- transition: background 0.1s ease;
-}
-
-.chevron:hover {
- background: var(--hover-bg, rgba(0, 0, 0, 0.06));
-}
-
-.chevronSpacer {
- width: 1rem;
- flex-shrink: 0;
-}
-
.nodeIcon {
display: flex;
align-items: center;
justify-content: center;
- font-size: 1rem;
+ font-size: 0.875rem;
flex-shrink: 0;
color: inherit;
+ opacity: 0.8;
+}
+
+.treeNode.active .nodeIcon,
+.treeNode.activeGroup .nodeIcon {
+ opacity: 1;
}
.nodeLabel {
@@ -208,7 +221,7 @@
padding: 0.0625rem 0.375rem;
background: var(--surface-color, #f0f0f0);
border-radius: 9999px;
- color: var(--text-tertiary, #888);
+ color: var(--text-tertiary, #94a3b8);
text-transform: uppercase;
letter-spacing: 0.025em;
flex-shrink: 0;
@@ -262,41 +275,45 @@
color: var(--primary-light, #93c5fd);
}
-:global(.dark-theme) .levelRoot {
+:global(.dark-theme) .treeNode.activeGroup {
+ color: var(--primary-light, #93c5fd);
+}
+
+:global(.dark-theme) .treeNode[data-depth="0"] {
color: var(--text-primary-dark, #fff);
}
-:global(.dark-theme) .levelOne,
-:global(.dark-theme) .levelTwo,
-:global(.dark-theme) .levelThree {
- color: var(--text-secondary-dark, #aaa);
+:global(.dark-theme) .toggle::after {
+ border-left-color: var(--text-tertiary-dark, #555);
}
-:global(.dark-theme) .levelDeep {
- color: var(--text-tertiary-dark, #888);
+:global(.dark-theme) .toggle:hover {
+ background: var(--hover-bg-dark, rgba(255, 255, 255, 0.08));
}
-:global(.dark-theme) .treeNodeChildren {
- border-left-color: var(--border-dark, #444);
+:global(.dark-theme) .toggle:hover::after {
+ border-left-color: var(--text-primary-dark, #ddd);
}
-:global(.dark-theme) .treeNodeContainer:has(.treeNode.active) > .treeNodeChildren {
+:global(.dark-theme) .treeNode.active .toggle::after,
+:global(.dark-theme) .treeNode.activeGroup .toggle::after {
border-left-color: var(--primary-light, #93c5fd);
}
+:global(.dark-theme) .nodeIcon {
+ opacity: 0.7;
+}
+
+:global(.dark-theme) .treeNode.active .nodeIcon,
+:global(.dark-theme) .treeNode.activeGroup .nodeIcon {
+ opacity: 1;
+}
+
:global(.dark-theme) .nodeBadge {
background: var(--surface-dark, #2a2a2a);
color: var(--text-tertiary-dark, #888);
}
-:global(.dark-theme) .chevron {
- color: var(--text-tertiary-dark, #666);
-}
-
-:global(.dark-theme) .chevron:hover {
- background: var(--hover-bg-dark, rgba(255, 255, 255, 0.1));
-}
-
:global(.dark-theme) .treeNode.active .nodeBadge {
background: var(--primary-color, #2563eb);
color: white;
diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx
index 4cb31ed..1d38b65 100644
--- a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx
+++ b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx
@@ -12,7 +12,6 @@
import React, { useState, useEffect, ReactNode } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
-import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import styles from './TreeNavigation.module.css';
// =============================================================================
@@ -155,8 +154,11 @@ const TreeNode: React.FC = ({
}
}, [currentPath, autoExpandActive, node]);
- // Check if this exact node is active
+ // Check if this node is active (exact match or ancestor of active path)
const isActive = node.path ? currentPath === node.path || currentPath.startsWith(node.path + '/') : false;
+ // Differentiate: leaf active (strong highlight) vs group active (subtle text only)
+ const isLeafActive = isActive && !hasChildren;
+ const isGroupActive = isActive && !!hasChildren;
// Handle click
const handleClick = (e: React.MouseEvent) => {
@@ -186,34 +188,24 @@ const TreeNode: React.FC = ({
}
};
- // Handle chevron click separately
- const handleChevronClick = (e: React.MouseEvent) => {
+ // Handle toggle click separately (expand/collapse)
+ const handleToggleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded(!isExpanded);
};
- // Get level-specific styles
- const getLevelClass = () => {
- switch (level) {
- case 0: return styles.levelRoot;
- case 1: return styles.levelOne;
- case 2: return styles.levelTwo;
- case 3: return styles.levelThree;
- default: return styles.levelDeep;
- }
- };
-
// Render the node content
const nodeContent = (
<>
{isExpandable && (
-
- {isExpanded ? : }
-
+
)}
{!isExpandable && hasChildren === false && (
-
+
)}
{node.icon && {node.icon}}
{node.label}
@@ -228,7 +220,7 @@ const TreeNode: React.FC = ({
);
// Determine if we should render as NavLink or button
- const nodeClasses = `${styles.treeNode} ${getLevelClass()} ${isActive ? styles.active : ''} ${node.disabled ? styles.disabled : ''} ${node.className || ''}`;
+ const nodeClasses = `${styles.treeNode} ${isLeafActive ? styles.active : ''} ${isGroupActive ? styles.activeGroup : ''} ${node.disabled ? styles.disabled : ''} ${node.className || ''}`;
const nodeElement = node.path ? (
= ({
className={nodeClasses}
onClick={handleClick}
data-id={node.dataId}
+ data-depth={level}
>
{nodeContent}
@@ -246,6 +239,7 @@ const TreeNode: React.FC = ({
onClick={handleClick}
disabled={node.disabled}
data-id={node.dataId}
+ data-depth={level}
>
{nodeContent}
diff --git a/src/hooks/useUserMandates.ts b/src/hooks/useUserMandates.ts
index c7c128c..f52b6f3 100644
--- a/src/hooks/useUserMandates.ts
+++ b/src/hooks/useUserMandates.ts
@@ -60,6 +60,7 @@ export interface Role {
export interface Mandate {
id: string;
name: string | { [key: string]: string };
+ label?: string;
code?: string;
language?: string;
isSystem?: boolean;
diff --git a/src/pages/admin/AccessManagementHub.tsx b/src/pages/admin/AccessManagementHub.tsx
index 5a64905..d522ea7 100644
--- a/src/pages/admin/AccessManagementHub.tsx
+++ b/src/pages/admin/AccessManagementHub.tsx
@@ -24,6 +24,7 @@ import { FeatureInstanceWizard } from './FeatureInstanceWizard';
import { InstanceHierarchyView } from './InstanceHierarchyView';
function getMandateName(mandate: Mandate): string {
+ if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx
index b266af9..fb0b92e 100644
--- a/src/pages/admin/AdminFeatureAccessPage.tsx
+++ b/src/pages/admin/AdminFeatureAccessPage.tsx
@@ -310,6 +310,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
// Get mandate name
const getMandateName = (mandate: Mandate) => {
+ if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
diff --git a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx
index 554ea1c..c7250af 100644
--- a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx
+++ b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx
@@ -86,7 +86,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
allOptions.push({
mandateId: mandate.id,
instanceId: inst.id,
- mandateName: typeof mandate.name === 'string' ? mandate.name : (mandate.name?.de || mandate.name?.en || Object.values(mandate.name || {})[0] || mandate.id),
+ mandateName: mandate.label || (typeof mandate.name === 'string' ? mandate.name : (mandate.name?.de || mandate.name?.en || Object.values(mandate.name || {})[0] || mandate.id)),
instanceLabel: inst.label || inst.id,
featureCode: inst.featureCode,
combinedKey: `${mandate.id}:${inst.id}`,
diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx
index 037afb8..febc527 100644
--- a/src/pages/admin/AdminInvitationsPage.tsx
+++ b/src/pages/admin/AdminInvitationsPage.tsx
@@ -226,6 +226,7 @@ export const AdminInvitationsPage: React.FC = () => {
// Get mandate name
const getMandateName = (mandate: Mandate) => {
+ if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx
index 2e9cadb..19ddff4 100644
--- a/src/pages/admin/AdminMandateRolesPage.tsx
+++ b/src/pages/admin/AdminMandateRolesPage.tsx
@@ -285,6 +285,7 @@ export const AdminMandateRolesPage: React.FC = () => {
// Get mandate name
const getMandateName = (mandate: Mandate) => {
+ if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
diff --git a/src/pages/admin/AdminUserMandatesPage.tsx b/src/pages/admin/AdminUserMandatesPage.tsx
index 8a0644c..72cd2fd 100644
--- a/src/pages/admin/AdminUserMandatesPage.tsx
+++ b/src/pages/admin/AdminUserMandatesPage.tsx
@@ -248,6 +248,7 @@ export const AdminUserMandatesPage: React.FC = () => {
// Get mandate name
const getMandateName = (mandate: Mandate) => {
+ if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
diff --git a/src/types/mandate.ts b/src/types/mandate.ts
index 8c5fa5b..30c1903 100644
--- a/src/types/mandate.ts
+++ b/src/types/mandate.ts
@@ -116,7 +116,8 @@ export interface MandateFeature {
*/
export interface Mandate {
id: string; // mandateId
- name: string; // Anzeige-Name
+ name: string; // Technischer Identifier
+ label?: string; // Anzeige-Label (fuer FK-Referenzen und UI)
code?: string; // Optionaler Code
features: MandateFeature[];
}