Files
leocrm/frontend/src/pages/SettingsMenuOrder.tsx
T
Agent Zero e017da9d12 feat: navigation restructure + drag-and-drop menu ordering
Navigation:
- Remove Plugins header from sidebar
- Merge static items and plugin items into unified sorted list
- Group related menu items (Dateien, E-Mail, Kalender, Automation)
- German labels for all menu items
- Sort by order field, alphabetical fallback for ties

Drag-and-Drop Menu:
- New backend endpoints: GET/PUT /api/v1/users/me/menu-order
- Store menu order in user preferences JSONB field
- New SettingsMenuOrder page with @dnd-kit drag-and-drop
- New Settings tab: Menu ordering
- Sidebar reads saved menu order and sorts accordingly
- Reset to default option
2026-07-24 21:46:27 +02:00

239 lines
7.1 KiB
TypeScript

import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { SortableMenuItem } from '@/components/layout/SortableMenuItem';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Skeleton } from '@/components/ui/Skeleton';
import { useToast } from '@/components/ui/Toast';
import { usePluginStore, PluginMenuItem } from '@/store/pluginStore';
import { useMenuOrder, useUpdateMenuOrder } from '@/api/users';
import { GripVertical, RotateCcw, Save } from 'lucide-react';
interface MenuEntry {
id: string;
label: string;
icon: string;
isGroup?: boolean;
}
const DEFAULT_ORDER = [
'dashboard',
'contacts',
'calendar',
'mail',
'tasks',
'dms',
'automation',
'reports',
'communication',
'ai-assistant',
'settings',
];
export function SettingsMenuOrderPage() {
const { t } = useTranslation();
const toast = useToast();
const manifests = usePluginStore((s) => s.manifests);
const { data: menuOrderData, isLoading: orderLoading } = useMenuOrder();
const updateMutation = useUpdateMenuOrder();
const [items, setItems] = useState<MenuEntry[]>([]);
const pluginMenuItems = useMemo(
() => manifests
.flatMap((m) => m.menu_items)
.sort((a, b) => a.order - b.order),
[manifests]
);
// Build all menu entries from static + plugin items
const allEntries = useMemo(() => {
const entries: MenuEntry[] = [];
// Static entries
entries.push({ id: 'dashboard', label: t('nav.dashboard'), icon: 'Home' });
entries.push({ id: 'contacts', label: t('nav.contacts'), icon: 'Users' });
// Plugin entries
const groups = new Map<string, PluginMenuItem[]>();
const singles: PluginMenuItem[] = [];
for (const item of pluginMenuItems) {
if (item.group) {
if (!groups.has(item.group)) groups.set(item.group, []);
groups.get(item.group)!.push(item);
} else {
singles.push(item);
}
}
for (const item of singles) {
entries.push({
id: item.path,
label: t(item.label_key, item.label),
icon: item.icon,
});
}
for (const [group, groupItems] of groups) {
entries.push({
id: group,
label: group,
icon: groupItems[0].icon,
isGroup: true,
});
}
// Settings at the end
entries.push({ id: 'settings', label: t('nav.settings'), icon: 'Settings' });
return entries;
}, [pluginMenuItems, t]);
// Apply saved order or default
useEffect(() => {
if (orderLoading) return;
const savedOrder = menuOrderData?.menu_order;
if (savedOrder && savedOrder.length > 0) {
// Sort entries by saved order, keeping any new entries at the end
const orderMap = new Map(savedOrder.map((id, idx) => [id, idx]));
const sorted = [...allEntries].sort((a, b) => {
const aIdx = orderMap.get(a.id);
const bIdx = orderMap.get(b.id);
if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
if (aIdx !== undefined) return -1;
if (bIdx !== undefined) return 1;
return 0;
});
setItems(sorted);
} else {
// Use default order
const orderMap = new Map(DEFAULT_ORDER.map((id, idx) => [id, idx]));
const sorted = [...allEntries].sort((a, b) => {
const aIdx = orderMap.get(a.id);
const bIdx = orderMap.get(b.id);
if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
if (aIdx !== undefined) return -1;
if (bIdx !== undefined) return 1;
return 0;
});
setItems(sorted);
}
}, [allEntries, menuOrderData, orderLoading]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setItems((prev) => {
const oldIndex = prev.findIndex((item) => item.id === active.id);
const newIndex = prev.findIndex((item) => item.id === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
}, []);
const handleSave = async () => {
try {
const order = items.map((item) => item.id);
await updateMutation.mutateAsync(order);
toast.success(t('settings.saved', 'Gespeichert'));
} catch (err: any) {
toast.error(err.message || t('settings.saveFailed', 'Speichern fehlgeschlagen'));
}
};
const handleReset = () => {
const orderMap = new Map(DEFAULT_ORDER.map((id, idx) => [id, idx]));
const sorted = [...allEntries].sort((a, b) => {
const aIdx = orderMap.get(a.id);
const bIdx = orderMap.get(b.id);
if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
if (aIdx !== undefined) return -1;
if (bIdx !== undefined) return 1;
return 0;
});
setItems(sorted);
};
if (orderLoading) {
return (
<div className="space-y-4" data-testid="settings-menu-order-page">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-96" />
</div>
);
}
return (
<div className="space-y-6" data-testid="settings-menu-order-page">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-secondary-900">
{t('settings.menuOrder', 'Menü-Reihenfolge')}
</h1>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleReset}>
<RotateCcw className="w-4 h-4 mr-2" />
{t('settings.reset', 'Zurücksetzen')}
</Button>
<Button onClick={handleSave} disabled={updateMutation.isPending}>
<Save className="w-4 h-4 mr-2" />
{t('settings.save', 'Speichern')}
</Button>
</div>
</div>
<Card>
<p className="text-sm text-secondary-500 mb-4 px-4 pt-4">
{t('settings.menuOrderHint', 'Ziehen Sie die Menü-Einträge per Drag & Drop in die gewünschte Reihenfolge.')}
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
<div className="px-4 pb-4 space-y-1">
{items.map((item) => (
<SortableMenuItem
key={item.id}
id={item.id}
label={item.label}
icon={item.icon}
isGroup={item.isGroup}
/>
))}
</div>
</SortableContext>
</DndContext>
</Card>
</div>
);
}