/** * 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 { usePrompt } from '../../hooks/usePrompt'; import { useToast } from '../../contexts/ToastContext'; import api from '../../api'; import { useLanguage } from '../../providers/language/LanguageContext'; import styles from './MandateNavigation.module.css'; type NavTranslateFn = (key: string, params?: Record) => string; // ============================================================================= // HELPER FUNCTIONS - Convert API blocks to TreeItems // ============================================================================= /** * Convert a NavigationItem (from static block) to TreeNodeItem. * Labels are resolved server-side (resolveText) for the request language. */ 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(i => _navigationItemToTreeNode(i)), defaultExpanded, }; } /** * Convert a FeatureView to TreeNodeItem. * View labels are resolved server-side (resolveText) for the request language. */ 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) | undefined, t: NavTranslateFn, ): TreeNodeItem { const children = instance.views.map(v => _featureViewToTreeNode(v)); 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) | undefined, t: NavTranslateFn, ): 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, t)); } } 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) | undefined, t: NavTranslateFn, ): TreeNodeItem[] { return block.mandates .map((m) => _navigationMandateToTreeNode(m, onRename, t)) .filter((node): node is TreeNodeItem => node !== null); } // ============================================================================= // LOADING STATE // ============================================================================= const LoadingState: React.FC = () => { const { t } = useLanguage(); return (
{t('Navigation wird geladen…')}
); }; // ============================================================================= // EMPTY STATE // ============================================================================= const EmptyState: React.FC = () => { const { t } = useLanguage(); return (

{t('Keine Feature-Instanzen verfügbar.')}

{t('Kontaktiere einen Administrator, um Zugriff zu erhalten.')}

); }; // ============================================================================= // MAIN COMPONENT // ============================================================================= export const MandateNavigation: React.FC = () => { const { t } = useLanguage(); const { blocks, loading, refresh } = useNavigation(); const { prompt, PromptDialog } = usePrompt(); const { showWarning } = useToast(); const _handleRename = useCallback(async (instanceId: string, currentLabel: string) => { const newLabel = await prompt(t('Neuer Name:'), { title: t('Umbenennen'), defaultValue: currentLabel }); if (!newLabel || newLabel.trim() === currentLabel) return; try { await api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() }); refresh(); } catch (err: any) { showWarning(t('Fehler'), t('Umbenennung fehlgeschlagen: {detail}', { detail: String(err?.response?.data?.detail || err.message) })); } }, [refresh, prompt, showWarning, t]); const navigationItems: TreeItem[] = useMemo(() => { const items: TreeItem[] = []; let systemBlock: { title: string; items: NavigationItem[]; subgroups?: NavSubgroup[] } | null = null; let adminBlock: { title: string; items: NavigationItem[]; subgroups: NavSubgroup[] } | null = null; for (const block of blocks) { if (block.type === 'static') { if (block.id === 'admin') { adminBlock = { title: block.title, items: [...block.items], subgroups: block.subgroups || [] }; } else if (block.id === 'system') { systemBlock = { title: block.title, items: block.items || [], subgroups: block.subgroups }; } else if (block.items.length > 0) { if (!systemBlock) systemBlock = { title: block.title, items: [], subgroups: [] }; systemBlock.items.push(...block.items); } } } if (systemBlock) { const children: TreeNodeItem[] = []; for (const item of systemBlock.items) { children.push(_navigationItemToTreeNode(item)); } if (systemBlock.subgroups && systemBlock.subgroups.length > 0) { for (const sg of systemBlock.subgroups) { children.push({ id: sg.id, label: sg.title, children: sg.items.map(i => _navigationItemToTreeNode(i)), defaultExpanded: true, }); } } if (children.length > 0) { items.push({ id: 'meine-sicht', label: systemBlock.title, children, defaultExpanded: true, }); } } for (const block of blocks) { if (block.type === 'dynamic') { const mandateNodes = _dynamicBlockToTreeNodes(block, _handleRename, t); if (mandateNodes.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); items.push(...mandateNodes); } } } if (adminBlock && adminBlock.subgroups.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); const subgroupNodes: TreeNodeItem[] = adminBlock.subgroups.map(sg => ({ id: sg.id, label: sg.title, children: sg.items.map(i => _navigationItemToTreeNode(i)), defaultExpanded: false, })); items.push({ id: 'administration', label: adminBlock.title, children: subgroupNodes, defaultExpanded: false, }); } else if (adminBlock && adminBlock.items.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); items.push(_staticItemsToTreeNode('administration', adminBlock.title, adminBlock.items, false)); } return items; }, [blocks, _handleRename, t]); // 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;