Compare commits

...

2 Commits

Author SHA1 Message Date
Matthias Hochmeister
b3a2fd9ff9 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>
2026-03-03 14:59:08 +01:00
Matthias Hochmeister
5dfaf7db54 bug fixes 2026-03-03 14:45:46 +01:00
12 changed files with 282 additions and 72 deletions

View File

@@ -18,9 +18,11 @@ function getUserId(req: Request): string {
// ── Controller ──────────────────────────────────────────────────────────────── // ── Controller ────────────────────────────────────────────────────────────────
class AtemschutzController { class AtemschutzController {
async list(_req: Request, res: Response): Promise<void> { async list(req: Request, res: Response): Promise<void> {
try { try {
const records = await atemschutzService.getAll(); const userGroups: string[] = (req.user as any)?.groups ?? [];
const userId = getUserId(req);
const records = await atemschutzService.getAll(userGroups, userId);
res.status(200).json({ success: true, data: records }); res.status(200).json({ success: true, data: records });
} catch (error) { } catch (error) {
logger.error('Atemschutz list error', { error }); logger.error('Atemschutz list error', { error });
@@ -47,9 +49,11 @@ class AtemschutzController {
} }
} }
async getStats(_req: Request, res: Response): Promise<void> { async getStats(req: Request, res: Response): Promise<void> {
try { try {
const stats = await atemschutzService.getStats(); const userGroups: string[] = (req.user as any)?.groups ?? [];
const userId = getUserId(req);
const stats = await atemschutzService.getStats(userGroups, userId);
res.status(200).json({ success: true, data: stats }); res.status(200).json({ success: true, data: stats });
} catch (error) { } catch (error) {
logger.error('Atemschutz getStats error', { error }); logger.error('Atemschutz getStats error', { error });

View File

@@ -265,6 +265,24 @@ class EventsController {
} }
}; };
// -------------------------------------------------------------------------
// POST /api/events/:id/delete (hard delete)
// -------------------------------------------------------------------------
deleteEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const deleted = await eventsService.deleteEvent(id);
if (!deleted) {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
res.json({ success: true, message: 'Veranstaltung wurde gelöscht' });
} catch (error) {
logger.error('deleteEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Veranstaltung' });
}
};
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// GET /api/events/calendar-token // GET /api/events/calendar-token
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -3,8 +3,8 @@ import atemschutzController from '../controllers/atemschutz.controller';
import { authenticate } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware'; import { requireGroups } from '../middleware/rbac.middleware';
const ADMIN_GROUPS = ['dashboard_admin']; const ADMIN_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_atemschutz']; const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
const router = Router(); const router = Router();

View File

@@ -143,4 +143,15 @@ router.delete(
eventsController.cancelEvent.bind(eventsController) eventsController.cancelEvent.bind(eventsController)
); );
/**
* POST /api/events/:id/delete
* Hard-delete an event permanently. Requires admin or moderator.
*/
router.post(
'/:id/delete',
authenticate,
requireGroups(WRITE_GROUPS),
eventsController.deleteEvent.bind(eventsController)
);
export default router; export default router;

View File

@@ -8,19 +8,31 @@ import {
UpdateAtemschutzData, UpdateAtemschutzData,
} from '../models/atemschutz.model'; } from '../models/atemschutz.model';
const ATEMSCHUTZ_PRIVILEGED = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
class AtemschutzService { class AtemschutzService {
// ========================================================================= // =========================================================================
// ÜBERSICHT (ALL RECORDS) // ÜBERSICHT (ALL RECORDS)
// ========================================================================= // =========================================================================
async getAll(): Promise<AtemschutzUebersicht[]> { async getAll(userGroups: string[], userId: string): Promise<AtemschutzUebersicht[]> {
const isPrivileged = userGroups.some(g => ATEMSCHUTZ_PRIVILEGED.includes(g));
try { try {
const result = await pool.query(` let result;
SELECT * if (isPrivileged) {
FROM atemschutz_uebersicht result = await pool.query(`
WHERE mitglied_status IS NULL OR mitglied_status IN ('aktiv', 'anwärter') SELECT *
ORDER BY user_family_name, user_given_name FROM atemschutz_uebersicht
`); WHERE mitglied_status IS NULL OR mitglied_status IN ('aktiv', 'anwärter')
ORDER BY user_family_name, user_given_name
`);
} else {
result = await pool.query(`
SELECT *
FROM atemschutz_uebersicht
WHERE user_id = $1
`, [userId]);
}
return result.rows.map((row) => ({ return result.rows.map((row) => ({
...row, ...row,
@@ -208,7 +220,21 @@ class AtemschutzService {
// DASHBOARD KPI / STATISTIKEN // DASHBOARD KPI / STATISTIKEN
// ========================================================================= // =========================================================================
async getStats(): Promise<AtemschutzStats> { async getStats(userGroups: string[], userId: string): Promise<AtemschutzStats> {
const isPrivileged = userGroups.some(g => ATEMSCHUTZ_PRIVILEGED.includes(g));
if (!isPrivileged) {
return {
total: 0,
mitLehrgang: 0,
untersuchungGueltig: 0,
untersuchungAbgelaufen: 0,
untersuchungBaldFaellig: 0,
leistungstestGueltig: 0,
leistungstestAbgelaufen: 0,
leistungstestBaldFaellig: 0,
einsatzbereit: 0,
};
}
try { try {
const result = await pool.query(` const result = await pool.query(`
SELECT SELECT

View File

@@ -490,6 +490,24 @@ class EventsService {
} }
} }
/**
* Hard-deletes an event (and any recurrence children) from the database.
* Returns true if the event was found and deleted, false if not found.
*/
async deleteEvent(id: string): Promise<boolean> {
logger.info('Hard-deleting event', { id });
// Delete recurrence children first (wiederholung_parent_id references)
await pool.query(
`DELETE FROM veranstaltungen WHERE wiederholung_parent_id = $1`,
[id]
);
const result = await pool.query(
`DELETE FROM veranstaltungen WHERE id = $1`,
[id]
);
return (result.rowCount ?? 0) > 0;
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// ICAL TOKEN // ICAL TOKEN
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -152,7 +152,9 @@ const StatCard: React.FC<StatCardProps> = ({ label, value, color, bgcolor }) =>
function Atemschutz() { function Atemschutz() {
const notification = useNotification(); const notification = useNotification();
const { user } = useAuth(); const { user } = useAuth();
const canWrite = user?.groups?.some(g => ['dashboard_admin', 'dashboard_atemschutz'].includes(g)) ?? false; const ATEMSCHUTZ_PRIVILEGED = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
const canViewAll = user?.groups?.some(g => ATEMSCHUTZ_PRIVILEGED.includes(g)) ?? false;
const canWrite = canViewAll;
// Data state // Data state
const [traeger, setTraeger] = useState<AtemschutzUebersicht[]>([]); const [traeger, setTraeger] = useState<AtemschutzUebersicht[]>([]);
@@ -181,20 +183,25 @@ function Atemschutz() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const [traegerData, statsData, membersData] = await Promise.all([ if (canViewAll) {
atemschutzApi.getAll(), const [traegerData, statsData, membersData] = await Promise.all([
atemschutzApi.getStats(), atemschutzApi.getAll(),
membersService.getMembers({ pageSize: 500 }), atemschutzApi.getStats(),
]); membersService.getMembers({ pageSize: 500 }),
setTraeger(traegerData); ]);
setStats(statsData); setTraeger(traegerData);
setMembers(membersData.items); setStats(statsData);
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();
@@ -359,7 +366,12 @@ function Atemschutz() {
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}> <Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Atemschutzverwaltung Atemschutzverwaltung
</Typography> </Typography>
{!loading && stats && ( {!loading && !canViewAll && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Dein persönlicher Atemschutz-Status
</Typography>
)}
{!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">
{stats.total} Gesamt {stats.total} Gesamt
@@ -382,7 +394,7 @@ function Atemschutz() {
</Box> </Box>
{/* Stats cards */} {/* Stats cards */}
{!loading && stats && ( {!loading && stats && canViewAll && (
<Grid container spacing={2} sx={{ mb: 3 }}> <Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6} sm={3}> <Grid item xs={6} sm={3}>
<StatCard <StatCard
@@ -405,6 +417,7 @@ function Atemschutz() {
)} )}
{/* Search bar */} {/* Search bar */}
{canViewAll && (
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<TextField <TextField
placeholder="Suchen (Name, E-Mail, Dienstgrad...)" placeholder="Suchen (Name, E-Mail, Dienstgrad...)"
@@ -421,6 +434,7 @@ function Atemschutz() {
}} }}
/> />
</Box> </Box>
)}
{/* Loading state */} {/* Loading state */}
{loading && ( {loading && (
@@ -467,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>
@@ -545,31 +559,29 @@ 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"
onClick={() => handleOpenEdit(item)} onClick={() => handleOpenEdit(item)}
sx={{ minWidth: 'auto', mr: 0.5 }} sx={{ minWidth: 'auto', mr: 0.5 }}
> >
<Edit fontSize="small" /> <Edit fontSize="small" />
</Button> </Button>
</Tooltip> </Tooltip>
)} <Tooltip title="Löschen">
{canWrite && ( <Button
<Tooltip title="Löschen"> size="small"
<Button color="error"
size="small" onClick={() => setDeleteId(item.id)}
color="error" sx={{ minWidth: 'auto' }}
onClick={() => setDeleteId(item.id)} >
sx={{ minWidth: 'auto' }} <Delete fontSize="small" />
> </Button>
<Delete fontSize="small" /> </Tooltip>
</Button> </TableCell>
</Tooltip> )}
)}
</TableCell>
</TableRow> </TableRow>
); );
})} })}

View File

@@ -33,12 +33,7 @@ function Dashboard() {
<Box <Box
sx={{ sx={{
display: 'grid', display: 'grid',
gridTemplateColumns: { gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
xs: '1fr',
sm: 'repeat(2, 1fr)',
lg: 'repeat(3, 1fr)',
xl: 'repeat(4, 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>

View File

@@ -41,6 +41,8 @@ function Profile() {
}); });
}; };
const dashboardGroups = (user.groups ?? []).filter((g) => g.startsWith('dashboard_'));
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="lg">
@@ -93,7 +95,7 @@ function Profile() {
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
{/* Groups/Roles */} {/* Groups/Roles */}
{user.groups && user.groups.length > 0 && ( {dashboardGroups.length > 0 && (
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<Typography <Typography
variant="subtitle2" variant="subtitle2"
@@ -105,9 +107,11 @@ function Profile() {
Gruppen Gruppen
</Typography> </Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
{user.groups.map((group) => ( {dashboardGroups.map((group) => {
<Chip key={group} label={group} size="small" color="primary" /> const name = group.replace(/^dashboard_/, '');
))} const label = name.charAt(0).toUpperCase() + name.slice(1);
return <Chip key={group} label={label} size="small" color="primary" />;
})}
</Box> </Box>
</Box> </Box>
)} )}

View File

@@ -49,6 +49,7 @@ import {
Today as TodayIcon, Today as TodayIcon,
IosShare, IosShare,
Event as EventIcon, Event as EventIcon,
Delete as DeleteIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
@@ -851,9 +852,10 @@ interface ListViewProps {
canWrite: boolean; canWrite: boolean;
onEdit: (ev: VeranstaltungListItem) => void; onEdit: (ev: VeranstaltungListItem) => void;
onCancel: (id: string) => void; onCancel: (id: string) => void;
onDelete: (id: string) => void;
} }
function EventListView({ events, canWrite, onEdit, onCancel }: ListViewProps) { function EventListView({ events, canWrite, onEdit, onCancel, onDelete }: ListViewProps) {
if (events.length === 0) { if (events.length === 0) {
return ( return (
<Alert severity="info" sx={{ mt: 2 }}> <Alert severity="info" sx={{ mt: 2 }}>
@@ -945,9 +947,16 @@ function EventListView({ events, canWrite, onEdit, onCancel }: ListViewProps) {
<IconButton size="small" onClick={() => onEdit(ev)}> <IconButton size="small" onClick={() => onEdit(ev)}>
<EditIcon fontSize="small" /> <EditIcon fontSize="small" />
</IconButton> </IconButton>
<IconButton size="small" color="error" onClick={() => onCancel(ev.id)}> <Tooltip title="Stornieren">
<CancelIcon fontSize="small" /> <IconButton size="small" color="error" onClick={() => onCancel(ev.id)}>
</IconButton> <CancelIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Löschen">
<IconButton size="small" color="error" onClick={() => onDelete(ev.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box> </Box>
)} )}
</ListItem> </ListItem>
@@ -996,6 +1005,10 @@ export default function Veranstaltungen() {
const [cancelGrund, setCancelGrund] = useState(''); const [cancelGrund, setCancelGrund] = useState('');
const [cancelLoading, setCancelLoading] = useState(false); const [cancelLoading, setCancelLoading] = useState(false);
// Delete dialog
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
// iCal dialog // iCal dialog
const [icalOpen, setIcalOpen] = useState(false); const [icalOpen, setIcalOpen] = useState(false);
@@ -1100,6 +1113,22 @@ export default function Veranstaltungen() {
} }
}; };
const handleDeleteEvent = async () => {
if (!deleteId) return;
setDeleteLoading(true);
try {
await eventsApi.deleteEvent(deleteId);
setDeleteId(null);
loadData();
notification.showSuccess('Veranstaltung wurde gelöscht');
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Fehler beim Löschen';
notification.showError(msg);
} finally {
setDeleteLoading(false);
}
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Filtered events for list view // Filtered events for list view
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1246,6 +1275,7 @@ export default function Veranstaltungen() {
canWrite={canWrite} canWrite={canWrite}
onEdit={(ev) => { setEditingEvent(ev); setFormOpen(true); }} onEdit={(ev) => { setEditingEvent(ev); setFormOpen(true); }}
onCancel={(id) => { setCancelId(id); setCancelGrund(''); }} onCancel={(id) => { setCancelId(id); setCancelGrund(''); }}
onDelete={(id) => setDeleteId(id)}
/> />
</Paper> </Paper>
)} )}
@@ -1289,16 +1319,16 @@ export default function Veranstaltungen() {
maxWidth="xs" maxWidth="xs"
fullWidth fullWidth
> >
<DialogTitle>Veranstaltung absagen</DialogTitle> <DialogTitle>Veranstaltung stornieren</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText sx={{ mb: 2 }}> <DialogContentText sx={{ mb: 2 }}>
Bitte gib einen Grund für die Absage an (mind. 5 Zeichen). Bitte gib einen Grund für die Stornierung an (mind. 5 Zeichen).
</DialogContentText> </DialogContentText>
<TextField <TextField
fullWidth fullWidth
multiline multiline
rows={3} rows={3}
label="Absagegrund" label="Stornierungsgrund"
value={cancelGrund} value={cancelGrund}
onChange={(e) => setCancelGrund(e.target.value)} onChange={(e) => setCancelGrund(e.target.value)}
autoFocus autoFocus
@@ -1312,7 +1342,23 @@ export default function Veranstaltungen() {
onClick={handleCancelEvent} onClick={handleCancelEvent}
disabled={cancelGrund.trim().length < 5 || cancelLoading} disabled={cancelGrund.trim().length < 5 || cancelLoading}
> >
{cancelLoading ? <CircularProgress size={20} /> : 'Absagen'} {cancelLoading ? <CircularProgress size={20} /> : 'Stornieren'}
</Button>
</DialogActions>
</Dialog>
{/* Delete Dialog */}
<Dialog open={Boolean(deleteId)} onClose={() => setDeleteId(null)} maxWidth="xs" fullWidth>
<DialogTitle>Veranstaltung endgültig löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Soll diese Veranstaltung wirklich endgültig gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteId(null)}>Abbrechen</Button>
<Button variant="contained" color="error" onClick={handleDeleteEvent} disabled={deleteLoading}>
{deleteLoading ? <CircularProgress size={20} /> : 'Endgültig löschen'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -129,6 +129,11 @@ export const eventsApi = {
.then(() => undefined); .then(() => undefined);
}, },
/** Hard-delete an event permanently */
deleteEvent(id: string): Promise<void> {
return api.post(`/api/events/${id}/delete`).then(() => undefined);
},
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// iCal // iCal
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------