feat: add full-page chat route and sidebar menu ordering
This commit is contained in:
@@ -39,6 +39,7 @@ import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
|||||||
import Issues from './pages/Issues';
|
import Issues from './pages/Issues';
|
||||||
import IssueDetail from './pages/IssueDetail';
|
import IssueDetail from './pages/IssueDetail';
|
||||||
import IssueNeu from './pages/IssueNeu';
|
import IssueNeu from './pages/IssueNeu';
|
||||||
|
import Chat from './pages/Chat';
|
||||||
import AdminDashboard from './pages/AdminDashboard';
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
import AdminSettings from './pages/AdminSettings';
|
import AdminSettings from './pages/AdminSettings';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
@@ -77,6 +78,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/chat"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Chat />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/einsaetze"
|
path="/einsaetze"
|
||||||
element={
|
element={
|
||||||
@@ -261,14 +270,6 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/bestellungen/lieferanten/neu"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<LieferantDetail />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/bestellungen/lieferanten/:id"
|
path="/bestellungen/lieferanten/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Tooltip from '@mui/material/Tooltip';
|
|||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
import ListItem from '@mui/material/ListItem';
|
import ListItem from '@mui/material/ListItem';
|
||||||
import { useLayout, CHAT_PANEL_MIN_WIDTH, CHAT_PANEL_MAX_WIDTH } from '../../contexts/LayoutContext';
|
import { useLayout, CHAT_PANEL_MIN_WIDTH, CHAT_PANEL_MAX_WIDTH } from '../../contexts/LayoutContext';
|
||||||
import { ChatProvider, useChat } from '../../contexts/ChatContext';
|
import { useChat } from '../../contexts/ChatContext';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { configApi } from '../../services/config';
|
import { configApi } from '../../services/config';
|
||||||
@@ -341,11 +341,7 @@ const ChatPanelInner: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ChatPanel: React.FC = () => {
|
const ChatPanel: React.FC = () => {
|
||||||
return (
|
return <ChatPanelInner />;
|
||||||
<ChatProvider>
|
|
||||||
<ChatPanelInner />
|
|
||||||
</ChatProvider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ChatPanel;
|
export default ChatPanel;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, ReactNode } from 'react';
|
import { useState, ReactNode } from 'react';
|
||||||
import { Box, Toolbar } from '@mui/material';
|
import { Box, Toolbar } from '@mui/material';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import Header from '../shared/Header';
|
import Header from '../shared/Header';
|
||||||
import Sidebar from '../shared/Sidebar';
|
import Sidebar from '../shared/Sidebar';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import Loading from '../shared/Loading';
|
import Loading from '../shared/Loading';
|
||||||
import { LayoutProvider, useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
|
import { LayoutProvider, useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
|
||||||
|
import { ChatProvider } from '../../contexts/ChatContext';
|
||||||
import ChatPanel from '../chat/ChatPanel';
|
import ChatPanel from '../chat/ChatPanel';
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
@@ -15,6 +17,8 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
|
|||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const { isLoading } = useAuth();
|
const { isLoading } = useAuth();
|
||||||
const { sidebarCollapsed, chatPanelOpen, chatPanelWidth } = useLayout();
|
const { sidebarCollapsed, chatPanelOpen, chatPanelWidth } = useLayout();
|
||||||
|
const location = useLocation();
|
||||||
|
const onChatPage = location.pathname === '/chat';
|
||||||
|
|
||||||
const handleDrawerToggle = () => {
|
const handleDrawerToggle = () => {
|
||||||
setMobileOpen(!mobileOpen);
|
setMobileOpen(!mobileOpen);
|
||||||
@@ -25,7 +29,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH;
|
const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH;
|
||||||
const chatWidth = chatPanelOpen ? chatPanelWidth : 64;
|
const chatWidth = onChatPage ? 0 : (chatPanelOpen ? chatPanelWidth : 64);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||||
@@ -48,7 +52,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
|
|||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<ChatPanel />
|
{!onChatPage && <ChatPanel />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,7 +60,9 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
|
|||||||
function DashboardLayout({ children }: DashboardLayoutProps) {
|
function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
|
<ChatProvider>
|
||||||
<DashboardLayoutInner>{children}</DashboardLayoutInner>
|
<DashboardLayoutInner>{children}</DashboardLayoutInner>
|
||||||
|
</ChatProvider>
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ import {
|
|||||||
LocalShipping,
|
LocalShipping,
|
||||||
BugReport,
|
BugReport,
|
||||||
BookOnline,
|
BookOnline,
|
||||||
|
Forum,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
|
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
|
||||||
import { usePermissionContext } from '../../contexts/PermissionContext';
|
import { usePermissionContext } from '../../contexts/PermissionContext';
|
||||||
import { vehiclesApi } from '../../services/vehicles';
|
import { vehiclesApi } from '../../services/vehicles';
|
||||||
|
import { preferencesApi } from '../../services/settings';
|
||||||
|
|
||||||
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
|
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
|
||||||
|
|
||||||
@@ -69,6 +71,11 @@ const baseNavigationItems: NavigationItem[] = [
|
|||||||
icon: <DashboardIcon />,
|
icon: <DashboardIcon />,
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Chat',
|
||||||
|
icon: <Forum />,
|
||||||
|
path: '/chat',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Kalender',
|
text: 'Kalender',
|
||||||
icon: <CalendarMonth />,
|
icon: <CalendarMonth />,
|
||||||
@@ -169,6 +176,14 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
staleTime: 2 * 60 * 1000,
|
staleTime: 2 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: preferences } = useQuery({
|
||||||
|
queryKey: ['user-preferences'],
|
||||||
|
queryFn: preferencesApi.get,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuOrder: string[] = (preferences?.menuOrder as string[] | undefined) ?? [];
|
||||||
|
|
||||||
const vehicleSubItems: SubItem[] = useMemo(
|
const vehicleSubItems: SubItem[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(vehicleList ?? []).map((v) => ({
|
(vehicleList ?? []).map((v) => ({
|
||||||
@@ -220,8 +235,21 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
return item;
|
return item;
|
||||||
})
|
})
|
||||||
.filter((item) => !item.permission || hasPermission(item.permission));
|
.filter((item) => !item.permission || hasPermission(item.permission));
|
||||||
|
|
||||||
|
// Apply custom menu order: items in menuOrder are sorted to their index; rest keep relative order
|
||||||
|
if (menuOrder.length > 0) {
|
||||||
|
items.sort((a, b) => {
|
||||||
|
const aIdx = menuOrder.indexOf(a.path);
|
||||||
|
const bIdx = menuOrder.indexOf(b.path);
|
||||||
|
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
|
||||||
|
if (aIdx !== -1) return -1;
|
||||||
|
if (bIdx !== -1) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return hasPermission('admin:view') ? [...items, adminItem, adminSettingsItem] : items;
|
return hasPermission('admin:view') ? [...items, adminItem, adminSettingsItem] : items;
|
||||||
}, [vehicleSubItems, hasPermission]);
|
}, [vehicleSubItems, hasPermission, menuOrder]);
|
||||||
|
|
||||||
// Expand state for items with sub-items — auto-expand when route matches
|
// Expand state for items with sub-items — auto-expand when route matches
|
||||||
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});
|
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { nextcloudApi } from '../services/nextcloud';
|
import { nextcloudApi } from '../services/nextcloud';
|
||||||
import { useLayout } from './LayoutContext';
|
import { useLayout } from './LayoutContext';
|
||||||
import { useNotification } from './NotificationContext';
|
import { useNotification } from './NotificationContext';
|
||||||
@@ -24,7 +25,10 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
|||||||
const { chatPanelOpen } = useLayout();
|
const { chatPanelOpen } = useLayout();
|
||||||
const { showNotificationToast } = useNotification();
|
const { showNotificationToast } = useNotification();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const location = useLocation();
|
||||||
|
const onChatPage = location.pathname === '/chat';
|
||||||
const prevPanelOpenRef = useRef(chatPanelOpen);
|
const prevPanelOpenRef = useRef(chatPanelOpen);
|
||||||
|
const prevOnChatPageRef = useRef(onChatPage);
|
||||||
const prevUnreadRef = useRef<Map<string, number>>(new Map());
|
const prevUnreadRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
// Invalidate rooms/connection when panel opens so data is fresh immediately
|
// Invalidate rooms/connection when panel opens so data is fresh immediately
|
||||||
@@ -36,10 +40,21 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
|||||||
prevPanelOpenRef.current = chatPanelOpen;
|
prevPanelOpenRef.current = chatPanelOpen;
|
||||||
}, [chatPanelOpen, queryClient]);
|
}, [chatPanelOpen, queryClient]);
|
||||||
|
|
||||||
|
// Invalidate rooms/connection when navigating to the chat page
|
||||||
|
useEffect(() => {
|
||||||
|
if (onChatPage && !prevOnChatPageRef.current) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'connection'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||||
|
}
|
||||||
|
prevOnChatPageRef.current = onChatPage;
|
||||||
|
}, [onChatPage, queryClient]);
|
||||||
|
|
||||||
|
const isActive = chatPanelOpen || onChatPage;
|
||||||
|
|
||||||
const { data: connData } = useQuery({
|
const { data: connData } = useQuery({
|
||||||
queryKey: ['nextcloud', 'connection'],
|
queryKey: ['nextcloud', 'connection'],
|
||||||
queryFn: () => nextcloudApi.getConversations(),
|
queryFn: () => nextcloudApi.getConversations(),
|
||||||
refetchInterval: chatPanelOpen ? 5000 : 15000,
|
refetchInterval: isActive ? 5000 : 15000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +63,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
|||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['nextcloud', 'rooms'],
|
queryKey: ['nextcloud', 'rooms'],
|
||||||
queryFn: () => nextcloudApi.getRooms(),
|
queryFn: () => nextcloudApi.getRooms(),
|
||||||
refetchInterval: chatPanelOpen ? 5000 : 15000,
|
refetchInterval: isActive ? 5000 : 15000,
|
||||||
enabled: isConnected,
|
enabled: isConnected,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,7 +80,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [isConnected]);
|
}, [isConnected]);
|
||||||
|
|
||||||
// Detect new unread messages while panel is closed and show toast
|
// Detect new unread messages while panel is closed and not on chat page — show toast
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rooms.length) return;
|
if (!rooms.length) return;
|
||||||
const prev = prevUnreadRef.current;
|
const prev = prevUnreadRef.current;
|
||||||
@@ -81,7 +96,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
|||||||
|
|
||||||
for (const room of rooms) {
|
for (const room of rooms) {
|
||||||
const prevCount = prev.get(room.token) ?? 0;
|
const prevCount = prev.get(room.token) ?? 0;
|
||||||
if (!chatPanelOpen && room.unreadMessages > prevCount) {
|
if (!chatPanelOpen && !onChatPage && room.unreadMessages > prevCount) {
|
||||||
showNotificationToast(room.displayName, 'info');
|
showNotificationToast(room.displayName, 'info');
|
||||||
}
|
}
|
||||||
prev.set(room.token, room.unreadMessages);
|
prev.set(room.token, room.unreadMessages);
|
||||||
@@ -92,7 +107,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
|||||||
for (const key of prev.keys()) {
|
for (const key of prev.keys()) {
|
||||||
if (!currentTokens.has(key)) prev.delete(key);
|
if (!currentTokens.has(key)) prev.delete(key);
|
||||||
}
|
}
|
||||||
}, [rooms, chatPanelOpen, showNotificationToast]);
|
}, [rooms, chatPanelOpen, onChatPage, showNotificationToast]);
|
||||||
|
|
||||||
const selectRoom = useCallback((token: string | null) => {
|
const selectRoom = useCallback((token: string | null) => {
|
||||||
setSelectedRoomToken(token);
|
setSelectedRoomToken(token);
|
||||||
|
|||||||
98
frontend/src/pages/Chat.tsx
Normal file
98
frontend/src/pages/Chat.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { useChat } from '../contexts/ChatContext';
|
||||||
|
import { nextcloudApi } from '../services/nextcloud';
|
||||||
|
import { notificationsApi } from '../services/notifications';
|
||||||
|
import ChatRoomList from '../components/chat/ChatRoomList';
|
||||||
|
import ChatMessageView from '../components/chat/ChatMessageView';
|
||||||
|
|
||||||
|
const ChatPage: React.FC = () => {
|
||||||
|
const { rooms, selectedRoomToken, connected } = useChat();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const markedRoomsRef = React.useRef(new Set<string>());
|
||||||
|
|
||||||
|
// Dismiss nextcloud_talk notifications when on this page
|
||||||
|
React.useEffect(() => {
|
||||||
|
notificationsApi.dismissByType('nextcloud_talk').then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['unreadNotificationCount'] });
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
// Mark unread rooms as read when rooms data updates
|
||||||
|
React.useEffect(() => {
|
||||||
|
const unread = rooms.filter(
|
||||||
|
(r) => r.unreadMessages > 0 && !markedRoomsRef.current.has(r.token),
|
||||||
|
);
|
||||||
|
if (unread.length === 0) return;
|
||||||
|
unread.forEach((r) => markedRoomsRef.current.add(r.token));
|
||||||
|
Promise.allSettled(unread.map((r) => nextcloudApi.markAsRead(r.token))).then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||||
|
});
|
||||||
|
}, [rooms, queryClient]);
|
||||||
|
|
||||||
|
// Mark selected room as read when a conversation is opened
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedRoomToken) return;
|
||||||
|
nextcloudApi.markAsRead(selectedRoomToken).then(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [selectedRoomToken, queryClient]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 'calc(100vh - 112px)',
|
||||||
|
display: 'flex',
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!connected ? (
|
||||||
|
<Box sx={{ p: 3, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Nextcloud nicht verbunden. Bitte verbinden Sie sich in den{' '}
|
||||||
|
<Link to="/settings" style={{ color: 'inherit' }}>Einstellungen</Link>.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 280,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRight: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChatRoomList />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{selectedRoomToken ? (
|
||||||
|
<ChatMessageView />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Wähle ein Gespräch aus, um zu beginnen.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatPage;
|
||||||
@@ -15,8 +15,12 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets, Cloud, LinkOff, Forum, Person, OpenInNew } from '@mui/icons-material';
|
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets, Cloud, LinkOff, Forum, Person, OpenInNew, ArrowUpward, ArrowDownward, Sort, Restore } from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
@@ -26,16 +30,34 @@ import { WIDGETS, WidgetKey } from '../constants/widgets';
|
|||||||
import { nextcloudApi } from '../services/nextcloud';
|
import { nextcloudApi } from '../services/nextcloud';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
|
|
||||||
const POLL_INTERVAL = 2000;
|
const POLL_INTERVAL = 2000;
|
||||||
const POLL_TIMEOUT = 5 * 60 * 1000;
|
const POLL_TIMEOUT = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Ordered list of nav items eligible for reordering (mirrors baseNavigationItems, excluding admin/settings)
|
||||||
|
const ORDERABLE_NAV_ITEMS = [
|
||||||
|
{ text: 'Dashboard', path: '/dashboard', permission: undefined as string | undefined },
|
||||||
|
{ text: 'Chat', path: '/chat', permission: undefined as string | undefined },
|
||||||
|
{ text: 'Kalender', path: '/kalender', permission: 'kalender:view' },
|
||||||
|
{ text: 'Fahrzeugbuchungen', path: '/fahrzeugbuchungen', permission: 'fahrzeugbuchungen:view' },
|
||||||
|
{ text: 'Fahrzeuge', path: '/fahrzeuge', permission: 'fahrzeuge:view' },
|
||||||
|
{ text: 'Ausrüstung', path: '/ausruestung', permission: 'ausruestung:view' },
|
||||||
|
{ text: 'Mitglieder', path: '/mitglieder', permission: 'mitglieder:view_own' },
|
||||||
|
{ text: 'Atemschutz', path: '/atemschutz', permission: 'atemschutz:view' },
|
||||||
|
{ text: 'Wissen', path: '/wissen', permission: 'wissen:view' },
|
||||||
|
{ text: 'Bestellungen', path: '/bestellungen', permission: 'bestellungen:view' },
|
||||||
|
{ text: 'Interne Bestellungen', path: '/ausruestungsanfrage', permission: 'ausruestungsanfrage:view' },
|
||||||
|
{ text: 'Issues', path: '/issues', permission: 'issues:view_own' },
|
||||||
|
];
|
||||||
|
|
||||||
function Settings() {
|
function Settings() {
|
||||||
const { themeMode, setThemeMode } = useThemeMode();
|
const { themeMode, setThemeMode } = useThemeMode();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showInfo } = useNotification();
|
const { showInfo } = useNotification();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = usePermissionContext();
|
||||||
|
|
||||||
const { data: preferences, isLoading: prefsLoading } = useQuery({
|
const { data: preferences, isLoading: prefsLoading } = useQuery({
|
||||||
queryKey: ['user-preferences'],
|
queryKey: ['user-preferences'],
|
||||||
@@ -60,6 +82,36 @@ function Settings() {
|
|||||||
mutation.mutate({ ...current, widgets });
|
mutation.mutate({ ...current, widgets });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Menu ordering
|
||||||
|
const menuOrder: string[] = (preferences?.menuOrder as string[] | undefined) ?? [];
|
||||||
|
const visibleNavItems = ORDERABLE_NAV_ITEMS.filter(
|
||||||
|
(item) => !item.permission || hasPermission(item.permission),
|
||||||
|
);
|
||||||
|
const orderedNavItems = [...visibleNavItems].sort((a, b) => {
|
||||||
|
const aIdx = menuOrder.indexOf(a.path);
|
||||||
|
const bIdx = menuOrder.indexOf(b.path);
|
||||||
|
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
|
||||||
|
if (aIdx !== -1) return -1;
|
||||||
|
if (bIdx !== -1) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveNavItem = (fromIdx: number, toIdx: number) => {
|
||||||
|
if (toIdx < 0 || toIdx >= orderedNavItems.length) return;
|
||||||
|
const newOrder = [...orderedNavItems];
|
||||||
|
const [moved] = newOrder.splice(fromIdx, 1);
|
||||||
|
newOrder.splice(toIdx, 0, moved);
|
||||||
|
const current = preferences ?? {};
|
||||||
|
mutation.mutate({ ...current, menuOrder: newOrder.map((i) => i.path) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetMenuOrder = () => {
|
||||||
|
const current = preferences ?? {};
|
||||||
|
const updated = { ...current };
|
||||||
|
delete updated.menuOrder;
|
||||||
|
mutation.mutate(updated);
|
||||||
|
};
|
||||||
|
|
||||||
// Nextcloud Talk connection
|
// Nextcloud Talk connection
|
||||||
const { data: ncData, isLoading: ncLoading } = useQuery({
|
const { data: ncData, isLoading: ncLoading } = useQuery({
|
||||||
queryKey: ['nextcloud-talk-rooms'],
|
queryKey: ['nextcloud-talk-rooms'],
|
||||||
@@ -194,6 +246,70 @@ function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Menu Ordering */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Sort color="primary" sx={{ mr: 2 }} />
|
||||||
|
<Typography variant="h6">Menü-Reihenfolge</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<Restore />}
|
||||||
|
onClick={resetMenuOrder}
|
||||||
|
disabled={mutation.isPending || menuOrder.length === 0}
|
||||||
|
>
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mb: 1 }} />
|
||||||
|
{prefsLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<List disablePadding>
|
||||||
|
{orderedNavItems.map((item, idx) => (
|
||||||
|
<ListItem
|
||||||
|
key={item.path}
|
||||||
|
disablePadding
|
||||||
|
sx={{ py: 0.25 }}
|
||||||
|
secondaryAction={
|
||||||
|
<Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => moveNavItem(idx, idx - 1)}
|
||||||
|
disabled={idx === 0 || mutation.isPending}
|
||||||
|
aria-label="Nach oben"
|
||||||
|
>
|
||||||
|
<ArrowUpward fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => moveNavItem(idx, idx + 1)}
|
||||||
|
disabled={idx === orderedNavItems.length - 1 || mutation.isPending}
|
||||||
|
aria-label="Nach unten"
|
||||||
|
>
|
||||||
|
<ArrowDownward fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.text}
|
||||||
|
primaryTypographyProps={{ variant: 'body2' }}
|
||||||
|
sx={{ pr: 8 }}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
{/* Nextcloud Talk */}
|
{/* Nextcloud Talk */}
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user