add features

This commit is contained in:
Matthias Hochmeister
2026-03-03 17:01:53 +01:00
parent 92b05726d4
commit 5a6fc85a75
30 changed files with 1104 additions and 198 deletions

View File

@@ -1,139 +1,52 @@
import React from 'react';
import {
Card,
CardContent,
Avatar,
Typography,
Box,
Chip,
} from '@mui/material';
import { Avatar, Box, Paper, Typography } from '@mui/material';
import { User } from '../../types/auth.types';
interface UserProfileProps {
user: User;
}
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
// Get first letter of name for avatar
const getInitials = (name: string): string => {
return name.charAt(0).toUpperCase();
};
function getGreeting(): string {
const h = new Date().getHours();
if (h >= 5 && h <= 10) return 'Guten Morgen';
if (h >= 11 && h <= 13) return 'Mahlzeit';
if (h >= 14 && h <= 16) return 'Guten Nachmittag';
if (h >= 17 && h <= 21) return 'Guten Abend';
return 'Gute Nacht';
}
// Format date (placeholder until we have actual dates)
const formatDate = (date?: string): string => {
if (!date) return 'Nicht verfügbar';
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
const firstName = user.given_name || user.name.split(' ')[0];
const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || '') || user.name?.[0] || '?';
return (
<Card
<Paper
elevation={0}
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
borderRadius: 2,
px: 3,
py: 1.5,
}}
>
<CardContent>
<Box
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: { xs: 'column', sm: 'row' },
gap: 3,
width: 40,
height: 40,
bgcolor: 'rgba(255,255,255,0.2)',
fontSize: '1rem',
fontWeight: 'bold',
}}
>
{/* Avatar */}
<Avatar
sx={{
width: 80,
height: 80,
bgcolor: 'rgba(255, 255, 255, 0.2)',
fontSize: '2rem',
fontWeight: 'bold',
}}
>
{getInitials(user.name)}
</Avatar>
{/* User Info */}
<Box sx={{ flex: 1, textAlign: { xs: 'center', sm: 'left' } }}>
<Typography variant="h5" component="div" gutterBottom>
Willkommen zurück, {user.given_name || user.name.split(' ')[0]}!
</Typography>
<Typography variant="body2" sx={{ opacity: 0.75, mb: 0.5 }}>
{user.name}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
{user.email}
</Typography>
{user.preferred_username && (
<Typography variant="body2" sx={{ opacity: 0.9 }}>
@{user.preferred_username}
</Typography>
)}
<Box
sx={{
display: 'flex',
gap: 1,
mt: 2,
flexWrap: 'wrap',
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Chip
label="Aktiv"
size="small"
sx={{
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
}}
/>
{user.groups && user.groups.length > 0 && (
<Chip
label={`${user.groups.length} Gruppe${user.groups.length > 1 ? 'n' : ''}`}
size="small"
sx={{
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
}}
/>
)}
</Box>
</Box>
{/* Additional Info */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
textAlign: { xs: 'center', sm: 'right' },
}}
>
<Box>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
Letzter Login
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
Heute
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
Mitglied seit
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
{formatDate()}
</Typography>
</Box>
</Box>
</Box>
</CardContent>
</Card>
{initials.toUpperCase()}
</Avatar>
<Typography variant="h6" sx={{ fontWeight: 500 }}>
{getGreeting()}, {firstName}!
</Typography>
</Box>
</Paper>
);
};

View File

@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import {
AppBar,
Badge,
Toolbar,
Typography,
IconButton,
@@ -21,7 +20,7 @@ import {
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
import { atemschutzApi } from '../../services/atemschutz';
import NotificationBell from './NotificationBell';
interface HeaderProps {
onMenuClick: () => void;
@@ -31,22 +30,6 @@ function Header({ onMenuClick }: HeaderProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [warningCount, setWarningCount] = useState(0);
// Fetch personal warning count for badge
useEffect(() => {
if (!user) return;
atemschutzApi.getMyStatus()
.then((record) => {
if (!record) return;
let count = 0;
const THRESHOLD = 60;
if (record.untersuchung_tage_rest !== null && record.untersuchung_tage_rest <= THRESHOLD) count++;
if (record.leistungstest_tage_rest !== null && record.leistungstest_tage_rest <= THRESHOLD) count++;
setWarningCount(count);
})
.catch(() => { /* non-critical */ });
}, [user]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -103,30 +86,26 @@ function Header({ onMenuClick }: HeaderProps) {
{user && (
<>
<NotificationBell />
<IconButton
onClick={handleMenuOpen}
size="small"
aria-label="Benutzerkonto"
aria-controls="user-menu"
aria-haspopup="true"
sx={{ ml: 1 }}
>
<Badge
badgeContent={warningCount}
color="error"
overlap="circular"
invisible={warningCount === 0}
<Avatar
sx={{
bgcolor: 'secondary.main',
width: 32,
height: 32,
fontSize: '0.875rem',
}}
>
<Avatar
sx={{
bgcolor: 'secondary.main',
width: 32,
height: 32,
fontSize: '0.875rem',
}}
>
{getInitials()}
</Avatar>
</Badge>
{getInitials()}
</Avatar>
</IconButton>
<Menu
@@ -154,11 +133,6 @@ function Header({ onMenuClick }: HeaderProps) {
<Typography variant="body2" color="text.secondary">
{user.email}
</Typography>
{warningCount > 0 && (
<Typography variant="caption" color="error.main" sx={{ display: 'block', mt: 0.5 }}>
{warningCount} persönliche{warningCount !== 1 ? ' Warnungen' : ' Warnung'}
</Typography>
)}
</Box>
<Divider />
<MenuItem onClick={handleProfile}>

View File

@@ -0,0 +1,225 @@
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) {
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;