From e017da9d12277771c469c8673bcf1a0f9e31182a Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 24 Jul 2026 21:46:27 +0200 Subject: [PATCH] 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 --- app/plugins/builtins/ai_assistant/plugin.py | 2 +- app/plugins/builtins/automation/plugin.py | 4 +- app/plugins/builtins/calendar/plugin.py | 4 +- app/plugins/builtins/dms/plugin.py | 4 +- app/plugins/builtins/kommunikation/plugin.py | 2 +- app/plugins/builtins/mail/plugin.py | 4 +- .../builtins/report_generator/plugin.py | 2 +- app/plugins/builtins/tasks/plugin.py | 2 +- app/routes/users.py | 60 +++- frontend/package-lock.json | 57 ++++ frontend/package.json | 3 + frontend/src/api/users.ts | 25 +- frontend/src/components/layout/Sidebar.tsx | 275 ++++++++++-------- .../components/layout/SortableMenuItem.tsx | 65 +++++ frontend/src/pages/Settings.tsx | 1 + frontend/src/pages/SettingsMenuOrder.tsx | 238 +++++++++++++++ frontend/src/routes/index.tsx | 2 + 17 files changed, 608 insertions(+), 142 deletions(-) create mode 100644 frontend/src/components/layout/SortableMenuItem.tsx create mode 100644 frontend/src/pages/SettingsMenuOrder.tsx diff --git a/app/plugins/builtins/ai_assistant/plugin.py b/app/plugins/builtins/ai_assistant/plugin.py index dd17d7d..533f743 100644 --- a/app/plugins/builtins/ai_assistant/plugin.py +++ b/app/plugins/builtins/ai_assistant/plugin.py @@ -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), diff --git a/app/plugins/builtins/automation/plugin.py b/app/plugins/builtins/automation/plugin.py index 2a10390..015360c 100644 --- a/app/plugins/builtins/automation/plugin.py +++ b/app/plugins/builtins/automation/plugin.py @@ -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, ), ], diff --git a/app/plugins/builtins/calendar/plugin.py b/app/plugins/builtins/calendar/plugin.py index cc370ee..5bca2d2 100644 --- a/app/plugins/builtins/calendar/plugin.py +++ b/app/plugins/builtins/calendar/plugin.py @@ -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), diff --git a/app/plugins/builtins/dms/plugin.py b/app/plugins/builtins/dms/plugin.py index ada375d..1b3599f 100644 --- a/app/plugins/builtins/dms/plugin.py +++ b/app/plugins/builtins/dms/plugin.py @@ -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), diff --git a/app/plugins/builtins/kommunikation/plugin.py b/app/plugins/builtins/kommunikation/plugin.py index c4ed863..55b1bc3 100644 --- a/app/plugins/builtins/kommunikation/plugin.py +++ b/app/plugins/builtins/kommunikation/plugin.py @@ -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), diff --git a/app/plugins/builtins/mail/plugin.py b/app/plugins/builtins/mail/plugin.py index 0f2dd14..4d109f1 100644 --- a/app/plugins/builtins/mail/plugin.py +++ b/app/plugins/builtins/mail/plugin.py @@ -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), diff --git a/app/plugins/builtins/report_generator/plugin.py b/app/plugins/builtins/report_generator/plugin.py index 8f87add..39ba708 100644 --- a/app/plugins/builtins/report_generator/plugin.py +++ b/app/plugins/builtins/report_generator/plugin.py @@ -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), diff --git a/app/plugins/builtins/tasks/plugin.py b/app/plugins/builtins/tasks/plugin.py index 240f18d..8c93fe7 100644 --- a/app/plugins/builtins/tasks/plugin.py +++ b/app/plugins/builtins/tasks/plugin.py @@ -39,7 +39,7 @@ class TasksPlugin(BasePlugin): menu_items=[ FrontendMenuItem( label_key="nav.tasks", - label="Tasks", + label="Aufgaben", path="/tasks", icon="CheckSquare", order=30, diff --git a/app/routes/users.py b/app/routes/users.py index e0c42ff..17eb812 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -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} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d86eb5..81c0cec 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 247ae12..4e5ef14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 9fdbdbd..8a10ac9 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -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('/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({ diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 1c41ce3..7ef134d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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: