From 67b7d5ccd2b4227a6a986eeaeaded9c15f549b9c Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Thu, 12 Mar 2026 17:51:57 +0100 Subject: [PATCH] resolve issues with new features --- frontend/src/components/chat/ChatPanel.tsx | 41 ++++++------ .../components/shared/NotificationBell.tsx | 44 ++++++++++--- frontend/src/components/shared/Sidebar.tsx | 4 +- frontend/src/contexts/NotificationContext.tsx | 32 ++++++++++ frontend/src/pages/AusruestungDetail.tsx | 4 +- frontend/src/pages/FahrzeugDetail.tsx | 4 +- frontend/src/pages/Fahrzeuge.tsx | 4 +- frontend/src/pages/Kalender.tsx | 63 ++++++++++--------- frontend/src/pages/Mitglieder.tsx | 6 +- frontend/src/pages/Veranstaltungen.tsx | 4 +- 10 files changed, 138 insertions(+), 68 deletions(-) diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index f86d108..02b68fd 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -37,30 +37,29 @@ const ChatPanelInner: React.FC = () => { }); const nextcloudUrl = externalLinks?.nextcloud; - // Keep a ref to rooms so the effect can access the latest list without - // re-running every time room data refreshes. - const roomsRef = React.useRef(rooms); - roomsRef.current = rooms; - + // Dismiss internal notifications when panel opens React.useEffect(() => { - if (chatPanelOpen) { - // Dismiss our internal notification-centre entries - notificationsApi.dismissByType('nextcloud_talk').then(() => { - queryClient.invalidateQueries({ queryKey: ['notifications'] }); - queryClient.invalidateQueries({ queryKey: ['unreadNotificationCount'] }); - }).catch(() => {}); - - // Also mark all unread rooms as read directly in Nextcloud so that - // Nextcloud's own notification badges clear as well. - roomsRef.current - .filter((r) => r.unreadMessages > 0) - .forEach((r) => { - nextcloudApi.markAsRead(r.token).catch(() => {}); - }); - queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); - } + if (!chatPanelOpen) return; + notificationsApi.dismissByType('nextcloud_talk').then(() => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + queryClient.invalidateQueries({ queryKey: ['unreadNotificationCount'] }); + }).catch(() => {}); }, [chatPanelOpen, queryClient]); + // Mark unread rooms as read in Nextcloud whenever panel is open and rooms update + React.useEffect(() => { + if (!chatPanelOpen) return; + rooms.filter((r) => r.unreadMessages > 0).forEach((r) => { + nextcloudApi.markAsRead(r.token).catch(() => {}); + }); + }, [chatPanelOpen, rooms]); + + // Mark the selected room as read when a conversation is opened + React.useEffect(() => { + if (!selectedRoomToken) return; + nextcloudApi.markAsRead(selectedRoomToken).catch(() => {}); + }, [selectedRoomToken]); + if (!chatPanelOpen) { return ( { const navigate = useNavigate(); + const { showNotificationToast } = useNotification(); const [unreadCount, setUnreadCount] = useState(0); const [notifications, setNotifications] = useState([]); const [loadingList, setLoadingList] = useState(false); const [anchorEl, setAnchorEl] = useState(null); const pollTimerRef = useRef | null>(null); + // Track known notification IDs to detect new ones; null = not yet initialized + const knownIdsRef = useRef | null>(null); - const fetchUnreadCount = useCallback(async () => { + const fetchAndToastNew = useCallback(async () => { try { - const count = await notificationsApi.getUnreadCount(); + const data = await notificationsApi.getNotifications(); + const unread = data.filter((n) => !n.gelesen); + const count = unread.length; setUnreadCount(count); + + if (knownIdsRef.current === null) { + // First load — initialize without toasting + knownIdsRef.current = new Set(data.map((n) => n.id)); + return; + } + + // Find notifications we haven't seen before + const newOnes = unread.filter((n) => !knownIdsRef.current!.has(n.id)); + newOnes.forEach((n) => { + knownIdsRef.current!.add(n.id); + const severity = n.schwere === 'fehler' ? 'error' : n.schwere === 'warnung' ? 'warning' : 'info'; + showNotificationToast(n.titel, severity); + }); + // Also add all known IDs to avoid re-toasting on re-fetch + data.forEach((n) => knownIdsRef.current!.add(n.id)); } catch { // non-critical } - }, []); + }, [showNotificationToast]); - // Poll unread count every 60 seconds + // Poll for new notifications every 15 seconds useEffect(() => { - fetchUnreadCount(); - pollTimerRef.current = setInterval(fetchUnreadCount, POLL_INTERVAL_MS); + fetchAndToastNew(); + pollTimerRef.current = setInterval(fetchAndToastNew, POLL_INTERVAL_MS); return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); }; - }, [fetchUnreadCount]); + }, [fetchAndToastNew]); const handleOpen = async (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -88,6 +110,12 @@ const NotificationBell: React.FC = () => { try { const data = await notificationsApi.getNotifications(); setNotifications(data); + // Mark all as known so we don't toast them again + if (knownIdsRef.current === null) { + knownIdsRef.current = new Set(data.map((n) => n.id)); + } else { + data.forEach((n) => knownIdsRef.current!.add(n.id)); + } // Refresh count after loading full list const count = await notificationsApi.getUnreadCount(); setUnreadCount(count); diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 34ee2eb..91902dc 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -135,7 +135,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { const vehicleSubItems: SubItem[] = useMemo( () => (vehicleList ?? []).map((v) => ({ - text: v.kurzname ?? v.bezeichnung, + text: v.bezeichnung ?? v.kurzname, path: `/fahrzeuge/${v.id}`, })), [vehicleList], @@ -196,7 +196,7 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { > handleNavigation(hasSubItems ? item.subItems![0].path : item.path)} + onClick={() => handleNavigation(item.path)} aria-label={`Zu ${item.text} navigieren`} sx={{ justifyContent: sidebarCollapsed ? 'center' : 'initial', diff --git a/frontend/src/contexts/NotificationContext.tsx b/frontend/src/contexts/NotificationContext.tsx index e61e70a..ca3fea3 100644 --- a/frontend/src/contexts/NotificationContext.tsx +++ b/frontend/src/contexts/NotificationContext.tsx @@ -12,6 +12,7 @@ interface NotificationContextType { showError: (message: string) => void; showWarning: (message: string) => void; showInfo: (message: string) => void; + showNotificationToast: (message: string, severity?: AlertColor) => void; } const NotificationContext = createContext(undefined); @@ -24,6 +25,9 @@ export const NotificationProvider: React.FC = ({ chil const [_notifications, setNotifications] = useState([]); const [currentNotification, setCurrentNotification] = useState(null); + // Left-side toast queue for new backend notifications + const [toastQueue, setToastQueue] = useState([]); + const addNotification = useCallback((message: string, severity: AlertColor) => { const id = Date.now(); const notification: Notification = { id, message, severity }; @@ -52,6 +56,11 @@ export const NotificationProvider: React.FC = ({ chil addNotification(message, 'info'); }, [addNotification]); + const showNotificationToast = useCallback((message: string, severity: AlertColor = 'info') => { + const id = Date.now() + Math.random(); + setToastQueue((prev) => [...prev, { id, message, severity }]); + }, []); + const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => { if (reason === 'clickaway') { return; @@ -71,16 +80,23 @@ export const NotificationProvider: React.FC = ({ chil }, 200); }; + const handleToastClose = (_event?: React.SyntheticEvent | Event, reason?: string) => { + if (reason === 'clickaway') return; + setToastQueue((prev) => prev.slice(1)); + }; + const value: NotificationContextType = { showSuccess, showError, showWarning, showInfo, + showNotificationToast, }; return ( {children} + {/* Right-side: action feedback */} = ({ chil {currentNotification?.message} + {/* Left-side: new backend notification toasts */} + 0} + autoHideDuration={5000} + onClose={handleToastClose} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + + {toastQueue[0]?.message} + + ); }; diff --git a/frontend/src/pages/AusruestungDetail.tsx b/frontend/src/pages/AusruestungDetail.tsx index b92ac18..0f31e4c 100644 --- a/frontend/src/pages/AusruestungDetail.tsx +++ b/frontend/src/pages/AusruestungDetail.tsx @@ -44,6 +44,7 @@ import { } from '@mui/icons-material'; import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useLayout } from '../contexts/LayoutContext'; import { equipmentApi } from '../services/equipment'; import { fromGermanDate } from '../utils/dateInput'; import { @@ -351,6 +352,7 @@ interface WartungTabProps { } const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdded, canWrite }) => { + const { chatPanelOpen } = useLayout(); const [dialogOpen, setDialogOpen] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); @@ -446,7 +448,7 @@ const WartungTab: React.FC = ({ equipmentId, wartungslog, onAdd color="primary" size="small" aria-label="Wartung eintragen" - sx={{ position: 'fixed', bottom: 32, right: 32 }} + sx={{ position: 'fixed', bottom: 32, right: chatPanelOpen ? 376 : 80, transition: 'right 225ms cubic-bezier(0.4, 0, 0.6, 1)' }} onClick={() => { setForm(emptyForm); setSaveError(null); setDialogOpen(true); }} > diff --git a/frontend/src/pages/FahrzeugDetail.tsx b/frontend/src/pages/FahrzeugDetail.tsx index 09c9036..0b83fe5 100644 --- a/frontend/src/pages/FahrzeugDetail.tsx +++ b/frontend/src/pages/FahrzeugDetail.tsx @@ -55,6 +55,7 @@ import { } from '@mui/icons-material'; import { useNavigate, useParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useLayout } from '../contexts/LayoutContext'; import { vehiclesApi } from '../services/vehicles'; import { fromGermanDate } from '../utils/dateInput'; import { equipmentApi } from '../services/equipment'; @@ -322,6 +323,7 @@ const WARTUNG_ART_ICONS: Record = { }; const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => { + const { chatPanelOpen } = useLayout(); const [dialogOpen, setDialogOpen] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); @@ -398,7 +400,7 @@ const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdde color="primary" size="small" aria-label="Wartung eintragen" - sx={{ position: 'fixed', bottom: 32, right: 32 }} + sx={{ position: 'fixed', bottom: 32, right: chatPanelOpen ? 376 : 80, transition: 'right 225ms cubic-bezier(0.4, 0, 0.6, 1)' }} onClick={() => { setForm(emptyForm); setDialogOpen(true); }} > diff --git a/frontend/src/pages/Fahrzeuge.tsx b/frontend/src/pages/Fahrzeuge.tsx index 47ebc51..ef29f01 100644 --- a/frontend/src/pages/Fahrzeuge.tsx +++ b/frontend/src/pages/Fahrzeuge.tsx @@ -29,6 +29,7 @@ import { } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useLayout } from '../contexts/LayoutContext'; import { vehiclesApi } from '../services/vehicles'; import { equipmentApi } from '../services/equipment'; import type { VehicleEquipmentWarning } from '../types/equipment.types'; @@ -271,6 +272,7 @@ const VehicleCard: React.FC = ({ vehicle, onClick, warnings = function Fahrzeuge() { const navigate = useNavigate(); const { isAdmin } = usePermissions(); + const { chatPanelOpen } = useLayout(); const [vehicles, setVehicles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -415,7 +417,7 @@ function Fahrzeuge() { navigate('/fahrzeuge/neu')} > diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 39f2bbe..0298438 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -70,6 +70,7 @@ import { } from '@mui/icons-material'; import { useNavigate, useSearchParams } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useLayout } from '../contexts/LayoutContext'; import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDate, isValidGermanDateTime } from '../utils/dateInput'; import { useAuth } from '../contexts/AuthContext'; import { useNotification } from '../contexts/NotificationContext'; @@ -1681,6 +1682,7 @@ export default function Kalender() { const [searchParams] = useSearchParams(); const { user } = useAuth(); const notification = useNotification(); + const { chatPanelOpen } = useLayout(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -2218,34 +2220,6 @@ export default function Kalender() { - {/* Category filter */} - {kategorien.length > 0 && ( - - setSelectedKategorie('all')} - color={selectedKategorie === 'all' ? 'primary' : 'default'} - variant={selectedKategorie === 'all' ? 'filled' : 'outlined'} - size="small" - /> - {kategorien.map((k) => ( - - setSelectedKategorie(selectedKategorie === k.id ? 'all' : k.id) - } - size="small" - sx={{ - bgcolor: selectedKategorie === k.id ? k.farbe : undefined, - color: selectedKategorie === k.id ? 'white' : undefined, - }} - variant={selectedKategorie === k.id ? 'filled' : 'outlined'} - /> - ))} - - )} - {/* Kategorien verwalten */} {canWriteEvents && ( @@ -2296,6 +2270,34 @@ export default function Kalender() { + {/* Category filter — between controls and navigation */} + {kategorien.length > 0 && ( + + setSelectedKategorie('all')} + color={selectedKategorie === 'all' ? 'primary' : 'default'} + variant={selectedKategorie === 'all' ? 'filled' : 'outlined'} + size="small" + /> + {kategorien.map((k) => ( + + setSelectedKategorie(selectedKategorie === k.id ? 'all' : k.id) + } + size="small" + sx={{ + bgcolor: selectedKategorie === k.id ? k.farbe : undefined, + color: selectedKategorie === k.id ? 'white' : undefined, + }} + variant={selectedKategorie === k.id ? 'filled' : 'outlined'} + /> + ))} + + )} + {/* Navigation */} @@ -2554,7 +2556,7 @@ export default function Kalender() { {canWriteEvents && ( { setVeranstEditing(null); setVeranstFormOpen(true); @@ -2562,7 +2564,6 @@ export default function Kalender() { > - )} {/* Day Popover */} diff --git a/frontend/src/pages/Mitglieder.tsx b/frontend/src/pages/Mitglieder.tsx index 2ea2bab..005e22c 100644 --- a/frontend/src/pages/Mitglieder.tsx +++ b/frontend/src/pages/Mitglieder.tsx @@ -33,6 +33,7 @@ import { } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useLayout } from '../contexts/LayoutContext'; import { useAuth } from '../contexts/AuthContext'; import { membersService } from '../services/members'; import { @@ -74,7 +75,7 @@ function useDebounce(value: T, delay: number): T { function Mitglieder() { const navigate = useNavigate(); const { user } = useAuth(); - const canWrite = useCanWrite(); + const { chatPanelOpen } = useLayout(); const canWrite = useCanWrite(); // --- redirect non-admin/non-kommando users to their own profile --- useEffect(() => { @@ -434,7 +435,8 @@ function Mitglieder() { sx={{ position: 'fixed', bottom: 32, - right: 32, + right: chatPanelOpen ? 376 : 80, + transition: 'right 225ms cubic-bezier(0.4, 0, 0.6, 1)', zIndex: (theme) => theme.zIndex.speedDial, }} > diff --git a/frontend/src/pages/Veranstaltungen.tsx b/frontend/src/pages/Veranstaltungen.tsx index 5bb9cd5..436dfda 100644 --- a/frontend/src/pages/Veranstaltungen.tsx +++ b/frontend/src/pages/Veranstaltungen.tsx @@ -51,6 +51,7 @@ import { Delete as DeleteIcon, } from '@mui/icons-material'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useLayout } from '../contexts/LayoutContext'; import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime } from '../utils/dateInput'; import { useAuth } from '../contexts/AuthContext'; import { useNotification } from '../contexts/NotificationContext'; @@ -1004,6 +1005,7 @@ function EventListView({ events, canWrite, onEdit, onCancel, onDelete }: ListVie export default function Veranstaltungen() { const { user } = useAuth(); + const { chatPanelOpen } = useLayout(); const notification = useNotification(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -1317,7 +1319,7 @@ export default function Veranstaltungen() { { setEditingEvent(null); setFormOpen(true); }} >