frontend_nyla/src/components/Navigation/MandateNavigation.tsx
2026-03-28 16:58:55 +01:00

287 lines
8.6 KiB
TypeScript

/**
* 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 ? (
<button
className={styles.renameButton}
title="Umbenennen"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRename(instance.id, instance.uiLabel); }}
>
<FaPen size={10} />
</button>
) : 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 = () => (
<div className={styles.loadingState}>
<FaSpinner className={styles.spinner} />
<span>Navigation wird geladen...</span>
</div>
);
// =============================================================================
// EMPTY STATE
// =============================================================================
const EmptyState: React.FC = () => (
<div className={styles.emptyState}>
<p>Keine Feature-Instanzen verfügbar.</p>
<p className={styles.emptyHint}>
Kontaktiere einen Administrator, um Zugriff zu erhalten.
</p>
</div>
);
// =============================================================================
// 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 (
<div className={styles.navigation}>
<LoadingState />
</div>
);
}
return (
<div className={styles.navigation}>
{hasNavigation ? (
<TreeNavigation
items={navigationItems}
autoExpandActive={true}
/>
) : (
<EmptyState />
)}
</div>
);
};
export default MandateNavigation;