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
This commit is contained in:
Agent Zero
2026-07-24 21:46:27 +02:00
parent 4b9cb5a098
commit e017da9d12
17 changed files with 608 additions and 142 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ class AIAssistantPlugin(BasePlugin):
],
is_core=True,
menu_items=[
FrontendMenuItem(label_key='nav.aiAssistant', label='AI Assistant', path='/ai-assistant', icon='Bot', order=90),
FrontendMenuItem(label_key='nav.aiAssistant', label='KI Assistent', path='/ai-assistant', icon='Bot', order=90),
],
page_routes=[
FrontendPageRoute(path='/ai-assistant', component='@/pages/AIAssistant', protected=True),
+3 -1
View File
@@ -75,13 +75,15 @@ class AutomationPlugin(BasePlugin):
label="Automation",
path="/automation",
icon="Zap",
group="Automation",
order=50,
),
FrontendMenuItem(
label_key="nav.agents",
label="Agents",
label="Agenten",
path="/agents",
icon="Bot",
group="Automation",
order=51,
),
],
+2 -2
View File
@@ -42,8 +42,8 @@ class CalendarPlugin(BasePlugin):
"calendar:admin",
],
menu_items=[
FrontendMenuItem(label_key='nav.calendar', label='Calendar', path='/calendar', icon='Calendar', order=20),
FrontendMenuItem(label_key='nav.calendarKanban', label='Calendar Kanban', path='/calendar/kanban', icon='KanbanSquare', group='nav.calendar', order=21),
FrontendMenuItem(label_key='nav.calendar', label='Kalender', path='/calendar', icon='Calendar', group='Kalender', order=20),
FrontendMenuItem(label_key='nav.calendar.kanban', label='Kanban', path='/calendar/kanban', icon='KanbanSquare', group='Kalender', order=21),
],
page_routes=[
FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True),
+2 -2
View File
@@ -32,8 +32,8 @@ class DmsPlugin(BasePlugin):
"dms:admin",
],
menu_items=[
FrontendMenuItem(label_key='nav.files', label='Files', path='/dms', icon='FolderOpen', order=40),
FrontendMenuItem(label_key='nav.trash', label='Trash', path='/dms/trash', icon='Trash2', group='nav.files', order=41),
FrontendMenuItem(label_key='nav.dms', label='Dateien', path='/dms', icon='FolderOpen', group='Dateien', order=40),
FrontendMenuItem(label_key='nav.dms.trash', label='Papierkorb', path='/dms/trash', icon='Trash2', group='Dateien', order=41),
],
page_routes=[
FrontendPageRoute(path='/dms', component='@/pages/Dms', protected=True),
+1 -1
View File
@@ -50,7 +50,7 @@ class KommunikationPlugin(BasePlugin):
],
is_core=True,
menu_items=[
FrontendMenuItem(label_key='nav.communication', label='Communication', path='/communication', icon='MessageSquare', order=80),
FrontendMenuItem(label_key='nav.communication', label='Kommunikation', path='/communication', icon='MessageSquare', order=80),
],
page_routes=[
FrontendPageRoute(path='/communication', component='@/pages/Communication', protected=True),
+2 -2
View File
@@ -57,8 +57,8 @@ class MailPlugin(BasePlugin):
migrations=["0001_initial.sql", "0006_flag_type.sql", "0007_sync_queue.sql", "0008_sync_queue_deleted_at.sql", "0009_remove_mail_soft_delete.sql", "0010_add_deleted_at.sql"],
permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"],
menu_items=[
FrontendMenuItem(label_key='nav.email', label='Mail', path='/mail', icon='Mail', order=30),
FrontendMenuItem(label_key='nav.emailSettings', label='Mail Settings', path='/mail/settings', icon='Settings', group='nav.email', order=31),
FrontendMenuItem(label_key='nav.mail', label='E-Mail', path='/mail', icon='Mail', group='E-Mail', order=30),
FrontendMenuItem(label_key='nav.mail.settings', label='Einstellungen', path='/mail/settings', icon='Settings', group='E-Mail', order=31),
],
page_routes=[
FrontendPageRoute(path='/mail', component='@/pages/Mail', protected=True),
@@ -27,7 +27,7 @@ class ReportGeneratorPlugin(BasePlugin):
migrations=["0001_initial.sql"],
permissions=["reports:read", "reports:generate", "reports:manage_templates"],
menu_items=[
FrontendMenuItem(label_key='nav.reports', label='Reports', path='/reports', icon='BarChart3', order=70),
FrontendMenuItem(label_key='nav.reports', label='Berichte', path='/reports', icon='BarChart3', order=70),
],
page_routes=[
FrontendPageRoute(path='/reports', component='@/pages/Reports', protected=True),
+1 -1
View File
@@ -39,7 +39,7 @@ class TasksPlugin(BasePlugin):
menu_items=[
FrontendMenuItem(
label_key="nav.tasks",
label="Tasks",
label="Aufgaben",
path="/tasks",
icon="CheckSquare",
order=30,
+59 -1
View File
@@ -13,7 +13,7 @@ from app.core.auth import get_redis
from app.core.db import get_db
from app.core.notifications import create_notification
from app.core.permissions import invalidate_permission_cache
from app.deps import require_permission
from app.deps import get_current_user, require_permission
from app.schemas.user import UserCreate, UserUpdate, UserResponse, PaginatedUsers
from app.services.user_service import user_service, _UNSET
@@ -276,3 +276,61 @@ async def delete_user(
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/me/menu-order")
async def get_menu_order(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get the current user's menu order preference."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
from sqlalchemy import select
from app.models.user import User
result = await db.execute(
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
menu_order = user.preferences.get("menu_order", [])
return {"menu_order": menu_order}
@router.put("/me/menu-order")
async def update_menu_order(
body: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update the current user's menu order preference."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
from sqlalchemy import select
from app.models.user import User
menu_order = body.get("menu_order")
if not isinstance(menu_order, list) or not all(isinstance(x, str) for x in menu_order):
raise HTTPException(
400,
detail={"detail": "menu_order must be a list of strings", "code": "invalid_format"},
)
result = await db.execute(
select(User).where(User.id == user_id, User.tenant_id == tenant_id)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
prefs = dict(user.preferences) if user.preferences else {}
prefs["menu_order"] = menu_order
user.preferences = prefs
await db.commit()
return {"menu_order": menu_order}
+57
View File
@@ -8,6 +8,9 @@
"name": "leocrm-frontend",
"version": "1.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.0",
"@tanstack/react-query": "^5.56.0",
"@tanstack/react-table": "^8.21.3",
@@ -1721,6 +1724,55 @@
"node": ">=18"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -9629,6 +9681,11 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/type-fest": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
+3
View File
@@ -15,6 +15,9 @@
"e2e:headed": "playwright test --headed"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.0",
"@tanstack/react-query": "^5.56.0",
"@tanstack/react-table": "^8.21.3",
+24 -1
View File
@@ -3,7 +3,7 @@
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from './client';
import { PaginatedResponse } from './types';
// ── Types matching backend schemas ──
@@ -80,6 +80,29 @@ export function useUpdateUser() {
});
}
// ── Menu Order ──
export interface MenuOrderResponse {
menu_order: string[];
}
export function useMenuOrder() {
return useQuery({
queryKey: ['menu-order'],
queryFn: () => apiGet<MenuOrderResponse>('/users/me/menu-order'),
});
}
export function useUpdateMenuOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (menu_order: string[]) => apiPut('/users/me/menu-order', { menu_order }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['menu-order'] });
},
});
}
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
+146 -129
View File
@@ -6,11 +6,13 @@ import { useUIStore } from '@/store/uiStore';
import { ChevronRight, FileText, Home, Settings, Users } from 'lucide-react';
import { usePluginStore } from '@/store/pluginStore';
import * as LucideIcons from 'lucide-react';
import { useMenuOrder } from '@/api/users';
interface NavSingleItem {
to: string;
labelKey: string;
icon: React.ReactNode;
order: number;
}
const chevronIcon = (expanded: boolean) => (
@@ -26,12 +28,12 @@ function getIcon(name: string): React.ReactNode {
// All other menu entries (calendar, dms, mail, ai-assistant, automation, reports, tasks, communication)
// are provided by plugin manifests via usePluginStore(s => s.getAllMenuItems()).
const singleItems: NavSingleItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
{ to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: <Home className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 0 },
{ to: '/contacts', labelKey: 'nav.contacts', icon: <Users className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 10 },
];
const bottomItems: NavSingleItem[] = [
{ to: '/settings', labelKey: 'nav.settings', icon: <Settings className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} /> },
{ to: '/settings', labelKey: 'nav.settings', icon: <Settings className="w-5 h-5 flex-shrink-0" aria-hidden="true" strokeWidth={2} />, order: 999 },
];
export function Sidebar() {
@@ -39,27 +41,72 @@ export function Sidebar() {
const { sidebarOpen, setSidebarOpen } = useUIStore();
const location = useLocation();
const manifests = usePluginStore(s => s.manifests);
const pluginMenuItems = useMemo(
() => manifests
const { data: menuOrderData } = useMenuOrder();
const allMenuItems = useMemo(() => {
const staticItems = singleItems.map(item => ({
path: item.to,
labelKey: item.labelKey,
label: item.labelKey,
icon: item.icon,
order: item.order,
group: undefined as string | undefined,
isStatic: true as const,
}));
const pluginItems = manifests
.flatMap((m) => m.menu_items)
.sort((a, b) => a.order - b.order),
[manifests]
);
.map(item => ({
path: item.path,
labelKey: item.label_key,
label: item.label,
icon: item.icon,
order: item.order,
group: item.group,
isStatic: false as const,
}));
const allItems = [...staticItems, ...pluginItems];
// Apply saved menu order if available
const savedOrder = menuOrderData?.menu_order;
if (savedOrder && savedOrder.length > 0) {
const orderMap = new Map(savedOrder.map((id, idx) => [id, idx]));
return allItems.sort((a, b) => {
// Map path to menu ID: static items use their path without leading /
const aId = a.path.startsWith('/') ? a.path.slice(1) : a.path;
const bId = b.path.startsWith('/') ? b.path.slice(1) : b.path;
const aIdx = orderMap.get(aId);
const bIdx = orderMap.get(bId);
if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
if (aIdx !== undefined) return -1;
if (bIdx !== undefined) return 1;
// Fall back to order field for unknown items
if (a.order !== b.order) return a.order - b.order;
return a.label.localeCompare(b.label);
});
}
return allItems.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.label.localeCompare(b.label);
});
}, [manifests, menuOrderData]);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
useEffect(() => {
setExpandedItems((prev) => {
const next = new Set(prev);
// Auto-expand plugin groups whose child route is active
for (const item of pluginMenuItems) {
// Auto-expand groups whose child route is active
for (const item of allMenuItems) {
if (item.group && location.pathname.startsWith(item.path)) {
next.add(`plugin-group-${item.group}`);
}
}
return next;
});
}, [location.pathname, pluginMenuItems]);
}, [location.pathname, allMenuItems]);
const toggleExpand = (labelKey: string) => {
setExpandedItems((prev) => {
@@ -94,124 +141,94 @@ export function Sidebar() {
</div>
<nav className="flex-1 overflow-y-auto py-4" aria-label="Hauptnavigation">
<ul className="space-y-1 px-2">
{singleItems.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white'
: 'hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(item.labelKey)}
>
{item.icon}
<span className="truncate">{t(item.labelKey)}</span>
</NavLink>
</li>
))}
{pluginMenuItems.length > 0 && (
<>
<li className="px-3 pt-4 pb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-secondary-500">
{t('nav.plugins', 'Plugins')}
</span>
</li>
{(() => {
const groups = new Map<string, typeof pluginMenuItems>();
const singles: typeof pluginMenuItems = [];
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);
}
}
const elements: React.ReactNode[] = [];
for (const item of singles) {
elements.push(
<li key={item.path}>
<NavLink
to={item.path}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white'
: 'hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(item.label_key, item.label)}
>
{getIcon(item.icon)}
<span className="truncate">{t(item.label_key, item.label)}</span>
</NavLink>
</li>
);
}
for (const [group, items] of groups) {
const groupKey = `plugin-group-${group}`;
const expanded = expandedItems.has(groupKey);
elements.push(
<li key={groupKey}>
<div
className={clsx(
'flex items-center gap-2 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors cursor-pointer select-none',
'text-secondary-300 hover:bg-secondary-800 hover:text-white'
)}
onClick={() => toggleExpand(groupKey)}
role="button"
tabIndex={0}
aria-expanded={expanded}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(groupKey);
}
}}
>
{getIcon(items[0].icon)}
<span className="flex-1 truncate">{group}</span>
{chevronIcon(expanded)}
</div>
{expanded && (
<ul className="mt-1 ml-4 space-y-1 border-l border-secondary-700 pl-2" role="group">
{items.map((child) => (
<li key={child.path}>
<NavLink
to={child.path}
className={({ isActive }) =>
clsx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white font-medium'
: 'text-secondary-400 hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(child.label_key, child.label)}
>
<span className="truncate">{t(child.label_key, child.label)}</span>
</NavLink>
</li>
))}
</ul>
)}
</li>
);
}
return elements;
})()}
</>
)}
{(() => {
const groups = new Map<string, typeof allMenuItems>();
const singles: typeof allMenuItems = [];
for (const item of allMenuItems) {
if (item.group) {
if (!groups.has(item.group)) groups.set(item.group, []);
groups.get(item.group)!.push(item);
} else {
singles.push(item);
}
}
const elements: React.ReactNode[] = [];
for (const item of singles) {
elements.push(
<li key={item.path}>
<NavLink
to={item.path}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white'
: 'hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(item.labelKey)}
>
{item.isStatic ? item.icon : getIcon(item.icon as string)}
<span className="truncate">{t(item.labelKey, item.label)}</span>
</NavLink>
</li>
);
}
for (const [group, items] of groups) {
const groupKey = `plugin-group-${group}`;
const expanded = expandedItems.has(groupKey);
elements.push(
<li key={groupKey}>
<div
className={clsx(
'flex items-center gap-2 px-3 py-2.5 rounded-md text-sm font-medium min-h-touch',
'motion-safe:transition-colors cursor-pointer select-none',
'text-secondary-300 hover:bg-secondary-800 hover:text-white'
)}
onClick={() => toggleExpand(groupKey)}
role="button"
tabIndex={0}
aria-expanded={expanded}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(groupKey);
}
}}
>
{getIcon(items[0].icon as string)}
<span className="flex-1 truncate">{group}</span>
{chevronIcon(expanded)}
</div>
{expanded && (
<ul className="mt-1 ml-4 space-y-1 border-l border-secondary-700 pl-2" role="group">
{items.map((child) => (
<li key={child.path}>
<NavLink
to={child.path}
className={({ isActive }) =>
clsx(
'flex items-center gap-2 px-3 py-2 rounded-md text-sm min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isActive
? 'bg-primary-600 text-white font-medium'
: 'text-secondary-400 hover:bg-secondary-800 hover:text-white'
)
}
aria-label={t(child.labelKey, child.label)}
>
<span className="truncate">{t(child.labelKey, child.label)}</span>
</NavLink>
</li>
))}
</ul>
)}
</li>
);
}
return elements;
})()}
{bottomItems.map((item) => (
<li key={item.to}>
@@ -0,0 +1,65 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, FolderOpen } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import clsx from 'clsx';
interface SortableMenuItemProps {
id: string;
label: string;
icon: string;
isGroup?: boolean;
}
function getIcon(name: string): React.ReactNode {
const Icon = (LucideIcons as any)[name];
return Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.FileText className="h-4 w-4" />;
}
export function SortableMenuItem({ id, label, icon, isGroup }: SortableMenuItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={clsx(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium',
'bg-white border border-secondary-200',
'motion-safe:transition-shadow',
isDragging && 'shadow-lg ring-2 ring-primary-500 z-50',
!isDragging && 'hover:bg-secondary-50'
)}
>
<button
className="cursor-grab active:cursor-grabbing text-secondary-400 hover:text-secondary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded"
{...attributes}
{...listeners}
aria-label="Ziehen zum Sortieren"
type="button"
>
<GripVertical className="w-4 h-4" />
</button>
<span className="text-secondary-400">
{isGroup ? <FolderOpen className="h-4 w-4" /> : getIcon(icon)}
</span>
<span className="flex-1 truncate">
{isGroup && <span className="text-xs font-semibold uppercase tracking-wider text-secondary-500 mr-2">{label}</span>}
{!isGroup && label}
</span>
</div>
);
}
+1
View File
@@ -29,6 +29,7 @@ export function SettingsPage() {
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
{ to: '/settings/theme', label: t('settings.theme', 'Theme'), icon: '\ud83c\udfa8' },
{ to: '/settings/mcp', label: t('settings.mcp', 'MCP'), icon: '\ud83d\udd27' },
{ to: '/settings/menu', label: t('settings.menuOrder', 'Menü'), icon: '\ud83d\udccb' },
];
const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
+238
View File
@@ -0,0 +1,238 @@
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>
);
}
+2
View File
@@ -36,6 +36,7 @@ const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => (
const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings })));
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m => ({ default: m.SettingsMcpPage })));
const SettingsMenuOrderPage = React.lazy(() => import('@/pages/SettingsMenuOrder').then(m => ({ default: m.SettingsMenuOrderPage })));
const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage })));
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
@@ -113,6 +114,7 @@ const router = createBrowserRouter([
{ path: 'theme', element: withSuspense(<SettingsThemePage />) },
{ path: 'automation', element: withSuspense(<AutomationSettingsPage />) },
{ path: 'mcp', element: withSuspense(<SettingsMcpPage />) },
{ path: 'menu', element: withSuspense(<SettingsMenuOrderPage />) },
{ path: '*', element: <PluginRouteRenderer /> },
],
},