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;

View File

@@ -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,
};
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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' }}>

View File

@@ -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 }));
};

View File

@@ -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);

View File

@@ -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 }));
};

View 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');
},
};

View File

@@ -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;

View File

@@ -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;
}

View 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;
}