feat: responsive widgets, atemschutz permission UX, event hard-delete

- fix dashboard grid: use auto-fill instead of auto-fit for equal-width widgets
- atemschutz: skip stats/members API calls for non-privileged users, hide
  empty Aktionen column, add personal status subtitle
- kalender: add permanent delete option for events with confirmation dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-03-03 14:59:08 +01:00
parent 5dfaf7db54
commit b3a2fd9ff9
3 changed files with 117 additions and 38 deletions

View File

@@ -183,6 +183,7 @@ function Atemschutz() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
if (canViewAll) {
const [traegerData, statsData, membersData] = await Promise.all([ const [traegerData, statsData, membersData] = await Promise.all([
atemschutzApi.getAll(), atemschutzApi.getAll(),
atemschutzApi.getStats(), atemschutzApi.getStats(),
@@ -191,12 +192,16 @@ function Atemschutz() {
setTraeger(traegerData); setTraeger(traegerData);
setStats(statsData); setStats(statsData);
setMembers(membersData.items); setMembers(membersData.items);
} else {
const traegerData = await atemschutzApi.getAll();
setTraeger(traegerData);
}
} catch { } catch {
setError('Atemschutzdaten konnten nicht geladen werden. Bitte versuchen Sie es erneut.'); setError('Atemschutzdaten konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [canViewAll]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@@ -361,6 +366,11 @@ function Atemschutz() {
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}> <Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Atemschutzverwaltung Atemschutzverwaltung
</Typography> </Typography>
{!loading && !canViewAll && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Dein persönlicher Atemschutz-Status
</Typography>
)}
{!loading && stats && canViewAll && ( {!loading && stats && canViewAll && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@@ -471,7 +481,7 @@ function Atemschutz() {
<TableCell>Untersuchung gültig bis</TableCell> <TableCell>Untersuchung gültig bis</TableCell>
<TableCell>Leistungstest gültig bis</TableCell> <TableCell>Leistungstest gültig bis</TableCell>
<TableCell align="center">Status</TableCell> <TableCell align="center">Status</TableCell>
<TableCell align="right">Aktionen</TableCell> {canWrite && <TableCell align="right">Aktionen</TableCell>}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@@ -549,8 +559,8 @@ function Atemschutz() {
variant="filled" variant="filled"
/> />
</TableCell> </TableCell>
<TableCell align="right">
{canWrite && ( {canWrite && (
<TableCell align="right">
<Tooltip title="Bearbeiten"> <Tooltip title="Bearbeiten">
<Button <Button
size="small" size="small"
@@ -560,8 +570,6 @@ function Atemschutz() {
<Edit fontSize="small" /> <Edit fontSize="small" />
</Button> </Button>
</Tooltip> </Tooltip>
)}
{canWrite && (
<Tooltip title="Löschen"> <Tooltip title="Löschen">
<Button <Button
size="small" size="small"
@@ -572,8 +580,8 @@ function Atemschutz() {
<Delete fontSize="small" /> <Delete fontSize="small" />
</Button> </Button>
</Tooltip> </Tooltip>
)}
</TableCell> </TableCell>
)}
</TableRow> </TableRow>
); );
})} })}

View File

@@ -33,7 +33,7 @@ function Dashboard() {
<Box <Box
sx={{ sx={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 2.5, gap: 2.5,
alignItems: 'start', alignItems: 'start',
}} }}

View File

@@ -51,6 +51,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ContentCopy as CopyIcon, ContentCopy as CopyIcon,
DeleteForever as DeleteForeverIcon,
DirectionsCar as CarIcon, DirectionsCar as CarIcon,
Edit as EditIcon, Edit as EditIcon,
Event as EventIcon, Event as EventIcon,
@@ -453,11 +454,12 @@ interface DayPopoverProps {
onClose: () => void; onClose: () => void;
onTrainingClick: (id: string) => void; onTrainingClick: (id: string) => void;
onEventEdit: (ev: VeranstaltungListItem) => void; onEventEdit: (ev: VeranstaltungListItem) => void;
onEventDelete: (id: string) => void;
} }
function DayPopover({ function DayPopover({
anchorEl, day, trainingForDay, eventsForDay, anchorEl, day, trainingForDay, eventsForDay,
canWriteEvents, onClose, onTrainingClick, onEventEdit, canWriteEvents, onClose, onTrainingClick, onEventEdit, onEventDelete,
}: DayPopoverProps) { }: DayPopoverProps) {
if (!day) return null; if (!day) return null;
const hasContent = trainingForDay.length > 0 || eventsForDay.length > 0; const hasContent = trainingForDay.length > 0 || eventsForDay.length > 0;
@@ -589,6 +591,15 @@ function DayPopover({
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
)} )}
{canWriteEvents && (
<IconButton
size="small"
color="error"
onClick={() => { onEventDelete(ev.id); onClose(); }}
>
<DeleteForeverIcon fontSize="small" />
</IconButton>
)}
</ListItem> </ListItem>
))} ))}
</List> </List>
@@ -610,11 +621,12 @@ interface CombinedListViewProps {
onTrainingClick: (id: string) => void; onTrainingClick: (id: string) => void;
onEventEdit: (ev: VeranstaltungListItem) => void; onEventEdit: (ev: VeranstaltungListItem) => void;
onEventCancel: (id: string) => void; onEventCancel: (id: string) => void;
onEventDelete: (id: string) => void;
} }
function CombinedListView({ function CombinedListView({
trainingEvents, veranstaltungen, selectedKategorie, trainingEvents, veranstaltungen, selectedKategorie,
canWriteEvents, onTrainingClick, onEventEdit, onEventCancel, canWriteEvents, onTrainingClick, onEventEdit, onEventCancel, onEventDelete,
}: CombinedListViewProps) { }: CombinedListViewProps) {
type ListEntry = type ListEntry =
| { kind: 'training'; item: UebungListItem } | { kind: 'training'; item: UebungListItem }
@@ -742,6 +754,18 @@ function CombinedListView({
</IconButton> </IconButton>
</Box> </Box>
)} )}
{!isTraining && canWriteEvents && (
<Box sx={{ display: 'flex', gap: 0.5, ml: item.abgesagt ? 1 : 0 }}>
<IconButton
size="small"
color="error"
onClick={() => onEventDelete(item.id)}
title="Endgültig löschen"
>
<DeleteForeverIcon fontSize="small" />
</IconButton>
</Box>
)}
</ListItem> </ListItem>
</Box> </Box>
); );
@@ -1141,6 +1165,8 @@ export default function Kalender() {
const [cancelEventId, setCancelEventId] = useState<string | null>(null); const [cancelEventId, setCancelEventId] = useState<string | null>(null);
const [cancelEventGrund, setCancelEventGrund] = useState(''); const [cancelEventGrund, setCancelEventGrund] = useState('');
const [cancelEventLoading, setCancelEventLoading] = useState(false); const [cancelEventLoading, setCancelEventLoading] = useState(false);
const [deleteEventId, setDeleteEventId] = useState<string | null>(null);
const [deleteEventLoading, setDeleteEventLoading] = useState(false);
// ── Bookings tab state ─────────────────────────────────────────────────────── // ── Bookings tab state ───────────────────────────────────────────────────────
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() => const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
@@ -1326,6 +1352,21 @@ export default function Kalender() {
} }
}; };
const handleDeleteEvent = async () => {
if (!deleteEventId) return;
setDeleteEventLoading(true);
try {
await eventsApi.deleteEvent(deleteEventId);
notification.showSuccess('Veranstaltung wurde endgültig gelöscht');
setDeleteEventId(null);
loadCalendarData();
} catch (e: unknown) {
notification.showError((e as any)?.message || 'Fehler beim Löschen');
} finally {
setDeleteEventLoading(false);
}
};
// ── Booking helpers ────────────────────────────────────────────────────────── // ── Booking helpers ──────────────────────────────────────────────────────────
const getBookingsForCell = (vehicleId: string, day: Date): FahrzeugBuchungListItem[] => const getBookingsForCell = (vehicleId: string, day: Date): FahrzeugBuchungListItem[] =>
@@ -1642,6 +1683,7 @@ export default function Kalender() {
setCancelEventId(id); setCancelEventId(id);
setCancelEventGrund(''); setCancelEventGrund('');
}} }}
onEventDelete={(id) => setDeleteEventId(id)}
/> />
</Paper> </Paper>
)} )}
@@ -1673,6 +1715,7 @@ export default function Kalender() {
setVeranstEditing(ev); setVeranstEditing(ev);
setVeranstFormOpen(true); setVeranstFormOpen(true);
}} }}
onEventDelete={(id) => setDeleteEventId(id)}
/> />
{/* Veranstaltung Form Dialog */} {/* Veranstaltung Form Dialog */}
@@ -1720,6 +1763,34 @@ export default function Kalender() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Endgültig löschen Dialog */}
<Dialog
open={Boolean(deleteEventId)}
onClose={() => setDeleteEventId(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Veranstaltung endgültig löschen?</DialogTitle>
<DialogContent>
<DialogContentText>
Diese Veranstaltung wird endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteEventId(null)} disabled={deleteEventLoading}>
Abbrechen
</Button>
<Button
variant="contained"
color="error"
onClick={handleDeleteEvent}
disabled={deleteEventLoading}
>
{deleteEventLoading ? <CircularProgress size={20} /> : 'Endgültig löschen'}
</Button>
</DialogActions>
</Dialog>
{/* iCal Event subscription dialog */} {/* iCal Event subscription dialog */}
<Dialog open={icalEventOpen} onClose={() => setIcalEventOpen(false)} maxWidth="sm" fullWidth> <Dialog open={icalEventOpen} onClose={() => setIcalEventOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Kalender abonnieren</DialogTitle> <DialogTitle>Kalender abonnieren</DialogTitle>