241 lines
7.2 KiB
TypeScript
241 lines
7.2 KiB
TypeScript
import { asError } from '@/utils/errorTypes';
|
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
// TODO: P2-F23 — Replace hardcoded DEFAULT_ORDER with backend config
|
|
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: unknown) { const errObj = asError(err);
|
|
toast.error(errObj.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>
|
|
);
|
|
}
|