/** * MandateNavigation * * Hierarchische Navigation für das Multi-Tenant-System. * Verwendet TreeNavigation für flexible Baumstruktur. * * Navigation wird vollständig vom Backend geladen (/api/navigation). * Backend liefert Blocks-Struktur mit Static und Dynamic Blocks. * UI mappt uiComponent zu Icons via pageRegistry. * * 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) * ───────────── * ▶ Administration * - Users, Mandates, Roles, ... */ import React, { useMemo, useCallback } from 'react'; import { useNavigation } from '../../hooks/useNavigation'; import type { DynamicBlock, NavigationItem, NavSubgroup, NavigationMandate, FeatureInstance, FeatureView } from '../../hooks/useNavigation'; import { getPageIcon } from '../../config/pageRegistry'; import { FaSpinner, FaPen } from 'react-icons/fa'; import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation'; import api from '../../api'; import styles from './MandateNavigation.module.css'; // ============================================================================= // HELPER FUNCTIONS - Convert API blocks to TreeItems // ============================================================================= /** * Convert a NavigationItem (from static block) to TreeNodeItem */ function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem { return { id: item.objectKey, label: item.uiLabel, icon: getPageIcon(item.uiComponent), path: item.uiPath, }; } /** * Convert a list of NavigationItems into a collapsible TreeNodeItem container. * Used for grouping static items under "Meine Sicht" and "Administration". */ function _staticItemsToTreeNode( id: string, label: string, items: NavigationItem[], defaultExpanded: boolean = true, ): TreeNodeItem { return { id, label, children: items.map(navigationItemToTreeNode), defaultExpanded, }; } /** * Convert a FeatureView to TreeNodeItem */ function featureViewToTreeNode(view: FeatureView): TreeNodeItem { return { id: view.objectKey, label: view.uiLabel, path: view.uiPath, }; } /** * Convert a FeatureInstance to TreeNodeItem (with feature icon) * Instance node gets path to first view so clicking the instance name navigates to dashboard. * Shows the feature icon next to the instance name for visual distinction. * If user is instance admin, a rename icon appears on hover. */ function featureInstanceToTreeNode( instance: FeatureInstance, featureUiComponent: string, onRename?: (instanceId: string, currentLabel: string) => void, ): TreeNodeItem { const children = instance.views.map(featureViewToTreeNode); const renameAction = instance.isAdmin && onRename ? ( ) : undefined; return { id: instance.id, label: instance.uiLabel, icon: getPageIcon(featureUiComponent), path: instance.views.length > 0 ? instance.views[0].uiPath : undefined, children, defaultExpanded: false, actions: renameAction, }; } /** * Convert a NavigationMandate to TreeNodeItem * * FLAT STRUCTURE: Instances are listed directly under mandate (no feature grouping). * Each instance shows the feature's icon for visual distinction. * * Before: Mandate → Feature → Instance → Views * Now: Mandate → Instance (with feature icon) → Views */ function navigationMandateToTreeNode( mandate: NavigationMandate, onRename?: (instanceId: string, currentLabel: string) => void, ): TreeNodeItem | null { if (mandate.features.length === 0) { return null; } const instanceNodes: TreeNodeItem[] = []; for (const feature of mandate.features) { for (const instance of feature.instances) { instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent, onRename)); } } if (instanceNodes.length === 0) { return null; } return { id: mandate.id, label: mandate.uiLabel, children: instanceNodes, defaultExpanded: true, }; } /** * Convert a DynamicBlock to array of TreeNodeItems (mandate nodes) */ function dynamicBlockToTreeNodes( block: DynamicBlock, onRename?: (instanceId: string, currentLabel: string) => void, ): TreeNodeItem[] { return block.mandates .map((m) => navigationMandateToTreeNode(m, onRename)) .filter((node): node is TreeNodeItem => node !== null); } // ============================================================================= // LOADING STATE // ============================================================================= const LoadingState: React.FC = () => (
Navigation wird geladen...
); // ============================================================================= // EMPTY STATE // ============================================================================= const EmptyState: React.FC = () => (

Keine Feature-Instanzen verfügbar.

Kontaktiere einen Administrator, um Zugriff zu erhalten.

); // ============================================================================= // MAIN COMPONENT // ============================================================================= export const MandateNavigation: React.FC = () => { const { blocks, loading, refresh } = useNavigation('de'); const _handleRename = useCallback((instanceId: string, currentLabel: string) => { const newLabel = window.prompt('Neuer Name:', currentLabel); if (!newLabel || newLabel.trim() === currentLabel) return; api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() }) .then(() => refresh()) .catch((err: any) => alert('Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message))); }, [refresh]); const navigationItems: TreeItem[] = useMemo(() => { const items: TreeItem[] = []; const meineSichtItems: NavigationItem[] = []; let adminItems: NavigationItem[] = []; let adminSubgroups: NavSubgroup[] = []; for (const block of blocks) { if (block.type === 'static') { if (block.id === 'admin') { if (block.subgroups && block.subgroups.length > 0) { adminSubgroups = block.subgroups; } else { adminItems = [...block.items]; } } else if (block.items.length > 0) { meineSichtItems.push(...block.items); } } } if (meineSichtItems.length > 0) { items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true)); } for (const block of blocks) { if (block.type === 'dynamic') { const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename); if (mandateNodes.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); items.push(...mandateNodes); } } } if (adminSubgroups.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({ id: sg.id, label: sg.title, children: sg.items.map(navigationItemToTreeNode), defaultExpanded: false, })); items.push({ id: 'administration', label: 'Administration', children: subgroupNodes, defaultExpanded: false, }); } else if (adminItems.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false)); } return items; }, [blocks, _handleRename]); // Check if user has any navigation (static or dynamic) const hasNavigation = blocks.length > 0; // Show loading state while navigation is being fetched if (loading) { return (
); } return (
{hasNavigation ? ( ) : ( )}
); }; export default MandateNavigation;