329 lines
10 KiB
TypeScript
329 lines
10 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 { 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, string | number>) => string;
|
|
|
|
// =============================================================================
|
|
// HELPER FUNCTIONS - Convert API blocks to TreeItems
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Convert a NavigationItem (from static block) to TreeNodeItem.
|
|
* Labels are already translated by the backend via t().
|
|
*/
|
|
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 already translated by the backend.
|
|
*/
|
|
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 ? (
|
|
<button
|
|
className={styles.renameButton}
|
|
title={t('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) | 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 (
|
|
<div className={styles.loadingState}>
|
|
<FaSpinner className={styles.spinner} />
|
|
<span>{t('Navigation wird geladen…')}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// EMPTY STATE
|
|
// =============================================================================
|
|
|
|
const EmptyState: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
return (
|
|
<div className={styles.emptyState}>
|
|
<p>{t('Keine Feature-Instanzen verfügbar.')}</p>
|
|
<p className={styles.emptyHint}>
|
|
{t('Kontaktiere einen Administrator, um Zugriff zu erhalten.')}
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// 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 (
|
|
<div className={styles.navigation}>
|
|
<LoadingState />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.navigation}>
|
|
{hasNavigation ? (
|
|
<TreeNavigation
|
|
items={navigationItems}
|
|
autoExpandActive={true}
|
|
/>
|
|
) : (
|
|
<EmptyState />
|
|
)}
|
|
<PromptDialog />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MandateNavigation;
|