This commit is contained in:
Matthias Hochmeister
2026-03-16 15:26:43 +01:00
parent 023bd7acbb
commit c15d4a50e0
13 changed files with 142 additions and 43 deletions

View File

@@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import bookingService from '../services/booking.service'; import bookingService from '../services/booking.service';
import vehicleService from '../services/vehicle.service'; import vehicleService from '../services/vehicle.service';
import { hasPermission, resolveRequestRole } from '../middleware/rbac.middleware';
import { import {
CreateBuchungSchema, CreateBuchungSchema,
UpdateBuchungSchema, UpdateBuchungSchema,
@@ -87,7 +88,7 @@ class BookingController {
*/ */
async checkAvailability(req: Request, res: Response): Promise<void> { async checkAvailability(req: Request, res: Response): Promise<void> {
try { try {
const { fahrzeugId, from, to } = req.query; const { fahrzeugId, from, to, excludeId } = req.query;
if (!fahrzeugId || !from || !to) { if (!fahrzeugId || !from || !to) {
res res
.status(400) .status(400)
@@ -114,7 +115,7 @@ class BookingController {
} }
const hasConflict = await bookingService.checkConflict( const hasConflict = await bookingService.checkConflict(
fahrzeugId as string, beginn, ende fahrzeugId as string, beginn, ende, excludeId as string | undefined
); );
res.json({ success: true, data: { available: !hasConflict } }); res.json({ success: true, data: { available: !hasConflict } });
} catch (error) { } catch (error) {
@@ -204,8 +205,9 @@ class BookingController {
} }
/** /**
* DELETE /api/bookings/:id * DELETE /api/bookings/:id or PATCH /api/bookings/:id/cancel
* Soft-cancels a booking (sets abgesagt=TRUE). * Soft-cancels a booking (sets abgesagt=TRUE).
* Allowed for booking creator or users with bookings:write permission.
*/ */
async cancel(req: Request, res: Response): Promise<void> { async cancel(req: Request, res: Response): Promise<void> {
try { try {
@@ -214,6 +216,20 @@ class BookingController {
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' }); res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
return; return;
} }
// Check ownership: creator can always cancel their own booking
const booking = await bookingService.getById(id);
if (!booking) {
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
return;
}
const isOwner = booking.gebucht_von === req.user!.id;
const role = resolveRequestRole(req);
if (!isOwner && !hasPermission(role, 'bookings:write')) {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
const parsed = CancelBuchungSchema.safeParse(req.body); const parsed = CancelBuchungSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
handleZodError(res, parsed.error); handleZodError(res, parsed.error);

View File

@@ -69,7 +69,7 @@ const PERMISSION_ROLE_MIN: Record<string, AppRole> = {
function roleFromGroups(groups: string[]): AppRole { function roleFromGroups(groups: string[]): AppRole {
if (groups.includes('dashboard_admin')) return 'admin'; if (groups.includes('dashboard_admin')) return 'admin';
if (groups.includes('dashboard_kommando')) return 'kommandant'; if (groups.includes('dashboard_kommando')) return 'kommandant';
if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister')) return 'gruppenfuehrer'; if (groups.includes('dashboard_gruppenfuehrer') || groups.includes('dashboard_fahrmeister') || groups.includes('dashboard_zeugmeister') || groups.includes('dashboard_chargen')) return 'gruppenfuehrer';
return 'mitglied'; return 'mitglied';
} }

View File

@@ -43,6 +43,7 @@ export interface FahrzeugBuchungListItem {
beginn: Date; beginn: Date;
ende: Date; ende: Date;
abgesagt: boolean; abgesagt: boolean;
gebucht_von: string;
gebucht_von_name?: string | null; gebucht_von_name?: string | null;
} }

View File

@@ -21,9 +21,9 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b
router.post('/', authenticate, bookingController.create.bind(bookingController)); router.post('/', authenticate, bookingController.create.bind(bookingController));
router.patch('/:id', authenticate, requirePermission('bookings:write'), bookingController.update.bind(bookingController)); router.patch('/:id', authenticate, requirePermission('bookings:write'), bookingController.update.bind(bookingController));
// Soft-cancel (sets abgesagt=TRUE) // Soft-cancel (sets abgesagt=TRUE) — creator or bookings:write
router.delete('/:id', authenticate, requirePermission('bookings:write'), bookingController.cancel.bind(bookingController)); router.delete('/:id', authenticate, bookingController.cancel.bind(bookingController));
router.patch('/:id/cancel', authenticate, requirePermission('bookings:write'), bookingController.cancel.bind(bookingController)); router.patch('/:id/cancel', authenticate, bookingController.cancel.bind(bookingController));
// Hard-delete (admin only) // Hard-delete (admin only)
router.delete('/:id/force', authenticate, requirePermission('bookings:delete'), bookingController.hardDelete.bind(bookingController)); router.delete('/:id/force', authenticate, requirePermission('bookings:delete'), bookingController.hardDelete.bind(bookingController));

View File

@@ -27,6 +27,7 @@ function rowToListItem(row: any): FahrzeugBuchungListItem {
beginn: new Date(row.beginn), beginn: new Date(row.beginn),
ende: new Date(row.ende), ende: new Date(row.ende),
abgesagt: row.abgesagt, abgesagt: row.abgesagt,
gebucht_von: row.gebucht_von,
gebucht_von_name: row.gebucht_von_name ?? null, gebucht_von_name: row.gebucht_von_name ?? null,
}; };
} }
@@ -77,7 +78,7 @@ class BookingService {
const query = ` const query = `
SELECT SELECT
b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art, b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende, b.abgesagt, b.beginn, b.ende, b.abgesagt, b.gebucht_von,
f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen, f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen,
u.name AS gebucht_von_name u.name AS gebucht_von_name
FROM fahrzeug_buchungen b FROM fahrzeug_buchungen b
@@ -102,7 +103,7 @@ class BookingService {
const query = ` const query = `
SELECT SELECT
b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art, b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art,
b.beginn, b.ende, b.abgesagt, b.beginn, b.ende, b.abgesagt, b.gebucht_von,
f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen, f.bezeichnung AS fahrzeug_name, f.amtliches_kennzeichen AS fahrzeug_kennzeichen,
u.name AS gebucht_von_name u.name AS gebucht_von_name
FROM fahrzeug_buchungen b FROM fahrzeug_buchungen b

View File

@@ -586,6 +586,7 @@ class EventsService {
'dashboard_jugend': 'Feuerwehrjugend', 'dashboard_jugend': 'Feuerwehrjugend',
'dashboard_kommandant': 'Kommandanten', 'dashboard_kommandant': 'Kommandanten',
'dashboard_moderator': 'Moderatoren', 'dashboard_moderator': 'Moderatoren',
'dashboard_chargen': 'Gruppenkommandanten',
'feuerwehr-admin': 'Feuerwehr Admin', 'feuerwehr-admin': 'Feuerwehr Admin',
'feuerwehr-kommandant': 'Feuerwehr Kommandant', 'feuerwehr-kommandant': 'Feuerwehr Kommandant',
}; };

View File

@@ -125,7 +125,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, isOneT
borderRadius: 2, borderRadius: 2,
}} }}
> >
{!isOwnMessage && ( {!isOwnMessage && !isOneToOne && (
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block' }}> <Typography variant="caption" sx={{ fontWeight: 600, display: 'block' }}>
{message.actorDisplayName} {message.actorDisplayName}
</Typography> </Typography>

View File

@@ -137,6 +137,7 @@ const ChatMessageView: React.FC = () => {
}, [selectedRoomToken, chatPanelOpen, queryClient, fetchReactions]); }, [selectedRoomToken, chatPanelOpen, queryClient, fetchReactions]);
const room = rooms.find((r) => r.token === selectedRoomToken); const room = rooms.find((r) => r.token === selectedRoomToken);
const isOneToOne = room?.type === 1;
const sendMutation = useMutation({ const sendMutation = useMutation({
mutationFn: ({ message, replyTo }: { message: string; replyTo?: number }) => mutationFn: ({ message, replyTo }: { message: string; replyTo?: number }) =>
@@ -271,6 +272,7 @@ const ChatMessageView: React.FC = () => {
key={msg.id} key={msg.id}
message={msg} message={msg}
isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName} isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName}
isOneToOne={isOneToOne}
onReplyClick={setReplyToMessage} onReplyClick={setReplyToMessage}
onReactionToggled={handleReactionToggled} onReactionToggled={handleReactionToggled}
reactionsOverride={reactionsMap.get(msg.id)} reactionsOverride={reactionsMap.get(msg.id)}

View File

@@ -189,7 +189,58 @@ const ChatPanelInner: React.FC = () => {
</Typography> </Typography>
</Box> </Box>
) : selectedRoomToken ? ( ) : selectedRoomToken ? (
<Box sx={{ display: 'flex', flex: 1, minHeight: 0 }}>
{/* Compact room sidebar — hidden on mobile */}
<Box
sx={{
display: { xs: 'none', sm: 'flex' },
flexDirection: 'column',
width: 56,
flexShrink: 0,
borderRight: 1,
borderColor: 'divider',
overflow: 'auto',
py: 0.5,
}}
>
{rooms.map((room) => {
const isSelected = room.token === selectedRoomToken;
return (
<Tooltip key={room.token} title={room.displayName} placement="left" arrow>
<IconButton
onClick={() => selectRoom(room.token)}
sx={{
mx: 'auto',
my: 0.25,
p: 0.5,
}}
>
<Badge
variant="dot"
color="primary"
invisible={room.unreadMessages === 0}
>
<Avatar
sx={{
width: 32,
height: 32,
fontSize: '0.7rem',
bgcolor: isSelected ? 'primary.main' : 'action.hover',
color: isSelected ? 'primary.contrastText' : 'text.primary',
}}
>
{room.displayName.substring(0, 2).toUpperCase()}
</Avatar>
</Badge>
</IconButton>
</Tooltip>
);
})}
</Box>
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<ChatMessageView /> <ChatMessageView />
</Box>
</Box>
) : ( ) : (
<ChatRoomList /> <ChatRoomList />
)} )}

View File

@@ -238,7 +238,8 @@ function FahrzeugBuchungen() {
.checkAvailability( .checkAvailability(
form.fahrzeugId, form.fahrzeugId,
new Date(form.beginn), new Date(form.beginn),
new Date(form.ende) new Date(form.ende),
editingBooking?.id
) )
.then((result) => { .then((result) => {
if (!cancelled) setAvailability(result); if (!cancelled) setAvailability(result);
@@ -249,7 +250,7 @@ function FahrzeugBuchungen() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [form.fahrzeugId, form.beginn, form.ende]); }, [form.fahrzeugId, form.beginn, form.ende, editingBooking?.id]);
const openCreateDialog = () => { const openCreateDialog = () => {
setEditingBooking(null); setEditingBooking(null);
@@ -693,8 +694,9 @@ function FahrzeugBuchungen() {
Von: {detailBooking.gebucht_von_name} Von: {detailBooking.gebucht_von_name}
</Typography> </Typography>
)} )}
{canWrite && ( {(canWrite || detailBooking.gebucht_von === user?.id) && (
<Box sx={{ mt: 1.5, display: 'flex', gap: 1 }}> <Box sx={{ mt: 1.5, display: 'flex', gap: 1 }}>
{canWrite && (
<Button <Button
size="small" size="small"
startIcon={<Edit />} startIcon={<Edit />}
@@ -702,6 +704,7 @@ function FahrzeugBuchungen() {
> >
Bearbeiten Bearbeiten
</Button> </Button>
)}
<Button <Button
size="small" size="small"
color="error" color="error"

View File

@@ -70,7 +70,7 @@ import {
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
import { toGermanDate, toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDate, isValidGermanDateTime } from '../utils/dateInput'; import { toGermanDateTime, fromGermanDate, fromGermanDateTime, isValidGermanDateTime } from '../utils/dateInput';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { trainingApi } from '../services/training'; import { trainingApi } from '../services/training';
@@ -213,6 +213,24 @@ function fromDatetimeLocal(value: string): string {
return new Date(value).toISOString(); return new Date(value).toISOString();
} }
/** ISO string → YYYY-MM-DDTHH:MM (for type="datetime-local") */
function toDatetimeLocalValue(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** ISO string → YYYY-MM-DD (for type="date") */
function toDateInputValue(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
// Types for unified calendar // Types for unified calendar
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
@@ -910,7 +928,11 @@ function parseCsvRow(line: string, lineNo: number): CsvRow {
if (parts.length < 4) { if (parts.length < 4) {
return { titel: '', datum_von: '', datum_bis: '', ganztaegig: false, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Zu wenige Spalten` }; return { titel: '', datum_von: '', datum_bis: '', ganztaegig: false, ort: null, beschreibung: null, valid: false, error: `Zeile ${lineNo}: Zu wenige Spalten` };
} }
const [titel, rawVon, rawBis, rawGanztaegig, ort, , beschreibung] = parts; const [titel, rawVon, rawBis, rawGanztaegig, ort, ...rest] = parts;
// Support both 7-column (with Kategorie) and 6-column (without) CSVs:
// 7 cols: Titel;Von;Bis;Ganztaegig;Ort;Kategorie;Beschreibung
// 6 cols: Titel;Von;Bis;Ganztaegig;Ort;Beschreibung
const beschreibung = rest.length >= 2 ? rest[1] : rest[0];
const ganztaegig = rawGanztaegig?.trim().toLowerCase() === 'ja'; const ganztaegig = rawGanztaegig?.trim().toLowerCase() === 'ja';
const convertDate = (raw: string): string => { const convertDate = (raw: string): string => {
@@ -1383,19 +1405,19 @@ function VeranstaltungFormDialog({
const vonDate = new Date(form.datum_von); const vonDate = new Date(form.datum_von);
const bisDate = new Date(form.datum_bis); const bisDate = new Date(form.datum_bis);
if (isNaN(vonDate.getTime())) { if (isNaN(vonDate.getTime())) {
notification.showError(`Ungültiges Datum Von (Format: ${form.ganztaegig ? '01.03.2025' : '01.03.2025 18:00'})`); notification.showError('Ungültiges Datum Von');
return; return;
} }
if (isNaN(bisDate.getTime())) { if (isNaN(bisDate.getTime())) {
notification.showError(`Ungültiges Datum Bis (Format: ${form.ganztaegig ? '01.03.2025' : '01.03.2025 18:00'})`); notification.showError('Ungültiges Datum Bis');
return; return;
} }
if (bisDate < vonDate) { if (bisDate < vonDate) {
notification.showError('Datum Bis muss nach Datum Von liegen'); notification.showError('Datum Bis muss nach Datum Von liegen');
return; return;
} }
if (wiederholungAktiv && wiederholungBis && !isValidGermanDate(wiederholungBis)) { if (wiederholungAktiv && wiederholungBis && isNaN(new Date(wiederholungBis).getTime())) {
notification.showError('Ungültiges Datum für Wiederholung Bis (Format: 01.03.2025)'); notification.showError('Ungültiges Datum für Wiederholung Bis');
return; return;
} }
@@ -1406,7 +1428,7 @@ function VeranstaltungFormDialog({
wiederholung: (!editingEvent && wiederholungAktiv && wiederholungBis) wiederholung: (!editingEvent && wiederholungAktiv && wiederholungBis)
? { ? {
typ: wiederholungTyp, typ: wiederholungTyp,
bis: fromGermanDate(wiederholungBis) || wiederholungBis, bis: wiederholungBis,
intervall: wiederholungTyp === 'wöchentlich' ? wiederholungIntervall : undefined, intervall: wiederholungTyp === 'wöchentlich' ? wiederholungIntervall : undefined,
wochentag: (wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag') wochentag: (wiederholungTyp === 'monatlich_erster_wochentag' || wiederholungTyp === 'monatlich_letzter_wochentag')
? wiederholungWochentag ? wiederholungWochentag
@@ -1488,39 +1510,39 @@ function VeranstaltungFormDialog({
/> />
<TextField <TextField
label="Von" label="Von"
placeholder={form.ganztaegig ? 'TT.MM.JJJJ' : 'TT.MM.JJJJ HH:MM'} type={form.ganztaegig ? 'date' : 'datetime-local'}
value={ value={
form.ganztaegig form.ganztaegig
? toGermanDate(form.datum_von) ? toDateInputValue(form.datum_von)
: toGermanDateTime(form.datum_von) : toDatetimeLocalValue(form.datum_von)
} }
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const raw = e.target.value;
if (!raw) return;
const iso = form.ganztaegig const iso = form.ganztaegig
? fromDatetimeLocal(raw ? `${fromGermanDate(raw)} 00:00` : '') ? new Date(raw + 'T00:00:00').toISOString()
: fromDatetimeLocal(raw); : new Date(raw).toISOString();
handleChange('datum_von', iso); handleChange('datum_von', iso);
}} }}
helperText={form.ganztaegig ? 'Format: 01.03.2025' : 'Format: 01.03.2025 18:00'}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
fullWidth fullWidth
/> />
<TextField <TextField
label="Bis" label="Bis"
placeholder={form.ganztaegig ? 'TT.MM.JJJJ' : 'TT.MM.JJJJ HH:MM'} type={form.ganztaegig ? 'date' : 'datetime-local'}
value={ value={
form.ganztaegig form.ganztaegig
? toGermanDate(form.datum_bis) ? toDateInputValue(form.datum_bis)
: toGermanDateTime(form.datum_bis) : toDatetimeLocalValue(form.datum_bis)
} }
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const raw = e.target.value;
if (!raw) return;
const iso = form.ganztaegig const iso = form.ganztaegig
? fromDatetimeLocal(raw ? `${fromGermanDate(raw)} 23:59` : '') ? new Date(raw + 'T23:59:00').toISOString()
: fromDatetimeLocal(raw); : new Date(raw).toISOString();
handleChange('datum_bis', iso); handleChange('datum_bis', iso);
}} }}
helperText={form.ganztaegig ? 'Format: 01.03.2025' : 'Format: 01.03.2025 18:00'}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
fullWidth fullWidth
/> />
@@ -1649,7 +1671,7 @@ function VeranstaltungFormDialog({
<TextField <TextField
label="Wiederholungen bis" label="Wiederholungen bis"
size="small" size="small"
placeholder="TT.MM.JJJJ" type="date"
value={wiederholungBis} value={wiederholungBis}
onChange={(e) => setWiederholungBis(e.target.value)} onChange={(e) => setWiederholungBis(e.target.value)}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}

View File

@@ -57,7 +57,8 @@ export const bookingApi = {
checkAvailability( checkAvailability(
fahrzeugId: string, fahrzeugId: string,
from: Date, from: Date,
to: Date to: Date,
excludeId?: string
): Promise<{ available: boolean; reason?: string; ausserDienstVon?: string; ausserDienstBis?: string }> { ): Promise<{ available: boolean; reason?: string; ausserDienstVon?: string; ausserDienstBis?: string }> {
return api return api
.get<ApiResponse<{ available: boolean; reason?: string; ausserDienstVon?: string; ausserDienstBis?: string }>>('/api/bookings/availability', { .get<ApiResponse<{ available: boolean; reason?: string; ausserDienstVon?: string; ausserDienstBis?: string }>>('/api/bookings/availability', {
@@ -65,6 +66,7 @@ export const bookingApi = {
fahrzeugId, fahrzeugId,
from: from.toISOString(), from: from.toISOString(),
to: to.toISOString(), to: to.toISOString(),
...(excludeId ? { excludeId } : {}),
}, },
}) })
.then((r) => r.data.data); .then((r) => r.data.data);

View File

@@ -28,6 +28,7 @@ export interface FahrzeugBuchungListItem {
beginn: string; // ISO beginn: string; // ISO
ende: string; // ISO ende: string; // ISO
abgesagt: boolean; abgesagt: boolean;
gebucht_von: string;
gebucht_von_name?: string | null; gebucht_von_name?: string | null;
} }
@@ -35,7 +36,6 @@ export interface FahrzeugBuchung extends FahrzeugBuchungListItem {
beschreibung?: string | null; beschreibung?: string | null;
kontakt_person?: string | null; kontakt_person?: string | null;
kontakt_telefon?: string | null; kontakt_telefon?: string | null;
gebucht_von: string;
abgesagt_grund?: string | null; abgesagt_grund?: string | null;
erstellt_am: string; erstellt_am: string;
aktualisiert_am: string; aktualisiert_am: string;