diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa80b44..50d7777 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu'; import Issues from './pages/Issues'; import IssueDetail from './pages/IssueDetail'; import IssueNeu from './pages/IssueNeu'; +import Chat from './pages/Chat'; import AdminDashboard from './pages/AdminDashboard'; import AdminSettings from './pages/AdminSettings'; import NotFound from './pages/NotFound'; @@ -77,6 +78,14 @@ function App() { } /> + + + + } + /> } /> - - - - } - /> { }; const ChatPanel: React.FC = () => { - return ( - - - - ); + return ; }; export default ChatPanel; diff --git a/frontend/src/components/dashboard/DashboardLayout.tsx b/frontend/src/components/dashboard/DashboardLayout.tsx index 64be13c..3987eab 100644 --- a/frontend/src/components/dashboard/DashboardLayout.tsx +++ b/frontend/src/components/dashboard/DashboardLayout.tsx @@ -1,10 +1,12 @@ import { useState, ReactNode } from 'react'; import { Box, Toolbar } from '@mui/material'; +import { useLocation } from 'react-router-dom'; import Header from '../shared/Header'; import Sidebar from '../shared/Sidebar'; import { useAuth } from '../../contexts/AuthContext'; import Loading from '../shared/Loading'; import { LayoutProvider, useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext'; +import { ChatProvider } from '../../contexts/ChatContext'; import ChatPanel from '../chat/ChatPanel'; interface DashboardLayoutProps { @@ -15,6 +17,8 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) { const [mobileOpen, setMobileOpen] = useState(false); const { isLoading } = useAuth(); const { sidebarCollapsed, chatPanelOpen, chatPanelWidth } = useLayout(); + const location = useLocation(); + const onChatPage = location.pathname === '/chat'; const handleDrawerToggle = () => { setMobileOpen(!mobileOpen); @@ -25,7 +29,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) { } const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH; - const chatWidth = chatPanelOpen ? chatPanelWidth : 64; + const chatWidth = onChatPage ? 0 : (chatPanelOpen ? chatPanelWidth : 64); return ( @@ -48,7 +52,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) { {children} - + {!onChatPage && } ); } @@ -56,7 +60,9 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) { function DashboardLayout({ children }: DashboardLayoutProps) { return ( - {children} + + {children} + ); } diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 61cc36c..32aa258 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -28,12 +28,14 @@ import { LocalShipping, BugReport, BookOnline, + Forum, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext'; import { usePermissionContext } from '../../contexts/PermissionContext'; import { vehiclesApi } from '../../services/vehicles'; +import { preferencesApi } from '../../services/settings'; export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED }; @@ -69,6 +71,11 @@ const baseNavigationItems: NavigationItem[] = [ icon: , path: '/dashboard', }, + { + text: 'Chat', + icon: , + path: '/chat', + }, { text: 'Kalender', icon: , @@ -169,6 +176,14 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { 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( () => (vehicleList ?? []).map((v) => ({ @@ -220,8 +235,21 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { return item; }) .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; - }, [vehicleSubItems, hasPermission]); + }, [vehicleSubItems, hasPermission, menuOrder]); // Expand state for items with sub-items — auto-expand when route matches const [expandedItems, setExpandedItems] = useState>({}); diff --git a/frontend/src/contexts/ChatContext.tsx b/frontend/src/contexts/ChatContext.tsx index 9edff41..59f8ebe 100644 --- a/frontend/src/contexts/ChatContext.tsx +++ b/frontend/src/contexts/ChatContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useLocation } from 'react-router-dom'; import { nextcloudApi } from '../services/nextcloud'; import { useLayout } from './LayoutContext'; import { useNotification } from './NotificationContext'; @@ -24,7 +25,10 @@ export const ChatProvider: React.FC = ({ children }) => { const { chatPanelOpen } = useLayout(); const { showNotificationToast } = useNotification(); const queryClient = useQueryClient(); + const location = useLocation(); + const onChatPage = location.pathname === '/chat'; const prevPanelOpenRef = useRef(chatPanelOpen); + const prevOnChatPageRef = useRef(onChatPage); const prevUnreadRef = useRef>(new Map()); // Invalidate rooms/connection when panel opens so data is fresh immediately @@ -36,10 +40,21 @@ export const ChatProvider: React.FC = ({ children }) => { prevPanelOpenRef.current = chatPanelOpen; }, [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({ queryKey: ['nextcloud', 'connection'], queryFn: () => nextcloudApi.getConversations(), - refetchInterval: chatPanelOpen ? 5000 : 15000, + refetchInterval: isActive ? 5000 : 15000, retry: false, }); @@ -48,7 +63,7 @@ export const ChatProvider: React.FC = ({ children }) => { const { data } = useQuery({ queryKey: ['nextcloud', 'rooms'], queryFn: () => nextcloudApi.getRooms(), - refetchInterval: chatPanelOpen ? 5000 : 15000, + refetchInterval: isActive ? 5000 : 15000, enabled: isConnected, }); @@ -65,7 +80,7 @@ export const ChatProvider: React.FC = ({ children }) => { } }, [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(() => { if (!rooms.length) return; const prev = prevUnreadRef.current; @@ -81,7 +96,7 @@ export const ChatProvider: React.FC = ({ children }) => { for (const room of rooms) { const prevCount = prev.get(room.token) ?? 0; - if (!chatPanelOpen && room.unreadMessages > prevCount) { + if (!chatPanelOpen && !onChatPage && room.unreadMessages > prevCount) { showNotificationToast(room.displayName, 'info'); } prev.set(room.token, room.unreadMessages); @@ -92,7 +107,7 @@ export const ChatProvider: React.FC = ({ children }) => { for (const key of prev.keys()) { if (!currentTokens.has(key)) prev.delete(key); } - }, [rooms, chatPanelOpen, showNotificationToast]); + }, [rooms, chatPanelOpen, onChatPage, showNotificationToast]); const selectRoom = useCallback((token: string | null) => { setSelectedRoomToken(token); diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx new file mode 100644 index 0000000..fa90207 --- /dev/null +++ b/frontend/src/pages/Chat.tsx @@ -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()); + + // 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 ( + + + {!connected ? ( + + + Nextcloud nicht verbunden. Bitte verbinden Sie sich in den{' '} + Einstellungen. + + + ) : ( + <> + + + + + {selectedRoomToken ? ( + + ) : ( + + + Wähle ein Gespräch aus, um zu beginnen. + + + )} + + + )} + + + ); +}; + +export default ChatPage; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index ad23dcf..846fe60 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -15,8 +15,12 @@ import { CircularProgress, Button, Chip, + IconButton, + List, + ListItem, + ListItemText, } 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 { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; @@ -26,16 +30,34 @@ import { WIDGETS, WidgetKey } from '../constants/widgets'; import { nextcloudApi } from '../services/nextcloud'; import { useNotification } from '../contexts/NotificationContext'; import { useAuth } from '../contexts/AuthContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; const POLL_INTERVAL = 2000; 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() { const { themeMode, setThemeMode } = useThemeMode(); const queryClient = useQueryClient(); const { showInfo } = useNotification(); const { user } = useAuth(); const navigate = useNavigate(); + const { hasPermission } = usePermissionContext(); const { data: preferences, isLoading: prefsLoading } = useQuery({ queryKey: ['user-preferences'], @@ -60,6 +82,36 @@ function Settings() { 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 const { data: ncData, isLoading: ncLoading } = useQuery({ queryKey: ['nextcloud-talk-rooms'], @@ -194,6 +246,70 @@ function Settings() { + {/* Menu Ordering */} + + + + + + + Menü-Reihenfolge + + + + + {prefsLoading ? ( + + + + ) : ( + + {orderedNavItems.map((item, idx) => ( + + moveNavItem(idx, idx - 1)} + disabled={idx === 0 || mutation.isPending} + aria-label="Nach oben" + > + + + moveNavItem(idx, idx + 1)} + disabled={idx === orderedNavItems.length - 1 || mutation.isPending} + aria-label="Nach unten" + > + + + + } + > + + + ))} + + )} + + + + {/* Nextcloud Talk */}