feat: add full-page chat route and sidebar menu ordering

This commit is contained in:
Matthias Hochmeister
2026-03-27 18:00:58 +01:00
parent 1a66a66aab
commit c1b4a92a12
7 changed files with 284 additions and 24 deletions

View File

@@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/chat"
element={
<ProtectedRoute>
<Chat />
</ProtectedRoute>
}
/>
<Route
path="/einsaetze"
element={
@@ -261,14 +270,6 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/bestellungen/lieferanten/neu"
element={
<ProtectedRoute>
<LieferantDetail />
</ProtectedRoute>
}
/>
<Route
path="/bestellungen/lieferanten/:id"
element={

View File

@@ -13,7 +13,7 @@ import Tooltip from '@mui/material/Tooltip';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
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 { useQuery, useQueryClient } from '@tanstack/react-query';
import { configApi } from '../../services/config';
@@ -341,11 +341,7 @@ const ChatPanelInner: React.FC = () => {
};
const ChatPanel: React.FC = () => {
return (
<ChatProvider>
<ChatPanelInner />
</ChatProvider>
);
return <ChatPanelInner />;
};
export default ChatPanel;

View File

@@ -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 (
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
@@ -48,7 +52,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
{children}
</Box>
<ChatPanel />
{!onChatPage && <ChatPanel />}
</Box>
);
}
@@ -56,7 +60,9 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<LayoutProvider>
<ChatProvider>
<DashboardLayoutInner>{children}</DashboardLayoutInner>
</ChatProvider>
</LayoutProvider>
);
}

View File

@@ -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: <DashboardIcon />,
path: '/dashboard',
},
{
text: 'Chat',
icon: <Forum />,
path: '/chat',
},
{
text: 'Kalender',
icon: <CalendarMonth />,
@@ -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<Record<string, boolean>>({});

View File

@@ -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<ChatProviderProps> = ({ 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<Map<string, number>>(new Map());
// 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;
}, [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<ChatProviderProps> = ({ 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<ChatProviderProps> = ({ 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<ChatProviderProps> = ({ 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<ChatProviderProps> = ({ 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);

View 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;

View File

@@ -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() {
</Card>
</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 */}
<Grid item xs={12} md={6}>
<Card>