Compare commits
2 Commits
004b141cab
...
b3a2fd9ff9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3a2fd9ff9 | ||
|
|
5dfaf7db54 |
@@ -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 });
|
||||||
|
|||||||
@@ -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
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user