add features
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
225
frontend/src/components/shared/NotificationBell.tsx
Normal file
225
frontend/src/components/shared/NotificationBell.tsx
Normal 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;
|
||||
@@ -1,13 +1,27 @@
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { AusruestungKategorie } from '../types/equipment.types';
|
||||
|
||||
export function usePermissions() {
|
||||
const { user } = useAuth();
|
||||
const groups = user?.groups ?? [];
|
||||
|
||||
const isAdmin = groups.includes('dashboard_admin');
|
||||
const isFahrmeister = groups.includes('dashboard_fahrmeister');
|
||||
const isZeugmeister = groups.includes('dashboard_zeugmeister');
|
||||
|
||||
return {
|
||||
isAdmin: groups.includes('dashboard_admin'),
|
||||
canChangeStatus: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'),
|
||||
canManageEquipment: groups.includes('dashboard_admin') || groups.includes('dashboard_fahrmeister'),
|
||||
isAdmin,
|
||||
isFahrmeister,
|
||||
isZeugmeister,
|
||||
canChangeStatus: isAdmin || isFahrmeister || isZeugmeister,
|
||||
canManageEquipment: isAdmin || isFahrmeister || isZeugmeister,
|
||||
canManageMotorizedEquipment: isAdmin || isFahrmeister,
|
||||
canManageNonMotorizedEquipment: isAdmin || isZeugmeister,
|
||||
canManageCategory: (kategorie: AusruestungKategorie | null | undefined): boolean => {
|
||||
if (isAdmin) return true;
|
||||
if (!kategorie) return false;
|
||||
return kategorie.motorisiert ? isFahrmeister : isZeugmeister;
|
||||
},
|
||||
groups,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -563,7 +563,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdd
|
||||
function AusruestungDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin, canChangeStatus } = usePermissions();
|
||||
const { isAdmin, canManageCategory } = usePermissions();
|
||||
const notification = useNotification();
|
||||
|
||||
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
|
||||
@@ -630,6 +630,16 @@ function AusruestungDetailPage() {
|
||||
equipment.pruefung_tage_bis_faelligkeit !== null &&
|
||||
equipment.pruefung_tage_bis_faelligkeit < 0;
|
||||
|
||||
// Derive an inline category object so canManageCategory can do the motorisiert check
|
||||
const equipmentKategorie = {
|
||||
id: equipment.kategorie_id,
|
||||
name: equipment.kategorie_name,
|
||||
kurzname: equipment.kategorie_kurzname,
|
||||
sortierung: 0,
|
||||
motorisiert: equipment.kategorie_motorisiert,
|
||||
};
|
||||
const canWrite = canManageCategory(equipmentKategorie);
|
||||
|
||||
const subtitle = [
|
||||
equipment.kategorie_name,
|
||||
equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null,
|
||||
@@ -665,7 +675,7 @@ function AusruestungDetailPage() {
|
||||
label={AusruestungStatusLabel[equipment.status]}
|
||||
color={STATUS_CHIP_COLOR[equipment.status]}
|
||||
/>
|
||||
{canChangeStatus && (
|
||||
{canWrite && (
|
||||
<Tooltip title="Gerät bearbeiten">
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -714,7 +724,7 @@ function AusruestungDetailPage() {
|
||||
<UebersichtTab
|
||||
equipment={equipment}
|
||||
onStatusUpdated={fetchEquipment}
|
||||
canChangeStatus={canChangeStatus}
|
||||
canChangeStatus={canWrite}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
@@ -723,7 +733,7 @@ function AusruestungDetailPage() {
|
||||
equipmentId={equipment.id}
|
||||
wartungslog={equipment.wartungslog ?? []}
|
||||
onAdded={fetchEquipment}
|
||||
canWrite={canChangeStatus}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
|
||||
@@ -82,11 +82,11 @@ function toDateInput(iso: string | null | undefined): string {
|
||||
function AusruestungForm() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { canChangeStatus } = usePermissions();
|
||||
const { canManageEquipment } = usePermissions();
|
||||
const isEditMode = Boolean(id);
|
||||
|
||||
// -- Permission guard: only authorized users may create or edit equipment ----
|
||||
if (!canChangeStatus) {
|
||||
if (!canManageEquipment) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
|
||||
@@ -13,8 +13,6 @@ import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
|
||||
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
|
||||
import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
|
||||
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
|
||||
import PersonalWarningsBanner from '../components/dashboard/PersonalWarningsBanner';
|
||||
|
||||
function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const canViewAtemschutz = user?.groups?.some(g =>
|
||||
@@ -56,17 +54,6 @@ function Dashboard() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Personal Warnings Banner — full width, conditionally rendered */}
|
||||
{user && (
|
||||
<Box sx={{ gridColumn: '1 / -1' }}>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '150ms' }}>
|
||||
<Box>
|
||||
<PersonalWarningsBanner user={user} />
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Vehicle Status Card */}
|
||||
<Box>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '300ms' }}>
|
||||
|
||||
@@ -837,6 +837,19 @@ function VeranstaltungFormDialog({
|
||||
}, [open, editingEvent]);
|
||||
|
||||
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
|
||||
if (field === 'kategorie_id' && !editingEvent) {
|
||||
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
|
||||
const kat = kategorien.find((k) => k.id === value);
|
||||
if (kat) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
kategorie_id: value as string | null,
|
||||
alle_gruppen: kat.alle_gruppen,
|
||||
zielgruppen: kat.alle_gruppen ? [] : kat.zielgruppen,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ interface KategorieFormData {
|
||||
beschreibung: string;
|
||||
farbe: string;
|
||||
icon: string;
|
||||
alle_gruppen: boolean;
|
||||
zielgruppen: string[];
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ const EMPTY_FORM: KategorieFormData = {
|
||||
beschreibung: '',
|
||||
farbe: '#1976d2',
|
||||
icon: '',
|
||||
alle_gruppen: false,
|
||||
zielgruppen: [],
|
||||
};
|
||||
|
||||
@@ -80,7 +82,8 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
|
||||
beschreibung: editing.beschreibung ?? '',
|
||||
farbe: editing.farbe,
|
||||
icon: editing.icon ?? '',
|
||||
zielgruppen: editing.zielgruppen ?? [],
|
||||
alle_gruppen: editing.alle_gruppen ?? false,
|
||||
zielgruppen: editing.alle_gruppen ? [] : (editing.zielgruppen ?? []),
|
||||
});
|
||||
} else {
|
||||
setForm({ ...EMPTY_FORM });
|
||||
@@ -112,7 +115,8 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
|
||||
beschreibung: form.beschreibung.trim() || undefined,
|
||||
farbe: form.farbe,
|
||||
icon: form.icon.trim() || undefined,
|
||||
zielgruppen: form.zielgruppen,
|
||||
alle_gruppen: form.alle_gruppen,
|
||||
zielgruppen: form.alle_gruppen ? [] : form.zielgruppen,
|
||||
};
|
||||
if (editing) {
|
||||
await eventsApi.updateKategorie(editing.id, payload);
|
||||
@@ -188,7 +192,22 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
|
||||
placeholder="z.B. EmojiEvents"
|
||||
helperText="Name eines MUI Material Icons"
|
||||
/>
|
||||
{/* Group checkboxes */}
|
||||
{/* alle_gruppen toggle */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={form.alle_gruppen}
|
||||
onChange={(e) => setForm((prev) => ({
|
||||
...prev,
|
||||
alle_gruppen: e.target.checked,
|
||||
zielgruppen: e.target.checked ? [] : prev.zielgruppen,
|
||||
}))}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label="Alle Mitglieder"
|
||||
/>
|
||||
{/* Group checkboxes — disabled when alle_gruppen is set */}
|
||||
{groups.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
@@ -203,6 +222,7 @@ function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieD
|
||||
checked={form.zielgruppen.includes(group.id)}
|
||||
onChange={() => handleGroupToggle(group.id)}
|
||||
size="small"
|
||||
disabled={form.alle_gruppen}
|
||||
/>
|
||||
}
|
||||
label={group.label}
|
||||
@@ -435,7 +455,15 @@ export default function VeranstaltungKategorien() {
|
||||
{/* Gruppen */}
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{(kat.zielgruppen ?? []).length === 0
|
||||
{kat.alle_gruppen ? (
|
||||
<Chip
|
||||
label="Alle Mitglieder"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
) : (kat.zielgruppen ?? []).length === 0
|
||||
? <Typography variant="body2" color="text.secondary">—</Typography>
|
||||
: (kat.zielgruppen ?? []).map((gId) => {
|
||||
const group = groups.find((g) => g.id === gId);
|
||||
|
||||
@@ -610,6 +610,19 @@ function EventFormDialog({
|
||||
}, [open, editingEvent]);
|
||||
|
||||
const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => {
|
||||
if (field === 'kategorie_id' && !editingEvent) {
|
||||
// Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events)
|
||||
const kat = kategorien.find((k) => k.id === value);
|
||||
if (kat) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
kategorie_id: value as string | null,
|
||||
alle_gruppen: kat.alle_gruppen,
|
||||
zielgruppen: kat.alle_gruppen ? [] : kat.zielgruppen,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
|
||||
31
frontend/src/services/notifications.ts
Normal file
31
frontend/src/services/notifications.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { api } from './api';
|
||||
import type { Notification } from '../types/notification.types';
|
||||
|
||||
async function unwrap<T>(
|
||||
promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>
|
||||
): Promise<T> {
|
||||
const response = await promise;
|
||||
if (response.data?.data === undefined || response.data?.data === null) {
|
||||
throw new Error('Invalid API response');
|
||||
}
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
async getNotifications(): Promise<Notification[]> {
|
||||
return unwrap(api.get<{ success: boolean; data: Notification[] }>('/api/notifications'));
|
||||
},
|
||||
|
||||
async getUnreadCount(): Promise<number> {
|
||||
const data = await unwrap(api.get<{ success: boolean; data: { count: number } }>('/api/notifications/count'));
|
||||
return data.count;
|
||||
},
|
||||
|
||||
async markRead(id: string): Promise<void> {
|
||||
await api.patch(`/api/notifications/${id}/read`);
|
||||
},
|
||||
|
||||
async markAllRead(): Promise<void> {
|
||||
await api.post('/api/notifications/mark-all-read');
|
||||
},
|
||||
};
|
||||
@@ -21,10 +21,11 @@ export type AusruestungWartungslogArt = 'Prüfung' | 'Reparatur' | 'Sonstiges';
|
||||
// ── Lookup Entity ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AusruestungKategorie {
|
||||
id: string;
|
||||
name: string;
|
||||
kurzname: string;
|
||||
id: string;
|
||||
name: string;
|
||||
kurzname: string;
|
||||
sortierung: number;
|
||||
motorisiert: boolean;
|
||||
}
|
||||
|
||||
// ── API Response Shapes ──────────────────────────────────────────────────────
|
||||
@@ -35,6 +36,7 @@ export interface AusruestungListItem {
|
||||
kategorie_id: string;
|
||||
kategorie_name: string;
|
||||
kategorie_kurzname: string;
|
||||
kategorie_motorisiert: boolean;
|
||||
seriennummer: string | null;
|
||||
inventarnummer: string | null;
|
||||
hersteller: string | null;
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface VeranstaltungKategorie {
|
||||
farbe: string; // hex color e.g. '#1976d2'
|
||||
icon?: string | null; // MUI icon name
|
||||
zielgruppen: string[];
|
||||
alle_gruppen: boolean;
|
||||
erstellt_am: string;
|
||||
aktualisiert_am: string;
|
||||
}
|
||||
|
||||
20
frontend/src/types/notification.types.ts
Normal file
20
frontend/src/types/notification.types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notification types — mirrors backend model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type NotificationSchwere = 'info' | 'warnung' | 'fehler';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
user_id: string;
|
||||
typ: string;
|
||||
titel: string;
|
||||
nachricht: string;
|
||||
schwere: NotificationSchwere;
|
||||
gelesen: boolean;
|
||||
gelesen_am: string | null;
|
||||
link: string | null;
|
||||
quell_id: string | null;
|
||||
quell_typ: string | null;
|
||||
erstellt_am: string;
|
||||
}
|
||||
Reference in New Issue
Block a user