Files
dashboard/frontend/src/components/shared/NotificationBell.tsx
2026-03-04 15:01:26 +01:00

230 lines
7.3 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Badge,
Box,
Button,
CircularProgress,
Divider,
IconButton,
List,
ListItem,
ListItemButton,
ListItemText,
Popover,
Tooltip,
Typography,
} from '@mui/material';
import {
Notifications as BellIcon,
NotificationsNone as BellEmptyIcon,
Circle as CircleIcon,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { notificationsApi } from '../../services/notifications';
import type { Notification, NotificationSchwere } from '../../types/notification.types';
const POLL_INTERVAL_MS = 60_000; // 60 seconds
function schwerebColor(schwere: NotificationSchwere): 'error' | 'warning' | 'info' {
if (schwere === 'fehler') return 'error';
if (schwere === 'warnung') return 'warning';
return 'info';
}
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 2) return 'Gerade eben';
if (minutes < 60) return `vor ${minutes} Min.`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `vor ${hours} Std.`;
const days = Math.floor(hours / 24);
return `vor ${days} Tag${days !== 1 ? 'en' : ''}`;
}
const NotificationBell: React.FC = () => {
const navigate = useNavigate();
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchUnreadCount = useCallback(async () => {
try {
const count = await notificationsApi.getUnreadCount();
setUnreadCount(count);
} catch {
// non-critical
}
}, []);
// Poll unread count every 60 seconds
useEffect(() => {
fetchUnreadCount();
pollTimerRef.current = setInterval(fetchUnreadCount, POLL_INTERVAL_MS);
return () => {
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
};
}, [fetchUnreadCount]);
const handleOpen = async (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
setLoadingList(true);
try {
const data = await notificationsApi.getNotifications();
setNotifications(data);
// Refresh count after loading full list
const count = await notificationsApi.getUnreadCount();
setUnreadCount(count);
} catch {
// non-critical
} finally {
setLoadingList(false);
}
};
const handleClose = () => {
setAnchorEl(null);
};
const handleClickNotification = async (n: Notification) => {
if (!n.gelesen) {
try {
await notificationsApi.markRead(n.id);
setNotifications((prev) =>
prev.map((item) => item.id === n.id ? { ...item, gelesen: true } : item)
);
setUnreadCount((c) => Math.max(0, c - 1));
} catch {
// non-critical
}
}
handleClose();
if (n.link) {
if (n.link.startsWith('http://') || n.link.startsWith('https://')) {
window.open(n.link, '_blank');
} else {
navigate(n.link);
}
}
};
const handleMarkAllRead = async () => {
try {
await notificationsApi.markAllRead();
setNotifications((prev) => prev.map((n) => ({ ...n, gelesen: true })));
setUnreadCount(0);
} catch {
// non-critical
}
};
const open = Boolean(anchorEl);
const hasUnread = unreadCount > 0;
return (
<>
<Tooltip title="Benachrichtigungen">
<IconButton
color="inherit"
onClick={handleOpen}
aria-label="Benachrichtigungen öffnen"
size="small"
>
<Badge badgeContent={unreadCount} color="error" invisible={!hasUnread}>
{hasUnread ? <BellIcon /> : <BellEmptyIcon />}
</Badge>
</IconButton>
</Tooltip>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{ sx: { width: 360, maxHeight: 500, display: 'flex', flexDirection: 'column' } }}
>
{/* Header */}
<Box sx={{ px: 2, py: 1.5, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle1" fontWeight={600}>
Benachrichtigungen
</Typography>
{unreadCount > 0 && (
<Button size="small" onClick={handleMarkAllRead} sx={{ fontSize: '0.75rem' }}>
Alle als gelesen markieren
</Button>
)}
</Box>
<Divider />
{/* Body */}
<Box sx={{ overflowY: 'auto', flexGrow: 1 }}>
{loadingList ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
) : notifications.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 4 }}>
<BellEmptyIcon sx={{ fontSize: 40, color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
Keine Benachrichtigungen
</Typography>
</Box>
) : (
<List disablePadding>
{notifications.map((n, idx) => (
<React.Fragment key={n.id}>
{idx > 0 && <Divider component="li" />}
<ListItem disablePadding>
<ListItemButton
onClick={() => handleClickNotification(n)}
sx={{
py: 1.5,
px: 2,
bgcolor: n.gelesen ? 'transparent' : 'action.hover',
alignItems: 'flex-start',
gap: 1,
}}
>
<CircleIcon
sx={{
fontSize: 8,
mt: 0.75,
flexShrink: 0,
color: n.gelesen ? 'transparent' : `${schwerebColor(n.schwere)}.main`,
}}
/>
<ListItemText
primary={
<Typography variant="body2" fontWeight={n.gelesen ? 400 : 600}>
{n.titel}
</Typography>
}
secondary={
<Box>
<Typography variant="caption" color="text.secondary" display="block">
{n.nachricht}
</Typography>
<Typography variant="caption" color="text.disabled">
{formatRelative(n.erstellt_am)}
</Typography>
</Box>
}
disableTypography
/>
</ListItemButton>
</ListItem>
</React.Fragment>
))}
</List>
)}
</Box>
</Popover>
</>
);
};
export default NotificationBell;