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[]; }