resolve issues with new features

This commit is contained in:
Matthias Hochmeister
2026-03-12 16:42:21 +01:00
parent 5aa309b97a
commit 68586b01dc
19 changed files with 526 additions and 109 deletions

View File

@@ -14,8 +14,9 @@ import ListItem from '@mui/material/ListItem';
import { useLayout } from '../../contexts/LayoutContext';
import { ChatProvider, useChat } from '../../contexts/ChatContext';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { configApi } from '../../services/config';
import { notificationsApi } from '../../services/notifications';
import { safeOpenUrl } from '../../utils/safeOpenUrl';
import ChatRoomList from './ChatRoomList';
import ChatMessageView from './ChatMessageView';
@@ -27,6 +28,7 @@ const EXPANDED_WIDTH = 360;
const ChatPanelInner: React.FC = () => {
const { chatPanelOpen, setChatPanelOpen } = useLayout();
const { rooms, selectedRoomToken, selectRoom, connected } = useChat();
const queryClient = useQueryClient();
const { data: externalLinks } = useQuery({
queryKey: ['external-links'],
queryFn: () => configApi.getExternalLinks(),
@@ -34,6 +36,15 @@ const ChatPanelInner: React.FC = () => {
});
const nextcloudUrl = externalLinks?.nextcloud;
React.useEffect(() => {
if (chatPanelOpen) {
notificationsApi.dismissByType('nextcloud_talk').then(() => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['unreadNotificationCount'] });
}).catch(() => {});
}
}, [chatPanelOpen, queryClient]);
if (!chatPanelOpen) {
return (
<Paper

View File

@@ -185,7 +185,7 @@ const BookStackSearchWidget: React.FC = () => {
)}
{results.length > 0 && (
<Box sx={{ mt: 1 }}>
<Box sx={{ mt: 1, maxHeight: 300, overflow: 'auto' }}>
{results.map((result, index) => (
<ResultRow
key={result.id}

View File

@@ -8,55 +8,43 @@ import {
Divider,
} from '@mui/material';
import { Link as LinkIcon, OpenInNew } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { configApi } from '../../services/config';
import type { LinkCollection } from '../../types/config.types';
function LinksWidget() {
const { data: externalLinks } = useQuery({
queryKey: ['external-links'],
queryFn: () => configApi.getExternalLinks(),
staleTime: 10 * 60 * 1000,
});
const collections = externalLinks?.linkCollections ?? [];
const nonEmpty = collections.filter((c) => c.links.length > 0);
if (nonEmpty.length === 0) return null;
interface LinksWidgetProps {
collection: LinkCollection;
}
function LinksWidget({ collection }: LinksWidgetProps) {
return (
<>
{nonEmpty.map((collection) => (
<Card key={collection.id}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<LinkIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6">{collection.name}</Typography>
</Box>
<Divider sx={{ mb: 1.5 }} />
<Stack spacing={0.5}>
{collection.links.map((link, i) => (
<Link
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
underline="hover"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
py: 0.5,
}}
>
{link.name}
<OpenInNew sx={{ fontSize: 14, opacity: 0.6 }} />
</Link>
))}
</Stack>
</CardContent>
</Card>
))}
</>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<LinkIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6">{collection.name}</Typography>
</Box>
<Divider sx={{ mb: 1.5 }} />
<Stack spacing={0.5}>
{collection.links.map((link, i) => (
<Link
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
underline="hover"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
py: 0.5,
}}
>
{link.name}
<OpenInNew sx={{ fontSize: 14, opacity: 0.6 }} />
</Link>
))}
</Stack>
</CardContent>
</Card>
);
}

View File

@@ -138,6 +138,15 @@ const NotificationBell: React.FC = () => {
}
};
const handleDeleteAllRead = async () => {
try {
await notificationsApi.deleteAllRead();
setNotifications((prev) => prev.filter((n) => !n.gelesen));
} catch {
// non-critical
}
};
const open = Boolean(anchorEl);
const hasUnread = unreadCount > 0;
@@ -169,11 +178,18 @@ const NotificationBell: React.FC = () => {
<Typography variant="subtitle1" fontWeight={600}>
Benachrichtigungen
</Typography>
{unreadCount > 0 && (
<Button size="small" onClick={handleMarkAllRead} sx={{ fontSize: '0.75rem' }}>
Alle als gelesen markieren
</Button>
)}
<Box sx={{ display: 'flex', gap: 0.5 }}>
{notifications.some((n) => n.gelesen) && (
<Button size="small" onClick={handleDeleteAllRead} sx={{ fontSize: '0.75rem' }}>
Gelesene löschen
</Button>
)}
{unreadCount > 0 && (
<Button size="small" onClick={handleMarkAllRead} sx={{ fontSize: '0.75rem' }}>
Alle als gelesen markieren
</Button>
)}
</Box>
</Box>
<Divider />

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useState, useMemo } from 'react';
import {
Box,
Collapse,
Drawer,
IconButton,
List,
@@ -22,6 +23,8 @@ import {
AdminPanelSettings,
Settings,
Menu as MenuIcon,
ExpandMore,
ExpandLess,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
@@ -29,12 +32,32 @@ import { useAuth } from '../../contexts/AuthContext';
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
interface SubItem {
text: string;
tabIndex: number;
}
interface NavigationItem {
text: string;
icon: JSX.Element;
path: string;
subItems?: SubItem[];
}
const kalenderSubItems: SubItem[] = [
{ text: 'Veranstaltungen', tabIndex: 0 },
{ text: 'Fahrzeugbuchungen', tabIndex: 1 },
];
const adminSubItems: SubItem[] = [
{ text: 'Services', tabIndex: 0 },
{ text: 'System', tabIndex: 1 },
{ text: 'Benutzer', tabIndex: 2 },
{ text: 'Broadcast', tabIndex: 3 },
{ text: 'Banner', tabIndex: 4 },
{ text: 'Wartung', tabIndex: 5 },
];
const baseNavigationItems: NavigationItem[] = [
{
text: 'Dashboard',
@@ -45,6 +68,7 @@ const baseNavigationItems: NavigationItem[] = [
text: 'Kalender',
icon: <CalendarMonth />,
path: '/kalender',
subItems: kalenderSubItems,
},
{
text: 'Fahrzeuge',
@@ -77,6 +101,7 @@ const adminItem: NavigationItem = {
text: 'Admin',
icon: <AdminPanelSettings />,
path: '/admin',
subItems: adminSubItems,
};
const adminSettingsItem: NavigationItem = {
@@ -102,6 +127,19 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
return isAdmin ? [...baseNavigationItems, adminItem, adminSettingsItem] : baseNavigationItems;
}, [isAdmin]);
// Expand state for items with sub-items — auto-expand when route matches
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});
const isExpanded = (item: NavigationItem) => {
if (expandedItems[item.path] !== undefined) return expandedItems[item.path];
// Auto-expand when the current route matches
return location.pathname === item.path || location.pathname.startsWith(item.path + '/');
};
const toggleExpand = (path: string) => {
setExpandedItems((prev) => ({ ...prev, [path]: !isExpanded({ path } as NavigationItem) }));
};
const handleNavigation = (path: string) => {
navigate(path);
onMobileClose();
@@ -118,50 +156,101 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
<List sx={{ flex: 1 }}>
{navigationItems.map((item) => {
const isActive = location.pathname === item.path;
const hasSubItems = item.subItems && item.subItems.length > 0;
const expanded = hasSubItems && isExpanded(item);
return (
<ListItem key={item.text} disablePadding>
<Tooltip
title={item.text}
placement="right"
arrow
disableHoverListener={!sidebarCollapsed}
>
<ListItemButton
selected={isActive}
onClick={() => handleNavigation(item.path)}
aria-label={`Zu ${item.text} navigieren`}
sx={{
justifyContent: sidebarCollapsed ? 'center' : 'initial',
'&.Mui-selected': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.main',
},
'& .MuiListItemIcon-root': {
color: 'primary.contrastText',
},
},
}}
<Box key={item.text}>
<ListItem disablePadding>
<Tooltip
title={item.text}
placement="right"
arrow
disableHoverListener={!sidebarCollapsed}
>
<ListItemIcon
<ListItemButton
selected={isActive}
onClick={() => handleNavigation(hasSubItems ? `${item.path}?tab=0` : item.path)}
aria-label={`Zu ${item.text} navigieren`}
sx={{
color: isActive ? 'inherit' : 'text.secondary',
minWidth: sidebarCollapsed ? 0 : undefined,
justifyContent: 'center',
justifyContent: sidebarCollapsed ? 'center' : 'initial',
'&.Mui-selected': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.main',
},
'& .MuiListItemIcon-root': {
color: 'primary.contrastText',
},
},
}}
>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.text}
sx={{
display: sidebarCollapsed ? 'none' : 'block',
}}
/>
</ListItemButton>
</Tooltip>
</ListItem>
<ListItemIcon
sx={{
color: isActive ? 'inherit' : 'text.secondary',
minWidth: sidebarCollapsed ? 0 : undefined,
justifyContent: 'center',
}}
>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.text}
sx={{
display: sidebarCollapsed ? 'none' : 'block',
}}
/>
{hasSubItems && !sidebarCollapsed && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
toggleExpand(item.path);
}}
sx={{ color: isActive ? 'inherit' : 'text.secondary' }}
>
{expanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
</IconButton>
)}
</ListItemButton>
</Tooltip>
</ListItem>
{hasSubItems && !sidebarCollapsed && (
<Collapse in={expanded} timeout="auto" unmountOnExit>
<List disablePadding>
{item.subItems!.map((sub) => {
const subPath = `${item.path}?tab=${sub.tabIndex}`;
const isSubActive =
location.pathname === item.path &&
location.search === `?tab=${sub.tabIndex}`;
return (
<ListItemButton
key={sub.tabIndex}
onClick={() => handleNavigation(subPath)}
selected={isSubActive}
sx={{
pl: 4,
py: 0.5,
'&.Mui-selected': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.main',
},
},
}}
>
<ListItemText
primary={sub.text}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItemButton>
);
})}
</List>
</Collapse>
)}
</Box>
);
})}
</List>