bug fix for atemschutz
This commit is contained in:
270
backend/src/controllers/booking.controller.ts
Normal file
270
backend/src/controllers/booking.controller.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import bookingService from '../services/booking.service';
|
||||||
|
import {
|
||||||
|
CreateBuchungSchema,
|
||||||
|
UpdateBuchungSchema,
|
||||||
|
CancelBuchungSchema,
|
||||||
|
} from '../models/booking.model';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isValidUUID(s: string): boolean {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZodError(res: Response, err: ZodError): void {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: err.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConflictError(res: Response, err: Error): boolean {
|
||||||
|
if (err.message?.includes('bereits gebucht')) {
|
||||||
|
res.status(409).json({ success: false, message: err.message });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Controller
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BookingController {
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/calendar?from=&to=&fahrzeugId=
|
||||||
|
* Returns all non-cancelled bookings overlapping the given date range.
|
||||||
|
*/
|
||||||
|
async getCalendarRange(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { from, to, fahrzeugId } = req.query;
|
||||||
|
if (!from || !to) {
|
||||||
|
res.status(400).json({ success: false, message: 'from und to sind erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bookings = await bookingService.getBookingsByRange(
|
||||||
|
new Date(from as string),
|
||||||
|
new Date(to as string),
|
||||||
|
fahrzeugId as string | undefined
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: bookings });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking getCalendarRange error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/upcoming?limit=
|
||||||
|
* Returns the next upcoming non-cancelled bookings.
|
||||||
|
*/
|
||||||
|
async getUpcoming(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 20;
|
||||||
|
const bookings = await bookingService.getUpcoming(limit);
|
||||||
|
res.json({ success: true, data: bookings });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking getUpcoming error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/availability?fahrzeugId=&from=&to=
|
||||||
|
* Returns { available: true } when the vehicle has no conflicting booking.
|
||||||
|
*/
|
||||||
|
async checkAvailability(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { fahrzeugId, from, to } = req.query;
|
||||||
|
if (!fahrzeugId || !from || !to) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, message: 'fahrzeugId, from und to sind erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasConflict = await bookingService.checkConflict(
|
||||||
|
fahrzeugId as string,
|
||||||
|
new Date(from as string),
|
||||||
|
new Date(to as string)
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: { available: !hasConflict } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking checkAvailability error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Verfügbarkeit konnte nicht geprüft werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/:id
|
||||||
|
* Returns a single booking with all joined fields.
|
||||||
|
*/
|
||||||
|
async getById(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const booking = await bookingService.getById(id);
|
||||||
|
if (!booking) {
|
||||||
|
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: booking });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking getById error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchung konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/bookings
|
||||||
|
* Creates a new vehicle booking.
|
||||||
|
*/
|
||||||
|
async create(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parsed = CreateBuchungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
handleZodError(res, parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const booking = await bookingService.create(parsed.data, req.user!.id);
|
||||||
|
res.status(201).json({ success: true, data: booking });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (handleConflictError(res, error)) return;
|
||||||
|
logger.error('Booking create error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchung konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/bookings/:id
|
||||||
|
* Updates the provided fields of an existing booking.
|
||||||
|
*/
|
||||||
|
async update(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = UpdateBuchungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
handleZodError(res, parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.keys(parsed.data).length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const booking = await bookingService.update(id, parsed.data);
|
||||||
|
if (!booking) {
|
||||||
|
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: booking });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'No fields to update') {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (handleConflictError(res, error)) return;
|
||||||
|
logger.error('Booking update error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchung konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/bookings/:id
|
||||||
|
* Soft-cancels a booking (sets abgesagt=TRUE).
|
||||||
|
*/
|
||||||
|
async cancel(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = CancelBuchungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
handleZodError(res, parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bookingService.cancel(id, parsed.data.abgesagt_grund);
|
||||||
|
res.json({ success: true, message: 'Buchung wurde storniert' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking cancel error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchung konnte nicht storniert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/bookings/:id/force
|
||||||
|
* Hard-deletes a booking record (admin only).
|
||||||
|
*/
|
||||||
|
async hardDelete(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bookingService.delete(id);
|
||||||
|
res.json({ success: true, message: 'Buchung gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking hardDelete error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchung konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/calendar-token
|
||||||
|
* Returns the user's iCal subscribe token and URL, creating it if needed.
|
||||||
|
*/
|
||||||
|
async getCalendarToken(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await bookingService.getOrCreateIcalToken(req.user!.id);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking getCalendarToken error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Kalender-Token konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/calendar.ics?token=&fahrzeugId=
|
||||||
|
* Returns an iCal file for the subscriber. No authentication required
|
||||||
|
* (token-based access).
|
||||||
|
*/
|
||||||
|
async getIcalExport(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { token, fahrzeugId } = req.query;
|
||||||
|
if (!token) {
|
||||||
|
res.status(400).send('Token required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ical = await bookingService.getIcalExport(
|
||||||
|
token as string,
|
||||||
|
fahrzeugId as string | undefined
|
||||||
|
);
|
||||||
|
if (!ical) {
|
||||||
|
res.status(404).send('Invalid token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="fahrzeugbuchungen.ics"');
|
||||||
|
res.send(ical);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking getIcalExport error', { error });
|
||||||
|
res.status(500).send('Internal server error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new BookingController();
|
||||||
321
backend/src/controllers/events.controller.ts
Normal file
321
backend/src/controllers/events.controller.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import eventsService from '../services/events.service';
|
||||||
|
import {
|
||||||
|
CreateKategorieSchema,
|
||||||
|
UpdateKategorieSchema,
|
||||||
|
CreateVeranstaltungSchema,
|
||||||
|
UpdateVeranstaltungSchema,
|
||||||
|
CancelVeranstaltungSchema,
|
||||||
|
} from '../models/events.model';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Known Authentik groups exposed to the frontend for event targeting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const KNOWN_GROUPS = [
|
||||||
|
{ id: 'dashboard_admin', label: 'Administratoren' },
|
||||||
|
{ id: 'dashboard_moderator', label: 'Moderatoren' },
|
||||||
|
{ id: 'dashboard_mitglied', label: 'Mitglieder' },
|
||||||
|
{ id: 'dashboard_fahrmeister', label: 'Fahrmeister' },
|
||||||
|
{ id: 'dashboard_zeugmeister', label: 'Zeugmeister' },
|
||||||
|
{ id: 'dashboard_atemschutz', label: 'Atemschutzwart' },
|
||||||
|
{ id: 'dashboard_jugend', label: 'Feuerwehrjugend' },
|
||||||
|
{ id: 'dashboard_kommandant', label: 'Kommandanten' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper — extract userGroups from request
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getUserGroups(req: Request): string[] {
|
||||||
|
return (req.user as any)?.groups ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Controller
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class EventsController {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/kategorien
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
listKategorien = async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const data = await eventsService.getKategorien();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('listKategorien error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Kategorien' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/events/kategorien
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
createKategorie = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const parsed = CreateKategorieSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await eventsService.createKategorie(parsed.data, req.user!.id);
|
||||||
|
res.status(201).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('createKategorie error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Kategorie' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PATCH /api/events/kategorien/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
updateKategorie = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const parsed = UpdateKategorieSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await eventsService.updateKategorie(id, parsed.data);
|
||||||
|
if (!data) {
|
||||||
|
res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('updateKategorie error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Kategorie' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// DELETE /api/events/kategorien/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
deleteKategorie = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
await eventsService.deleteKategorie(id);
|
||||||
|
res.json({ success: true, message: 'Kategorie wurde gelöscht' });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (
|
||||||
|
error.message === 'Kategorie nicht gefunden' ||
|
||||||
|
error.message?.includes('noch Veranstaltungen')
|
||||||
|
) {
|
||||||
|
res.status(409).json({ success: false, message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('deleteKategorie error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Kategorie' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/groups
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getAvailableGroups = async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
res.json({ success: true, data: KNOWN_GROUPS });
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/calendar?from=<ISO>&to=<ISO>
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getCalendarRange = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const fromStr = req.query.from as string | undefined;
|
||||||
|
const toStr = req.query.to as string | undefined;
|
||||||
|
|
||||||
|
if (!fromStr || !toStr) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Query-Parameter "from" und "to" sind erforderlich (ISO-8601)',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = new Date(fromStr);
|
||||||
|
const to = new Date(toStr);
|
||||||
|
|
||||||
|
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültiges Datumsformat' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to < from) {
|
||||||
|
res.status(400).json({ success: false, message: '"to" muss nach "from" liegen' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userGroups = getUserGroups(req);
|
||||||
|
const data = await eventsService.getEventsByDateRange(from, to, userGroups);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getCalendarRange error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalenders' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/upcoming?limit=10
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getUpcoming = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const limit = Math.min(Number(req.query.limit ?? 10), 50);
|
||||||
|
const userGroups = getUserGroups(req);
|
||||||
|
const data = await eventsService.getUpcomingEvents(limit, userGroups);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getUpcoming error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltungen' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getById = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const event = await eventsService.getById(id);
|
||||||
|
if (!event) {
|
||||||
|
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: event });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getById error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/events
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
createEvent = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const parsed = CreateVeranstaltungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await eventsService.createEvent(parsed.data, req.user!.id);
|
||||||
|
res.status(201).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('createEvent error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PATCH /api/events/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
updateEvent = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const parsed = UpdateVeranstaltungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await eventsService.updateEvent(id, parsed.data);
|
||||||
|
if (!data) {
|
||||||
|
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('updateEvent error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// DELETE /api/events/:id (soft cancel)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
cancelEvent = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const parsed = CancelVeranstaltungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await eventsService.cancelEvent(id, parsed.data.abgesagt_grund, req.user!.id);
|
||||||
|
res.json({ success: true, message: 'Veranstaltung wurde abgesagt' });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Event not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('cancelEvent error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Absagen der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/calendar-token
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getCalendarToken = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await eventsService.getOrCreateIcalToken(req.user.id);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getCalendarToken error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalender-Tokens' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/calendar.ics?token=<token>
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getIcalExport = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const token = req.query.token as string | undefined;
|
||||||
|
if (!token) {
|
||||||
|
res.status(400).send('Token required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ical = await eventsService.getIcalExport(token);
|
||||||
|
if (!ical) {
|
||||||
|
res.status(404).send('Invalid token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="veranstaltungen.ics"');
|
||||||
|
// 30-minute cache — calendar clients typically re-fetch at this interval
|
||||||
|
res.setHeader('Cache-Control', 'max-age=1800, public');
|
||||||
|
res.send(ical);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getIcalExport error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new EventsController();
|
||||||
118
backend/src/database/migrations/015_create_veranstaltungen.sql
Normal file
118
backend/src/database/migrations/015_create_veranstaltungen.sql
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Migration 015: Veranstaltungen (Events / General Calendar)
|
||||||
|
-- General event calendar for Feuerwehr Dashboard, separate from the training
|
||||||
|
-- calendar (uebungen). Supports categories, RSVPs, and iCal subscriptions.
|
||||||
|
-- Depends on: 001_create_users_table.sql (uuid-ossp, pgcrypto extensions,
|
||||||
|
-- users table, update_updated_at_column trigger function)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. Event categories table
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS veranstaltung_kategorien (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
farbe VARCHAR(7) NOT NULL DEFAULT '#1976d2', -- hex colour for UI chips
|
||||||
|
icon VARCHAR(100), -- MUI icon name, e.g. 'Event', 'FireTruck'
|
||||||
|
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER update_veranstaltung_kategorien_aktualisiert_am
|
||||||
|
BEFORE UPDATE ON veranstaltung_kategorien
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. Main events table
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS veranstaltungen (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
titel VARCHAR(500) NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
ort VARCHAR(500),
|
||||||
|
ort_url VARCHAR(1000), -- optional maps/navigation link
|
||||||
|
kategorie_id UUID REFERENCES veranstaltung_kategorien(id) ON DELETE SET NULL,
|
||||||
|
datum_von TIMESTAMPTZ NOT NULL,
|
||||||
|
datum_bis TIMESTAMPTZ NOT NULL,
|
||||||
|
ganztaegig BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
-- zielgruppen: array of Authentik group names, e.g. '{dashboard_mitglied,dashboard_jugend}'
|
||||||
|
zielgruppen TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
alle_gruppen BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE = visible to all members
|
||||||
|
max_teilnehmer INTEGER CHECK (max_teilnehmer > 0),
|
||||||
|
anmeldung_erforderlich BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
anmeldung_bis TIMESTAMPTZ,
|
||||||
|
erstellt_von UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
abgesagt_grund TEXT,
|
||||||
|
abgesagt_am TIMESTAMPTZ,
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT veranstaltung_datum_reihenfolge CHECK (datum_bis >= datum_von)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_von
|
||||||
|
ON veranstaltungen(datum_von);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_bis
|
||||||
|
ON veranstaltungen(datum_bis);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_kategorie_id
|
||||||
|
ON veranstaltungen(kategorie_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_abgesagt
|
||||||
|
ON veranstaltungen(abgesagt) WHERE abgesagt = FALSE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_alle_gruppen
|
||||||
|
ON veranstaltungen(alle_gruppen);
|
||||||
|
-- Compound index for the most common calendar-range query
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_von_bis
|
||||||
|
ON veranstaltungen(datum_von, datum_bis);
|
||||||
|
|
||||||
|
CREATE TRIGGER update_veranstaltungen_aktualisiert_am
|
||||||
|
BEFORE UPDATE ON veranstaltungen
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. RSVP / attendance table
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS veranstaltung_teilnahmen (
|
||||||
|
veranstaltung_id UUID NOT NULL REFERENCES veranstaltungen(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
-- status values: zugesagt, abgesagt, erschienen, unbekannt
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'unbekannt',
|
||||||
|
notiz VARCHAR(500),
|
||||||
|
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
PRIMARY KEY (veranstaltung_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltung_teilnahmen_veranstaltung_id
|
||||||
|
ON veranstaltung_teilnahmen(veranstaltung_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltung_teilnahmen_user_id
|
||||||
|
ON veranstaltung_teilnahmen(user_id);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 4. Per-user iCal subscription tokens
|
||||||
|
-- One token per user — covers the full events calendar feed for that user.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS veranstaltung_ical_tokens (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(128) UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
zuletzt_verwendet_am TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltung_ical_tokens_token
|
||||||
|
ON veranstaltung_ical_tokens(token);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 5. Seed default event categories
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
INSERT INTO veranstaltung_kategorien (name, farbe, icon) VALUES
|
||||||
|
('Allgemein', '#1976d2', 'Event'),
|
||||||
|
('Ausbildung', '#2e7d32', 'School'),
|
||||||
|
('Gesellschaft', '#e65100', 'People'),
|
||||||
|
('Feuerwehrjugend', '#f57c00', 'ChildCare'),
|
||||||
|
('Kommando', '#6a1b9a', 'Shield')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Migration 016: Fahrzeugbuchungen (Vehicle Booking System)
|
||||||
|
-- Allows members to book fire department vehicles for internal use, external
|
||||||
|
-- events, maintenance slots and reservations. Includes per-user iCal feeds.
|
||||||
|
-- Depends on: 001_create_users_table.sql (uuid-ossp, pgcrypto extensions,
|
||||||
|
-- users table, update_updated_at_column trigger function)
|
||||||
|
-- 005_create_fahrzeuge.sql (fahrzeuge table)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. ENUM: booking type
|
||||||
|
-- Uses DO-block for idempotent creation (PostgreSQL has no CREATE TYPE IF NOT EXISTS)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE TYPE fahrzeug_buchung_art AS ENUM (
|
||||||
|
'intern',
|
||||||
|
'extern',
|
||||||
|
'wartung',
|
||||||
|
'reservierung',
|
||||||
|
'sonstiges'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. Vehicle bookings table
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS fahrzeug_buchungen (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
|
||||||
|
titel VARCHAR(500) NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
beginn TIMESTAMPTZ NOT NULL,
|
||||||
|
ende TIMESTAMPTZ NOT NULL,
|
||||||
|
buchungs_art fahrzeug_buchung_art NOT NULL DEFAULT 'intern',
|
||||||
|
gebucht_von UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
-- kontakt fields are relevant for external bookings
|
||||||
|
kontakt_person VARCHAR(255),
|
||||||
|
kontakt_telefon VARCHAR(50),
|
||||||
|
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
abgesagt_grund TEXT,
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT buchung_ende_nach_beginn CHECK (ende > beginn)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_fahrzeug_id
|
||||||
|
ON fahrzeug_buchungen(fahrzeug_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_beginn
|
||||||
|
ON fahrzeug_buchungen(beginn);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_ende
|
||||||
|
ON fahrzeug_buchungen(ende);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_gebucht_von
|
||||||
|
ON fahrzeug_buchungen(gebucht_von);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_abgesagt
|
||||||
|
ON fahrzeug_buchungen(abgesagt) WHERE abgesagt = FALSE;
|
||||||
|
-- Compound index for availability / overlap checks (fahrzeug + time range)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_fahrzeug_beginn_ende
|
||||||
|
ON fahrzeug_buchungen(fahrzeug_id, beginn, ende);
|
||||||
|
|
||||||
|
CREATE TRIGGER update_fahrzeug_buchungen_aktualisiert_am
|
||||||
|
BEFORE UPDATE ON fahrzeug_buchungen
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. Per-user iCal subscription tokens for the vehicle booking calendar
|
||||||
|
-- One token per user — the feed returns all bookings the user has access to.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS fahrzeug_ical_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(128) UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
zuletzt_verwendet_am TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- one token per user for the full vehicle booking feed
|
||||||
|
UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_ical_tokens_token
|
||||||
|
ON fahrzeug_ical_tokens(token);
|
||||||
88
backend/src/models/booking.model.ts
Normal file
88
backend/src/models/booking.model.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Enums
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const BUCHUNGS_ARTEN = ['intern', 'extern', 'wartung', 'reservierung', 'sonstiges'] as const;
|
||||||
|
export type BuchungsArt = (typeof BUCHUNGS_ARTEN)[number];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core DB-mapped interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface FahrzeugBuchung {
|
||||||
|
id: string;
|
||||||
|
fahrzeug_id: string;
|
||||||
|
titel: string;
|
||||||
|
beschreibung?: string | null;
|
||||||
|
beginn: Date;
|
||||||
|
ende: Date;
|
||||||
|
buchungs_art: BuchungsArt;
|
||||||
|
gebucht_von: string;
|
||||||
|
kontakt_person?: string | null;
|
||||||
|
kontakt_telefon?: string | null;
|
||||||
|
abgesagt: boolean;
|
||||||
|
abgesagt_grund?: string | null;
|
||||||
|
erstellt_am: Date;
|
||||||
|
aktualisiert_am: Date;
|
||||||
|
// Joined
|
||||||
|
fahrzeug_name?: string | null;
|
||||||
|
fahrzeug_kennzeichen?: string | null;
|
||||||
|
gebucht_von_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight list item — used in calendar and upcoming list widget */
|
||||||
|
export interface FahrzeugBuchungListItem {
|
||||||
|
id: string;
|
||||||
|
fahrzeug_id: string;
|
||||||
|
fahrzeug_name: string;
|
||||||
|
fahrzeug_kennzeichen?: string | null;
|
||||||
|
titel: string;
|
||||||
|
buchungs_art: BuchungsArt;
|
||||||
|
beginn: Date;
|
||||||
|
ende: Date;
|
||||||
|
abgesagt: boolean;
|
||||||
|
gebucht_von_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zod validation schemas
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BuchungBaseSchema = z.object({
|
||||||
|
fahrzeugId: z.string().uuid('fahrzeugId muss eine gueltige UUID sein'),
|
||||||
|
titel: z.string().min(3, 'Titel muss mindestens 3 Zeichen haben').max(500),
|
||||||
|
beschreibung: z.string().max(2000).optional().nullable(),
|
||||||
|
beginn: z
|
||||||
|
.string()
|
||||||
|
.datetime({ offset: true, message: 'beginn muss ein ISO-8601 Datum mit Zeitzone sein' })
|
||||||
|
.transform((s) => new Date(s)),
|
||||||
|
ende: z
|
||||||
|
.string()
|
||||||
|
.datetime({ offset: true, message: 'ende muss ein ISO-8601 Datum mit Zeitzone sein' })
|
||||||
|
.transform((s) => new Date(s)),
|
||||||
|
buchungsArt: z.enum(BUCHUNGS_ARTEN).default('intern'),
|
||||||
|
kontaktPerson: z.string().max(255).optional().nullable(),
|
||||||
|
kontaktTelefon: z.string().max(50).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateBuchungSchema = BuchungBaseSchema.refine(
|
||||||
|
(d) => d.ende > d.beginn,
|
||||||
|
{ message: 'Ende muss nach Beginn liegen', path: ['ende'] }
|
||||||
|
);
|
||||||
|
export type CreateBuchungData = z.infer<typeof CreateBuchungSchema>;
|
||||||
|
|
||||||
|
export const UpdateBuchungSchema = BuchungBaseSchema.partial().refine(
|
||||||
|
(d) => d.ende == null || d.beginn == null || d.ende > d.beginn,
|
||||||
|
{ message: 'Ende muss nach Beginn liegen', path: ['ende'] }
|
||||||
|
);
|
||||||
|
export type UpdateBuchungData = z.infer<typeof UpdateBuchungSchema>;
|
||||||
|
|
||||||
|
export const CancelBuchungSchema = z.object({
|
||||||
|
abgesagt_grund: z
|
||||||
|
.string()
|
||||||
|
.min(5, 'Bitte gib einen Stornierungsgrund an (min. 5 Zeichen)')
|
||||||
|
.max(1000),
|
||||||
|
});
|
||||||
|
export type CancelBuchungData = z.infer<typeof CancelBuchungSchema>;
|
||||||
143
backend/src/models/events.model.ts
Normal file
143
backend/src/models/events.model.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core DB-mapped interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface VeranstaltungKategorie {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
beschreibung?: string | null;
|
||||||
|
farbe?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
erstellt_von?: string | null;
|
||||||
|
erstellt_am: Date;
|
||||||
|
aktualisiert_am: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Veranstaltung {
|
||||||
|
id: string;
|
||||||
|
titel: string;
|
||||||
|
beschreibung?: string | null;
|
||||||
|
ort?: string | null;
|
||||||
|
ort_url?: string | null;
|
||||||
|
kategorie_id?: string | null;
|
||||||
|
datum_von: Date;
|
||||||
|
datum_bis: Date;
|
||||||
|
ganztaegig: boolean;
|
||||||
|
zielgruppen: string[];
|
||||||
|
alle_gruppen: boolean;
|
||||||
|
max_teilnehmer?: number | null;
|
||||||
|
anmeldung_erforderlich: boolean;
|
||||||
|
anmeldung_bis?: Date | null;
|
||||||
|
erstellt_von: string;
|
||||||
|
abgesagt: boolean;
|
||||||
|
abgesagt_grund?: string | null;
|
||||||
|
abgesagt_am?: Date | null;
|
||||||
|
erstellt_am: Date;
|
||||||
|
aktualisiert_am: Date;
|
||||||
|
// Joined / enriched fields
|
||||||
|
kategorie_name?: string | null;
|
||||||
|
kategorie_farbe?: string | null;
|
||||||
|
kategorie_icon?: string | null;
|
||||||
|
erstellt_von_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight version for calendar and list views */
|
||||||
|
export interface VeranstaltungListItem {
|
||||||
|
id: string;
|
||||||
|
titel: string;
|
||||||
|
ort?: string | null;
|
||||||
|
kategorie_id?: string | null;
|
||||||
|
kategorie_name?: string | null;
|
||||||
|
kategorie_farbe?: string | null;
|
||||||
|
kategorie_icon?: string | null;
|
||||||
|
datum_von: Date;
|
||||||
|
datum_bis: Date;
|
||||||
|
ganztaegig: boolean;
|
||||||
|
alle_gruppen: boolean;
|
||||||
|
zielgruppen: string[];
|
||||||
|
abgesagt: boolean;
|
||||||
|
anmeldung_erforderlich: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zod schemas -- Kategorien
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const CreateKategorieSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(2, 'Name muss mindestens 2 Zeichen haben')
|
||||||
|
.max(255),
|
||||||
|
beschreibung: z.string().max(500).optional().nullable(),
|
||||||
|
farbe: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#[0-9a-fA-F]{6}$/, 'Farbe muss ein gültiger Hex-Farbwert sein (z.B. #1976d2)')
|
||||||
|
.optional(),
|
||||||
|
icon: z.string().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateKategorieData = z.infer<typeof CreateKategorieSchema>;
|
||||||
|
|
||||||
|
export const UpdateKategorieSchema = CreateKategorieSchema.partial();
|
||||||
|
|
||||||
|
export type UpdateKategorieData = z.infer<typeof UpdateKategorieSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zod schemas -- Veranstaltungen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const VeranstaltungBaseSchema = z.object({
|
||||||
|
titel: z
|
||||||
|
.string()
|
||||||
|
.min(3, 'Titel muss mindestens 3 Zeichen haben')
|
||||||
|
.max(500),
|
||||||
|
beschreibung: z.string().max(5000).optional().nullable(),
|
||||||
|
ort: z.string().max(500).optional().nullable(),
|
||||||
|
// Plain string validator — some users use relative paths or internal URLs
|
||||||
|
ort_url: z.string().max(1000).optional().nullable(),
|
||||||
|
kategorie_id: z.string().uuid('kategorie_id muss eine gültige UUID sein').optional().nullable(),
|
||||||
|
datum_von: z
|
||||||
|
.string()
|
||||||
|
.datetime({ offset: true, message: 'datum_von muss ein ISO-8601 Datum mit Zeitzone sein' })
|
||||||
|
.transform((s) => new Date(s)),
|
||||||
|
datum_bis: z
|
||||||
|
.string()
|
||||||
|
.datetime({ offset: true, message: 'datum_bis muss ein ISO-8601 Datum mit Zeitzone sein' })
|
||||||
|
.transform((s) => new Date(s)),
|
||||||
|
ganztaegig: z.boolean().default(false),
|
||||||
|
zielgruppen: z.array(z.string()).default([]),
|
||||||
|
alle_gruppen: z.boolean().default(false),
|
||||||
|
max_teilnehmer: z.number().int().positive().optional().nullable(),
|
||||||
|
anmeldung_erforderlich: z.boolean().default(false),
|
||||||
|
anmeldung_bis: z
|
||||||
|
.string()
|
||||||
|
.datetime({ offset: true, message: 'anmeldung_bis muss ein ISO-8601 Datum mit Zeitzone sein' })
|
||||||
|
.transform((s) => new Date(s))
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateVeranstaltungSchema = VeranstaltungBaseSchema.refine(
|
||||||
|
(d) => d.datum_bis >= d.datum_von,
|
||||||
|
{ message: 'datum_bis muss nach datum_von liegen', path: ['datum_bis'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CreateVeranstaltungData = z.infer<typeof CreateVeranstaltungSchema>;
|
||||||
|
|
||||||
|
export const UpdateVeranstaltungSchema = VeranstaltungBaseSchema.partial().refine(
|
||||||
|
(d) => d.datum_bis == null || d.datum_von == null || d.datum_bis >= d.datum_von,
|
||||||
|
{ message: 'datum_bis muss nach datum_von liegen', path: ['datum_bis'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
export type UpdateVeranstaltungData = z.infer<typeof UpdateVeranstaltungSchema>;
|
||||||
|
|
||||||
|
export const CancelVeranstaltungSchema = z.object({
|
||||||
|
abgesagt_grund: z
|
||||||
|
.string()
|
||||||
|
.min(5, 'Bitte gib einen Grund für die Absage an (min. 5 Zeichen)')
|
||||||
|
.max(1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CancelVeranstaltungData = z.infer<typeof CancelVeranstaltungSchema>;
|
||||||
37
backend/src/routes/booking.routes.ts
Normal file
37
backend/src/routes/booking.routes.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import bookingController from '../controllers/booking.controller';
|
||||||
|
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
|
||||||
|
import { requireGroups } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
|
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
|
||||||
|
const ADMIN_GROUPS = ['dashboard_admin'];
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ── Public (token-based, no session auth required) ───────────────────────────
|
||||||
|
|
||||||
|
router.get('/calendar.ics', optionalAuth, bookingController.getIcalExport.bind(bookingController));
|
||||||
|
|
||||||
|
// ── Read-only (all authenticated users) ──────────────────────────────────────
|
||||||
|
|
||||||
|
router.get('/calendar', authenticate, bookingController.getCalendarRange.bind(bookingController));
|
||||||
|
router.get('/upcoming', authenticate, bookingController.getUpcoming.bind(bookingController));
|
||||||
|
router.get('/availability', authenticate, bookingController.checkAvailability.bind(bookingController));
|
||||||
|
router.get('/calendar-token', authenticate, bookingController.getCalendarToken.bind(bookingController));
|
||||||
|
|
||||||
|
// ── Write operations ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
router.post('/', authenticate, requireGroups(WRITE_GROUPS), bookingController.create.bind(bookingController));
|
||||||
|
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.update.bind(bookingController));
|
||||||
|
|
||||||
|
// Soft-cancel (sets abgesagt=TRUE)
|
||||||
|
router.delete('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.cancel.bind(bookingController));
|
||||||
|
|
||||||
|
// Hard-delete (admin only)
|
||||||
|
router.delete('/:id/force', authenticate, requireGroups(ADMIN_GROUPS), bookingController.hardDelete.bind(bookingController));
|
||||||
|
|
||||||
|
// ── Single booking read — after specific routes to avoid path conflicts ───────
|
||||||
|
|
||||||
|
router.get('/:id', authenticate, bookingController.getById.bind(bookingController));
|
||||||
|
|
||||||
|
export default router;
|
||||||
146
backend/src/routes/events.routes.ts
Normal file
146
backend/src/routes/events.routes.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import eventsController from '../controllers/events.controller';
|
||||||
|
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
|
||||||
|
import { requireGroups } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/** Groups that may create, update, or cancel events */
|
||||||
|
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_moderator'];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Categories
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/kategorien
|
||||||
|
* List all event categories. Any authenticated user can read.
|
||||||
|
*/
|
||||||
|
router.get('/kategorien', authenticate, eventsController.listKategorien.bind(eventsController));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/events/kategorien
|
||||||
|
* Create a new category. Requires admin or moderator.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/kategorien',
|
||||||
|
authenticate,
|
||||||
|
requireGroups(WRITE_GROUPS),
|
||||||
|
eventsController.createKategorie.bind(eventsController)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/events/kategorien/:id
|
||||||
|
* Update an existing category. Requires admin or moderator.
|
||||||
|
*/
|
||||||
|
router.patch(
|
||||||
|
'/kategorien/:id',
|
||||||
|
authenticate,
|
||||||
|
requireGroups(WRITE_GROUPS),
|
||||||
|
eventsController.updateKategorie.bind(eventsController)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/events/kategorien/:id
|
||||||
|
* Delete a category (only if no events reference it). Requires admin or moderator.
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/kategorien/:id',
|
||||||
|
authenticate,
|
||||||
|
requireGroups(WRITE_GROUPS),
|
||||||
|
eventsController.deleteKategorie.bind(eventsController)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Known groups list (used by frontend to populate zielgruppen picker)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/groups
|
||||||
|
* Returns the list of known Authentik groups with human-readable labels.
|
||||||
|
*/
|
||||||
|
router.get('/groups', authenticate, eventsController.getAvailableGroups.bind(eventsController));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Calendar & upcoming — specific routes must come before /:id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/calendar?from=<ISO>&to=<ISO>
|
||||||
|
* Events in a date range, filtered by the requesting user's groups.
|
||||||
|
* Optional auth — unauthenticated callers only see alle_gruppen events.
|
||||||
|
*/
|
||||||
|
router.get('/calendar', optionalAuth, eventsController.getCalendarRange.bind(eventsController));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/upcoming?limit=10
|
||||||
|
* Next N upcoming events visible to the requesting user.
|
||||||
|
*/
|
||||||
|
router.get('/upcoming', optionalAuth, eventsController.getUpcoming.bind(eventsController));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/calendar-token
|
||||||
|
* Returns (or creates) the user's personal iCal subscribe token + URL.
|
||||||
|
* Requires authentication.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/calendar-token',
|
||||||
|
authenticate,
|
||||||
|
eventsController.getCalendarToken.bind(eventsController)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/calendar.ics?token=<token>
|
||||||
|
* iCal feed — authenticated via per-user opaque token.
|
||||||
|
* No Bearer token required; calendar clients use the token query param.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/calendar.ics',
|
||||||
|
optionalAuth,
|
||||||
|
eventsController.getIcalExport.bind(eventsController)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Events CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/events
|
||||||
|
* Create a new event. Requires admin or moderator.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
authenticate,
|
||||||
|
requireGroups(WRITE_GROUPS),
|
||||||
|
eventsController.createEvent.bind(eventsController)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/events/:id
|
||||||
|
* Single event detail. Any authenticated user.
|
||||||
|
*/
|
||||||
|
router.get('/:id', authenticate, eventsController.getById.bind(eventsController));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/events/:id
|
||||||
|
* Update an existing event. Requires admin or moderator.
|
||||||
|
*/
|
||||||
|
router.patch(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
requireGroups(WRITE_GROUPS),
|
||||||
|
eventsController.updateEvent.bind(eventsController)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/events/:id
|
||||||
|
* Soft-cancel an event (sets abgesagt=TRUE + reason). Requires admin or moderator.
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
authenticate,
|
||||||
|
requireGroups(WRITE_GROUPS),
|
||||||
|
eventsController.cancelEvent.bind(eventsController)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
390
backend/src/services/booking.service.ts
Normal file
390
backend/src/services/booking.service.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import pool from '../config/database';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
import {
|
||||||
|
FahrzeugBuchung,
|
||||||
|
FahrzeugBuchungListItem,
|
||||||
|
CreateBuchungData,
|
||||||
|
UpdateBuchungData,
|
||||||
|
} from '../models/booking.model';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Format a Date to iCal YYYYMMDDTHHMMSSZ format (UTC) */
|
||||||
|
function toIcalDate(d: Date): string {
|
||||||
|
return new Date(d).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToListItem(row: any): FahrzeugBuchungListItem {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
fahrzeug_id: row.fahrzeug_id,
|
||||||
|
fahrzeug_name: row.fahrzeug_name ?? '',
|
||||||
|
fahrzeug_kennzeichen: row.fahrzeug_kennzeichen ?? null,
|
||||||
|
titel: row.titel,
|
||||||
|
buchungs_art: row.buchungs_art,
|
||||||
|
beginn: new Date(row.beginn),
|
||||||
|
ende: new Date(row.ende),
|
||||||
|
abgesagt: row.abgesagt,
|
||||||
|
gebucht_von_name: row.gebucht_von_name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToBuchung(row: any): FahrzeugBuchung {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
fahrzeug_id: row.fahrzeug_id,
|
||||||
|
titel: row.titel,
|
||||||
|
beschreibung: row.beschreibung ?? null,
|
||||||
|
beginn: new Date(row.beginn),
|
||||||
|
ende: new Date(row.ende),
|
||||||
|
buchungs_art: row.buchungs_art,
|
||||||
|
gebucht_von: row.gebucht_von,
|
||||||
|
kontakt_person: row.kontakt_person ?? null,
|
||||||
|
kontakt_telefon: row.kontakt_telefon ?? null,
|
||||||
|
abgesagt: row.abgesagt,
|
||||||
|
abgesagt_grund: row.abgesagt_grund ?? null,
|
||||||
|
erstellt_am: new Date(row.erstellt_am),
|
||||||
|
aktualisiert_am: new Date(row.aktualisiert_am),
|
||||||
|
fahrzeug_name: row.fahrzeug_name ?? null,
|
||||||
|
fahrzeug_kennzeichen: row.fahrzeug_kennzeichen ?? null,
|
||||||
|
gebucht_von_name: row.gebucht_von_name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Booking Service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BookingService {
|
||||||
|
/**
|
||||||
|
* Returns bookings overlapping the given date range, optionally filtered
|
||||||
|
* to a single vehicle. Non-cancelled only.
|
||||||
|
*/
|
||||||
|
async getBookingsByRange(
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
fahrzeugId?: string
|
||||||
|
): Promise<FahrzeugBuchungListItem[]> {
|
||||||
|
const params: unknown[] = [from, to];
|
||||||
|
let vehicleFilter = '';
|
||||||
|
if (fahrzeugId) {
|
||||||
|
params.push(fahrzeugId);
|
||||||
|
vehicleFilter = `AND b.fahrzeug_id = $${params.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art,
|
||||||
|
b.beginn, b.ende, b.abgesagt,
|
||||||
|
f.name AS fahrzeug_name, f.kennzeichen AS fahrzeug_kennzeichen,
|
||||||
|
u.display_name AS gebucht_von_name
|
||||||
|
FROM fahrzeug_buchungen b
|
||||||
|
JOIN fahrzeuge f ON f.id = b.fahrzeug_id
|
||||||
|
JOIN users u ON u.id = b.gebucht_von
|
||||||
|
WHERE b.abgesagt = FALSE
|
||||||
|
AND (
|
||||||
|
b.beginn BETWEEN $1 AND $2
|
||||||
|
OR b.ende BETWEEN $1 AND $2
|
||||||
|
OR (b.beginn <= $1 AND b.ende >= $2)
|
||||||
|
)
|
||||||
|
${vehicleFilter}
|
||||||
|
ORDER BY b.beginn ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, params);
|
||||||
|
return rows.map(rowToListItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the next N upcoming non-cancelled bookings sorted ascending. */
|
||||||
|
async getUpcoming(limit = 20): Promise<FahrzeugBuchungListItem[]> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
b.id, b.fahrzeug_id, b.titel, b.buchungs_art::text AS buchungs_art,
|
||||||
|
b.beginn, b.ende, b.abgesagt,
|
||||||
|
f.name AS fahrzeug_name, f.kennzeichen AS fahrzeug_kennzeichen,
|
||||||
|
u.display_name AS gebucht_von_name
|
||||||
|
FROM fahrzeug_buchungen b
|
||||||
|
JOIN fahrzeuge f ON f.id = b.fahrzeug_id
|
||||||
|
JOIN users u ON u.id = b.gebucht_von
|
||||||
|
WHERE b.abgesagt = FALSE
|
||||||
|
AND b.beginn > NOW()
|
||||||
|
ORDER BY b.beginn ASC
|
||||||
|
LIMIT $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, [limit]);
|
||||||
|
return rows.map(rowToListItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a single booking by ID including all joined fields, or null. */
|
||||||
|
async getById(id: string): Promise<FahrzeugBuchung | null> {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
b.id, b.fahrzeug_id, b.titel, b.beschreibung,
|
||||||
|
b.buchungs_art::text AS buchungs_art,
|
||||||
|
b.beginn, b.ende,
|
||||||
|
b.gebucht_von, b.kontakt_person, b.kontakt_telefon,
|
||||||
|
b.abgesagt, b.abgesagt_grund,
|
||||||
|
b.erstellt_am, b.aktualisiert_am,
|
||||||
|
f.name AS fahrzeug_name, f.kennzeichen AS fahrzeug_kennzeichen,
|
||||||
|
u.display_name AS gebucht_von_name
|
||||||
|
FROM fahrzeug_buchungen b
|
||||||
|
JOIN fahrzeuge f ON f.id = b.fahrzeug_id
|
||||||
|
JOIN users u ON u.id = b.gebucht_von
|
||||||
|
WHERE b.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, [id]);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return rowToBuchung(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a vehicle is already booked for the given interval.
|
||||||
|
* Returns true if there IS a conflict.
|
||||||
|
* Pass excludeId to ignore a specific booking (used during updates).
|
||||||
|
*/
|
||||||
|
async checkConflict(
|
||||||
|
fahrzeugId: string,
|
||||||
|
beginn: Date,
|
||||||
|
ende: Date,
|
||||||
|
excludeId?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const query = `
|
||||||
|
SELECT 1
|
||||||
|
FROM fahrzeug_buchungen
|
||||||
|
WHERE fahrzeug_id = $1
|
||||||
|
AND abgesagt = FALSE
|
||||||
|
AND ($2::timestamptz, $3::timestamptz) OVERLAPS (beginn, ende)
|
||||||
|
AND ($4::uuid IS NULL OR id != $4)
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, [
|
||||||
|
fahrzeugId,
|
||||||
|
beginn,
|
||||||
|
ende,
|
||||||
|
excludeId ?? null,
|
||||||
|
]);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a new booking. Throws if the vehicle has a conflicting booking. */
|
||||||
|
async create(data: CreateBuchungData, userId: string): Promise<FahrzeugBuchung> {
|
||||||
|
const hasConflict = await this.checkConflict(
|
||||||
|
data.fahrzeugId,
|
||||||
|
data.beginn,
|
||||||
|
data.ende
|
||||||
|
);
|
||||||
|
if (hasConflict) {
|
||||||
|
throw new Error('Fahrzeug ist im gewählten Zeitraum bereits gebucht');
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO fahrzeug_buchungen
|
||||||
|
(fahrzeug_id, titel, beschreibung, beginn, ende, buchungs_art, gebucht_von, kontakt_person, kontakt_telefon)
|
||||||
|
VALUES
|
||||||
|
($1, $2, $3, $4, $5, $6::fahrzeug_buchung_art, $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, [
|
||||||
|
data.fahrzeugId,
|
||||||
|
data.titel,
|
||||||
|
data.beschreibung ?? null,
|
||||||
|
data.beginn,
|
||||||
|
data.ende,
|
||||||
|
data.buchungsArt,
|
||||||
|
userId,
|
||||||
|
data.kontaktPerson ?? null,
|
||||||
|
data.kontaktTelefon ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const newId: string = rows[0].id;
|
||||||
|
const booking = await this.getById(newId);
|
||||||
|
if (!booking) throw new Error('Buchung konnte nach dem Erstellen nicht geladen werden');
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the provided fields of a booking.
|
||||||
|
* Checks for conflicts when timing or vehicle fields change.
|
||||||
|
*/
|
||||||
|
async update(id: string, data: UpdateBuchungData): Promise<FahrzeugBuchung | null> {
|
||||||
|
const existing = await this.getById(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
// Resolve effective values for conflict check
|
||||||
|
const effectiveFahrzeugId = data.fahrzeugId ?? existing.fahrzeug_id;
|
||||||
|
const effectiveBeginn = data.beginn ?? existing.beginn;
|
||||||
|
const effectiveEnde = data.ende ?? existing.ende;
|
||||||
|
|
||||||
|
const timingChanged =
|
||||||
|
data.fahrzeugId != null || data.beginn != null || data.ende != null;
|
||||||
|
|
||||||
|
if (timingChanged) {
|
||||||
|
const hasConflict = await this.checkConflict(
|
||||||
|
effectiveFahrzeugId,
|
||||||
|
effectiveBeginn,
|
||||||
|
effectiveEnde,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
if (hasConflict) {
|
||||||
|
throw new Error('Fahrzeug ist im gewählten Zeitraum bereits gebucht');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic SET clause
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
const addField = (column: string, value: unknown, cast?: string) => {
|
||||||
|
params.push(value);
|
||||||
|
setClauses.push(`${column} = $${params.length}${cast ? `::${cast}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.fahrzeugId !== undefined) addField('fahrzeug_id', data.fahrzeugId);
|
||||||
|
if (data.titel !== undefined) addField('titel', data.titel);
|
||||||
|
if (data.beschreibung !== undefined) addField('beschreibung', data.beschreibung);
|
||||||
|
if (data.beginn !== undefined) addField('beginn', data.beginn);
|
||||||
|
if (data.ende !== undefined) addField('ende', data.ende);
|
||||||
|
if (data.buchungsArt !== undefined) addField('buchungs_art', data.buchungsArt, 'fahrzeug_buchung_art');
|
||||||
|
if (data.kontaktPerson !== undefined) addField('kontakt_person', data.kontaktPerson);
|
||||||
|
if (data.kontaktTelefon !== undefined) addField('kontakt_telefon', data.kontaktTelefon);
|
||||||
|
|
||||||
|
if (setClauses.length === 0) {
|
||||||
|
throw new Error('No fields to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
const query = `
|
||||||
|
UPDATE fahrzeug_buchungen
|
||||||
|
SET ${setClauses.join(', ')}, aktualisiert_am = NOW()
|
||||||
|
WHERE id = $${params.length}
|
||||||
|
`;
|
||||||
|
|
||||||
|
await pool.query(query, params);
|
||||||
|
return this.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-cancels a booking by setting abgesagt=TRUE and recording the reason. */
|
||||||
|
async cancel(id: string, abgesagt_grund: string): Promise<void> {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE fahrzeug_buchungen
|
||||||
|
SET abgesagt = TRUE, abgesagt_grund = $2, aktualisiert_am = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id, abgesagt_grund]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permanently deletes a booking record. */
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await pool.query('DELETE FROM fahrzeug_buchungen WHERE id = $1', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an existing iCal token for the user, or creates a new one.
|
||||||
|
* Also returns the subscribe URL the user can add to their calendar app.
|
||||||
|
*/
|
||||||
|
async getOrCreateIcalToken(
|
||||||
|
userId: string
|
||||||
|
): Promise<{ token: string; subscribeUrl: string }> {
|
||||||
|
const selectResult = await pool.query(
|
||||||
|
'SELECT token FROM fahrzeug_ical_tokens WHERE user_id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
let token: string;
|
||||||
|
if (selectResult.rows.length > 0) {
|
||||||
|
token = selectResult.rows[0].token;
|
||||||
|
} else {
|
||||||
|
const insertResult = await pool.query(
|
||||||
|
`INSERT INTO fahrzeug_ical_tokens (user_id)
|
||||||
|
VALUES ($1)
|
||||||
|
RETURNING token`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
token = insertResult.rows[0].token;
|
||||||
|
logger.info('Created new iCal token for user', { userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.ICAL_BASE_URL || 'http://localhost:3000';
|
||||||
|
const subscribeUrl = `${baseUrl}/api/bookings/calendar.ics?token=${token}`;
|
||||||
|
return { token, subscribeUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the iCal token and returns an iCal string for all (or one
|
||||||
|
* vehicle's) non-cancelled bookings. Returns null when the token is invalid.
|
||||||
|
*/
|
||||||
|
async getIcalExport(
|
||||||
|
token: string,
|
||||||
|
fahrzeugId?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
// Validate token and update last-used timestamp
|
||||||
|
const tokenResult = await pool.query(
|
||||||
|
`UPDATE fahrzeug_ical_tokens
|
||||||
|
SET zuletzt_verwendet_am = NOW()
|
||||||
|
WHERE token = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
if (tokenResult.rows.length === 0) return null;
|
||||||
|
|
||||||
|
// Fetch bookings
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let vehicleFilter = '';
|
||||||
|
if (fahrzeugId) {
|
||||||
|
params.push(fahrzeugId);
|
||||||
|
vehicleFilter = `AND b.fahrzeug_id = $${params.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
b.id, b.titel, b.beschreibung, b.buchungs_art::text AS buchungs_art,
|
||||||
|
b.beginn, b.ende,
|
||||||
|
f.name AS fahrzeug_name
|
||||||
|
FROM fahrzeug_buchungen b
|
||||||
|
JOIN fahrzeuge f ON f.id = b.fahrzeug_id
|
||||||
|
WHERE b.abgesagt = FALSE
|
||||||
|
${vehicleFilter}
|
||||||
|
ORDER BY b.beginn ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(query, params);
|
||||||
|
const now = toIcalDate(new Date());
|
||||||
|
|
||||||
|
const events = rows
|
||||||
|
.map((row: any) => {
|
||||||
|
const beschreibung = [row.buchungs_art, row.beschreibung]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' | ');
|
||||||
|
return (
|
||||||
|
'BEGIN:VEVENT\r\n' +
|
||||||
|
`UID:${row.id}@feuerwehr-buchungen\r\n` +
|
||||||
|
`DTSTAMP:${now}\r\n` +
|
||||||
|
`DTSTART:${toIcalDate(new Date(row.beginn))}\r\n` +
|
||||||
|
`DTEND:${toIcalDate(new Date(row.ende))}\r\n` +
|
||||||
|
`SUMMARY:${row.titel} - ${row.fahrzeug_name}\r\n` +
|
||||||
|
`DESCRIPTION:${beschreibung}\r\n` +
|
||||||
|
'END:VEVENT\r\n'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
'BEGIN:VCALENDAR\r\n' +
|
||||||
|
'VERSION:2.0\r\n' +
|
||||||
|
'PRODID:-//Feuerwehr Dashboard//Fahrzeugbuchungen//DE\r\n' +
|
||||||
|
'X-WR-CALNAME:Feuerwehr Fahrzeugbuchungen\r\n' +
|
||||||
|
'X-WR-TIMEZONE:Europe/Berlin\r\n' +
|
||||||
|
'CALSCALE:GREGORIAN\r\n' +
|
||||||
|
events +
|
||||||
|
'END:VCALENDAR\r\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new BookingService();
|
||||||
507
backend/src/services/events.service.ts
Normal file
507
backend/src/services/events.service.ts
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
import pool from '../config/database';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
import {
|
||||||
|
VeranstaltungKategorie,
|
||||||
|
Veranstaltung,
|
||||||
|
VeranstaltungListItem,
|
||||||
|
CreateKategorieData,
|
||||||
|
UpdateKategorieData,
|
||||||
|
CreateVeranstaltungData,
|
||||||
|
UpdateVeranstaltungData,
|
||||||
|
} from '../models/events.model';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Map a raw DB row to a VeranstaltungListItem */
|
||||||
|
function rowToListItem(row: any): VeranstaltungListItem {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
titel: row.titel,
|
||||||
|
ort: row.ort ?? null,
|
||||||
|
kategorie_id: row.kategorie_id ?? null,
|
||||||
|
kategorie_name: row.kategorie_name ?? null,
|
||||||
|
kategorie_farbe: row.kategorie_farbe ?? null,
|
||||||
|
kategorie_icon: row.kategorie_icon ?? null,
|
||||||
|
datum_von: new Date(row.datum_von),
|
||||||
|
datum_bis: new Date(row.datum_bis),
|
||||||
|
ganztaegig: row.ganztaegig,
|
||||||
|
alle_gruppen: row.alle_gruppen,
|
||||||
|
zielgruppen: row.zielgruppen ?? [],
|
||||||
|
abgesagt: row.abgesagt,
|
||||||
|
anmeldung_erforderlich: row.anmeldung_erforderlich,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a raw DB row to a full Veranstaltung */
|
||||||
|
function rowToVeranstaltung(row: any): Veranstaltung {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
titel: row.titel,
|
||||||
|
beschreibung: row.beschreibung ?? null,
|
||||||
|
ort: row.ort ?? null,
|
||||||
|
ort_url: row.ort_url ?? null,
|
||||||
|
kategorie_id: row.kategorie_id ?? null,
|
||||||
|
datum_von: new Date(row.datum_von),
|
||||||
|
datum_bis: new Date(row.datum_bis),
|
||||||
|
ganztaegig: row.ganztaegig,
|
||||||
|
zielgruppen: row.zielgruppen ?? [],
|
||||||
|
alle_gruppen: row.alle_gruppen,
|
||||||
|
max_teilnehmer: row.max_teilnehmer ?? null,
|
||||||
|
anmeldung_erforderlich: row.anmeldung_erforderlich,
|
||||||
|
anmeldung_bis: row.anmeldung_bis ? new Date(row.anmeldung_bis) : null,
|
||||||
|
erstellt_von: row.erstellt_von,
|
||||||
|
abgesagt: row.abgesagt,
|
||||||
|
abgesagt_grund: row.abgesagt_grund ?? null,
|
||||||
|
abgesagt_am: row.abgesagt_am ? new Date(row.abgesagt_am) : null,
|
||||||
|
erstellt_am: new Date(row.erstellt_am),
|
||||||
|
aktualisiert_am: new Date(row.aktualisiert_am),
|
||||||
|
// Joined fields
|
||||||
|
kategorie_name: row.kategorie_name ?? null,
|
||||||
|
kategorie_farbe: row.kategorie_farbe ?? null,
|
||||||
|
kategorie_icon: row.kategorie_icon ?? null,
|
||||||
|
erstellt_von_name: row.erstellt_von_name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a Date as YYYYMMDDTHHMMSSZ (UTC) for iCal output */
|
||||||
|
function formatIcalDate(date: Date): string {
|
||||||
|
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold long iCal lines at 75 octets (RFC 5545 §3.1) */
|
||||||
|
function icalFold(line: string): string {
|
||||||
|
if (line.length <= 75) return line;
|
||||||
|
let folded = '';
|
||||||
|
while (line.length > 75) {
|
||||||
|
folded += line.slice(0, 75) + '\r\n ';
|
||||||
|
line = line.slice(75);
|
||||||
|
}
|
||||||
|
folded += line;
|
||||||
|
return folded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape special characters in iCal text values (RFC 5545 §3.3.11) */
|
||||||
|
function icalEscape(value: string | null | undefined): string {
|
||||||
|
if (!value) return '';
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/;/g, '\\;')
|
||||||
|
.replace(/,/g, '\\,')
|
||||||
|
.replace(/\n/g, '\\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Events Service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class EventsService {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// KATEGORIEN
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Returns all event categories ordered by name. */
|
||||||
|
async getKategorien(): Promise<VeranstaltungKategorie[]> {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am
|
||||||
|
FROM veranstaltung_kategorien
|
||||||
|
ORDER BY name ASC
|
||||||
|
`);
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
beschreibung: row.beschreibung ?? null,
|
||||||
|
farbe: row.farbe ?? null,
|
||||||
|
icon: row.icon ?? null,
|
||||||
|
erstellt_von: row.erstellt_von ?? null,
|
||||||
|
erstellt_am: new Date(row.erstellt_am),
|
||||||
|
aktualisiert_am: new Date(row.aktualisiert_am),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a new event category. */
|
||||||
|
async createKategorie(data: CreateKategorieData, userId: string): Promise<VeranstaltungKategorie> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, erstellt_von)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am`,
|
||||||
|
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, userId]
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
beschreibung: row.beschreibung ?? null,
|
||||||
|
farbe: row.farbe ?? null,
|
||||||
|
icon: row.icon ?? null,
|
||||||
|
erstellt_von: row.erstellt_von ?? null,
|
||||||
|
erstellt_am: new Date(row.erstellt_am),
|
||||||
|
aktualisiert_am: new Date(row.aktualisiert_am),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates an existing event category. Returns null if not found. */
|
||||||
|
async updateKategorie(id: string, data: UpdateKategorieData): Promise<VeranstaltungKategorie | null> {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (data.name !== undefined) { fields.push(`name = $${idx++}`); values.push(data.name); }
|
||||||
|
if (data.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung); }
|
||||||
|
if (data.farbe !== undefined) { fields.push(`farbe = $${idx++}`); values.push(data.farbe); }
|
||||||
|
if (data.icon !== undefined) { fields.push(`icon = $${idx++}`); values.push(data.icon); }
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
// Nothing to update — return the existing record
|
||||||
|
const existing = await pool.query(
|
||||||
|
`SELECT id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am
|
||||||
|
FROM veranstaltung_kategorien WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (existing.rows.length === 0) return null;
|
||||||
|
const row = existing.rows[0];
|
||||||
|
return {
|
||||||
|
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
|
||||||
|
farbe: row.farbe ?? null, icon: row.icon ?? null, erstellt_von: row.erstellt_von ?? null,
|
||||||
|
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(`aktualisiert_am = NOW()`);
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE veranstaltung_kategorien SET ${fields.join(', ')}
|
||||||
|
WHERE id = $${idx}
|
||||||
|
RETURNING id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
const row = result.rows[0];
|
||||||
|
return {
|
||||||
|
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
|
||||||
|
farbe: row.farbe ?? null, icon: row.icon ?? null, erstellt_von: row.erstellt_von ?? null,
|
||||||
|
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an event category.
|
||||||
|
* Throws if any events still reference this category.
|
||||||
|
*/
|
||||||
|
async deleteKategorie(id: string): Promise<void> {
|
||||||
|
const refCheck = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS cnt FROM veranstaltungen WHERE kategorie_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (Number(refCheck.rows[0].cnt) > 0) {
|
||||||
|
throw new Error('Kategorie kann nicht gelöscht werden, da sie noch Veranstaltungen enthält');
|
||||||
|
}
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM veranstaltung_kategorien WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error('Kategorie nicht gefunden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// EVENTS — queries
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns events within [from, to] visible to the requesting user.
|
||||||
|
* Visibility: alle_gruppen=TRUE OR zielgruppen overlap with userGroups
|
||||||
|
* OR user is dashboard_admin.
|
||||||
|
*/
|
||||||
|
async getEventsByDateRange(
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
userGroups: string[]
|
||||||
|
): Promise<VeranstaltungListItem[]> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
v.id, v.titel, v.ort, v.kategorie_id,
|
||||||
|
k.name AS kategorie_name,
|
||||||
|
k.farbe AS kategorie_farbe,
|
||||||
|
k.icon AS kategorie_icon,
|
||||||
|
v.datum_von, v.datum_bis, v.ganztaegig,
|
||||||
|
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
|
||||||
|
FROM veranstaltungen v
|
||||||
|
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
||||||
|
WHERE (v.datum_von BETWEEN $1 AND $2 OR v.datum_bis BETWEEN $1 AND $2)
|
||||||
|
AND (
|
||||||
|
v.alle_gruppen = TRUE
|
||||||
|
OR v.zielgruppen && $3
|
||||||
|
OR 'dashboard_admin' = ANY($3)
|
||||||
|
)
|
||||||
|
ORDER BY v.datum_von ASC`,
|
||||||
|
[from, to, userGroups]
|
||||||
|
);
|
||||||
|
return result.rows.map(rowToListItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next N upcoming events (datum_von > NOW) visible to the user.
|
||||||
|
*/
|
||||||
|
async getUpcomingEvents(limit: number, userGroups: string[]): Promise<VeranstaltungListItem[]> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
v.id, v.titel, v.ort, v.kategorie_id,
|
||||||
|
k.name AS kategorie_name,
|
||||||
|
k.farbe AS kategorie_farbe,
|
||||||
|
k.icon AS kategorie_icon,
|
||||||
|
v.datum_von, v.datum_bis, v.ganztaegig,
|
||||||
|
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
|
||||||
|
FROM veranstaltungen v
|
||||||
|
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
||||||
|
WHERE v.datum_von > NOW()
|
||||||
|
AND (
|
||||||
|
v.alle_gruppen = TRUE
|
||||||
|
OR v.zielgruppen && $2
|
||||||
|
OR 'dashboard_admin' = ANY($2)
|
||||||
|
)
|
||||||
|
ORDER BY v.datum_von ASC
|
||||||
|
LIMIT $1`,
|
||||||
|
[limit, userGroups]
|
||||||
|
);
|
||||||
|
return result.rows.map(rowToListItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a single event with joined kategorie and creator info. Returns null if not found. */
|
||||||
|
async getById(id: string): Promise<Veranstaltung | null> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
v.*,
|
||||||
|
k.name AS kategorie_name,
|
||||||
|
k.farbe AS kategorie_farbe,
|
||||||
|
k.icon AS kategorie_icon,
|
||||||
|
u.name AS erstellt_von_name
|
||||||
|
FROM veranstaltungen v
|
||||||
|
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
||||||
|
LEFT JOIN users u ON u.id = v.erstellt_von
|
||||||
|
WHERE v.id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
return rowToVeranstaltung(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// EVENTS — mutations
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Creates a new event and returns the full record. */
|
||||||
|
async createEvent(data: CreateVeranstaltungData, userId: string): Promise<Veranstaltung> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO veranstaltungen (
|
||||||
|
titel, beschreibung, ort, ort_url, kategorie_id,
|
||||||
|
datum_von, datum_bis, ganztaegig,
|
||||||
|
zielgruppen, alle_gruppen,
|
||||||
|
max_teilnehmer, anmeldung_erforderlich, anmeldung_bis,
|
||||||
|
erstellt_von
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.titel,
|
||||||
|
data.beschreibung ?? null,
|
||||||
|
data.ort ?? null,
|
||||||
|
data.ort_url ?? null,
|
||||||
|
data.kategorie_id ?? null,
|
||||||
|
data.datum_von,
|
||||||
|
data.datum_bis,
|
||||||
|
data.ganztaegig,
|
||||||
|
data.zielgruppen,
|
||||||
|
data.alle_gruppen,
|
||||||
|
data.max_teilnehmer ?? null,
|
||||||
|
data.anmeldung_erforderlich,
|
||||||
|
data.anmeldung_bis ?? null,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return rowToVeranstaltung(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing event.
|
||||||
|
* Returns the updated record or null if not found.
|
||||||
|
*/
|
||||||
|
async updateEvent(id: string, data: UpdateVeranstaltungData): Promise<Veranstaltung | null> {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
const fieldMap: Record<string, any> = {
|
||||||
|
titel: data.titel,
|
||||||
|
beschreibung: data.beschreibung,
|
||||||
|
ort: data.ort,
|
||||||
|
ort_url: data.ort_url,
|
||||||
|
kategorie_id: data.kategorie_id,
|
||||||
|
datum_von: data.datum_von,
|
||||||
|
datum_bis: data.datum_bis,
|
||||||
|
ganztaegig: data.ganztaegig,
|
||||||
|
zielgruppen: data.zielgruppen,
|
||||||
|
alle_gruppen: data.alle_gruppen,
|
||||||
|
max_teilnehmer: data.max_teilnehmer,
|
||||||
|
anmeldung_erforderlich: data.anmeldung_erforderlich,
|
||||||
|
anmeldung_bis: data.anmeldung_bis,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [col, val] of Object.entries(fieldMap)) {
|
||||||
|
if (val !== undefined) {
|
||||||
|
fields.push(`${col} = $${idx++}`);
|
||||||
|
values.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return this.getById(id);
|
||||||
|
|
||||||
|
fields.push(`aktualisiert_am = NOW()`);
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE veranstaltungen SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
return rowToVeranstaltung(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-cancels an event by setting abgesagt=TRUE, recording the reason
|
||||||
|
* and the timestamp.
|
||||||
|
*/
|
||||||
|
async cancelEvent(id: string, grund: string, userId: string): Promise<void> {
|
||||||
|
logger.info('Cancelling event', { id, userId });
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE veranstaltungen
|
||||||
|
SET abgesagt = TRUE, abgesagt_grund = $2, abgesagt_am = NOW(), aktualisiert_am = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id, grund]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error('Event not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// ICAL TOKEN
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns (or creates) the personal iCal subscription token for a user.
|
||||||
|
*
|
||||||
|
* The subscribeUrl is built from ICAL_BASE_URL (env) so it can be used
|
||||||
|
* directly in calendar clients without any further transformation.
|
||||||
|
*/
|
||||||
|
async getOrCreateIcalToken(userId: string): Promise<{ token: string; subscribeUrl: string }> {
|
||||||
|
// Attempt to fetch an existing token first
|
||||||
|
const existing = await pool.query(
|
||||||
|
`SELECT token FROM veranstaltung_ical_tokens WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
let token: string;
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
token = existing.rows[0].token;
|
||||||
|
} else {
|
||||||
|
// Insert a new row — the DEFAULT clause generates the token via gen_random_bytes
|
||||||
|
const inserted = await pool.query(
|
||||||
|
`INSERT INTO veranstaltung_ical_tokens (user_id)
|
||||||
|
VALUES ($1)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET user_id = EXCLUDED.user_id
|
||||||
|
RETURNING token`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
token = inserted.rows[0].token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = (process.env.ICAL_BASE_URL ?? '').replace(/\/$/, '');
|
||||||
|
const subscribeUrl = `${baseUrl}/api/events/calendar.ics?token=${token}`;
|
||||||
|
|
||||||
|
return { token, subscribeUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// ICAL EXPORT
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an iCal feed for a given token.
|
||||||
|
*
|
||||||
|
* NOTE — Group visibility limitation:
|
||||||
|
* Groups are issued by Authentik and embedded only in the short-lived JWT.
|
||||||
|
* They are NOT persisted in the database. For token-based iCal access we
|
||||||
|
* therefore cannot look up which Authentik groups a user belongs to.
|
||||||
|
* As a safe fallback this export includes only events where alle_gruppen=TRUE
|
||||||
|
* (i.e. events intended for everyone). Authenticated users who request the
|
||||||
|
* .ics directly via Bearer token already get group-filtered results through
|
||||||
|
* the normal API endpoints.
|
||||||
|
*
|
||||||
|
* Returns null if the token is invalid.
|
||||||
|
*/
|
||||||
|
async getIcalExport(token: string): Promise<string | null> {
|
||||||
|
// Validate token and update last-used timestamp
|
||||||
|
const tokenResult = await pool.query(
|
||||||
|
`UPDATE veranstaltung_ical_tokens
|
||||||
|
SET zuletzt_verwendet_am = NOW()
|
||||||
|
WHERE token = $1
|
||||||
|
RETURNING user_id`,
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tokenResult.rows.length === 0) return null;
|
||||||
|
|
||||||
|
// Fetch public events: all future events + those that ended in the last 30 days
|
||||||
|
// Only alle_gruppen=TRUE events — see NOTE above about group limitation
|
||||||
|
const eventsResult = await pool.query(
|
||||||
|
`SELECT v.id, v.titel, v.beschreibung, v.ort, v.datum_von, v.datum_bis, v.ganztaegig, v.abgesagt
|
||||||
|
FROM veranstaltungen v
|
||||||
|
WHERE v.alle_gruppen = TRUE
|
||||||
|
AND v.datum_bis >= NOW() - INTERVAL '30 days'
|
||||||
|
ORDER BY v.datum_von ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lines: string[] = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Feuerwehr Dashboard//Veranstaltungen//DE',
|
||||||
|
'X-WR-CALNAME:Feuerwehr Veranstaltungen',
|
||||||
|
'X-WR-TIMEZONE:Europe/Berlin',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const row of eventsResult.rows) {
|
||||||
|
const datumVon = new Date(row.datum_von);
|
||||||
|
const datumBis = new Date(row.datum_bis);
|
||||||
|
|
||||||
|
lines.push('BEGIN:VEVENT');
|
||||||
|
lines.push(icalFold(`UID:${row.id}@feuerwehr-veranstaltungen`));
|
||||||
|
lines.push(`DTSTAMP:${formatIcalDate(now)}`);
|
||||||
|
lines.push(`DTSTART:${formatIcalDate(datumVon)}`);
|
||||||
|
lines.push(`DTEND:${formatIcalDate(datumBis)}`);
|
||||||
|
lines.push(icalFold(`SUMMARY:${row.abgesagt ? '[ABGESAGT] ' : ''}${icalEscape(row.titel)}`));
|
||||||
|
if (row.beschreibung) {
|
||||||
|
lines.push(icalFold(`DESCRIPTION:${icalEscape(row.beschreibung)}`));
|
||||||
|
}
|
||||||
|
if (row.ort) {
|
||||||
|
lines.push(icalFold(`LOCATION:${icalEscape(row.ort)}`));
|
||||||
|
}
|
||||||
|
if (row.abgesagt) {
|
||||||
|
lines.push('STATUS:CANCELLED');
|
||||||
|
}
|
||||||
|
lines.push('END:VEVENT');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('END:VCALENDAR');
|
||||||
|
|
||||||
|
// RFC 5545 requires CRLF line endings
|
||||||
|
return lines.join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new EventsService();
|
||||||
862
frontend/src/pages/FahrzeugBuchungen.tsx
Normal file
862
frontend/src/pages/FahrzeugBuchungen.tsx
Normal file
@@ -0,0 +1,862 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Chip,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Fab,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Popover,
|
||||||
|
Stack,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Today,
|
||||||
|
ContentCopy,
|
||||||
|
Cancel,
|
||||||
|
Edit,
|
||||||
|
IosShare,
|
||||||
|
CheckCircle,
|
||||||
|
Warning,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
import { bookingApi, fetchVehicles } from '../services/bookings';
|
||||||
|
import type {
|
||||||
|
FahrzeugBuchungListItem,
|
||||||
|
Fahrzeug,
|
||||||
|
CreateBuchungInput,
|
||||||
|
BuchungsArt,
|
||||||
|
} from '../types/booking.types';
|
||||||
|
import { BUCHUNGS_ART_LABELS, BUCHUNGS_ART_COLORS } from '../types/booking.types';
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
startOfWeek,
|
||||||
|
endOfWeek,
|
||||||
|
addWeeks,
|
||||||
|
subWeeks,
|
||||||
|
eachDayOfInterval,
|
||||||
|
isToday,
|
||||||
|
parseISO,
|
||||||
|
isSameDay,
|
||||||
|
} from 'date-fns';
|
||||||
|
import { de } from 'date-fns/locale';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMPTY_FORM: CreateBuchungInput = {
|
||||||
|
fahrzeugId: '',
|
||||||
|
titel: '',
|
||||||
|
beschreibung: '',
|
||||||
|
beginn: '',
|
||||||
|
ende: '',
|
||||||
|
buchungsArt: 'intern',
|
||||||
|
kontaktPerson: '',
|
||||||
|
kontaktTelefon: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FahrzeugBuchungen() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const notification = useNotification();
|
||||||
|
const canWrite =
|
||||||
|
user?.groups?.some((g) => WRITE_GROUPS.includes(g)) ?? false;
|
||||||
|
|
||||||
|
// ── Week navigation ────────────────────────────────────────────────────────
|
||||||
|
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
|
||||||
|
startOfWeek(new Date(), { weekStartsOn: 1 })
|
||||||
|
);
|
||||||
|
|
||||||
|
const weekEnd = endOfWeek(currentWeekStart, { weekStartsOn: 1 });
|
||||||
|
|
||||||
|
const weekDays = eachDayOfInterval({ start: currentWeekStart, end: weekEnd });
|
||||||
|
|
||||||
|
const weekLabel = `KW ${format(currentWeekStart, 'w')} · ${format(
|
||||||
|
currentWeekStart,
|
||||||
|
'dd.MM.'
|
||||||
|
)} – ${format(weekEnd, 'dd.MM.yyyy')}`;
|
||||||
|
|
||||||
|
// ── Data ──────────────────────────────────────────────────────────────────
|
||||||
|
const [vehicles, setVehicles] = useState<Fahrzeug[]>([]);
|
||||||
|
const [bookings, setBookings] = useState<FahrzeugBuchungListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const end = endOfWeek(currentWeekStart, { weekStartsOn: 1 });
|
||||||
|
const [vehiclesData, bookingsData] = await Promise.all([
|
||||||
|
fetchVehicles(),
|
||||||
|
bookingApi.getCalendarRange(currentWeekStart, end),
|
||||||
|
]);
|
||||||
|
setVehicles(vehiclesData);
|
||||||
|
setBookings(bookingsData);
|
||||||
|
setError(null);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Fehler beim Laden';
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentWeekStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// ── Cell helper ───────────────────────────────────────────────────────────
|
||||||
|
const getBookingsForCell = (
|
||||||
|
vehicleId: string,
|
||||||
|
day: Date
|
||||||
|
): FahrzeugBuchungListItem[] => {
|
||||||
|
return bookings.filter((b) => {
|
||||||
|
if (b.fahrzeug_id !== vehicleId || b.abgesagt) return false;
|
||||||
|
const start = parseISO(b.beginn);
|
||||||
|
const end = parseISO(b.ende);
|
||||||
|
return (
|
||||||
|
isSameDay(start, day) ||
|
||||||
|
isSameDay(end, day) ||
|
||||||
|
(start < day && end > day)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Create / Edit dialog ──────────────────────────────────────────────────
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingBooking, setEditingBooking] =
|
||||||
|
useState<FahrzeugBuchungListItem | null>(null);
|
||||||
|
const [form, setForm] = useState<CreateBuchungInput>({ ...EMPTY_FORM });
|
||||||
|
const [dialogLoading, setDialogLoading] = useState(false);
|
||||||
|
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||||
|
const [availability, setAvailability] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
// Check availability whenever the relevant form fields change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!form.fahrzeugId || !form.beginn || !form.ende) {
|
||||||
|
setAvailability(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
bookingApi
|
||||||
|
.checkAvailability(
|
||||||
|
form.fahrzeugId,
|
||||||
|
new Date(form.beginn),
|
||||||
|
new Date(form.ende)
|
||||||
|
)
|
||||||
|
.then(({ available }) => {
|
||||||
|
if (!cancelled) setAvailability(available);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAvailability(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [form.fahrzeugId, form.beginn, form.ende]);
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
setEditingBooking(null);
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setDialogError(null);
|
||||||
|
setAvailability(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCellClick = (vehicleId: string, day: Date) => {
|
||||||
|
if (!canWrite) return;
|
||||||
|
const dateStr = format(day, "yyyy-MM-dd'T'08:00");
|
||||||
|
const dateEndStr = format(day, "yyyy-MM-dd'T'17:00");
|
||||||
|
setEditingBooking(null);
|
||||||
|
setForm({ ...EMPTY_FORM, fahrzeugId: vehicleId, beginn: dateStr, ende: dateEndStr });
|
||||||
|
setDialogError(null);
|
||||||
|
setAvailability(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setDialogLoading(true);
|
||||||
|
setDialogError(null);
|
||||||
|
try {
|
||||||
|
const payload: CreateBuchungInput = {
|
||||||
|
...form,
|
||||||
|
beginn: new Date(form.beginn).toISOString(),
|
||||||
|
ende: new Date(form.ende).toISOString(),
|
||||||
|
};
|
||||||
|
if (editingBooking) {
|
||||||
|
await bookingApi.update(editingBooking.id, payload);
|
||||||
|
notification.showSuccess('Buchung aktualisiert');
|
||||||
|
} else {
|
||||||
|
await bookingApi.create(payload);
|
||||||
|
notification.showSuccess('Buchung erstellt');
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
loadData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const axiosError = e as { response?: { status?: number }; message?: string };
|
||||||
|
if (axiosError?.response?.status === 409) {
|
||||||
|
setDialogError('Fahrzeug ist im gewählten Zeitraum bereits gebucht');
|
||||||
|
} else {
|
||||||
|
setDialogError(axiosError?.message || 'Fehler beim Speichern');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDialogLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Cancel dialog ─────────────────────────────────────────────────────────
|
||||||
|
const [cancelId, setCancelId] = useState<string | null>(null);
|
||||||
|
const [cancelGrund, setCancelGrund] = useState('');
|
||||||
|
const [cancelLoading, setCancelLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!cancelId) return;
|
||||||
|
setCancelLoading(true);
|
||||||
|
try {
|
||||||
|
await bookingApi.cancel(cancelId, cancelGrund);
|
||||||
|
notification.showSuccess('Buchung storniert');
|
||||||
|
setCancelId(null);
|
||||||
|
setDetailAnchor(null);
|
||||||
|
setDetailBooking(null);
|
||||||
|
loadData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Fehler beim Stornieren';
|
||||||
|
notification.showError(msg);
|
||||||
|
} finally {
|
||||||
|
setCancelLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Detail popover ────────────────────────────────────────────────────────
|
||||||
|
const [detailAnchor, setDetailAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
const [detailBooking, setDetailBooking] =
|
||||||
|
useState<FahrzeugBuchungListItem | null>(null);
|
||||||
|
|
||||||
|
const handleBookingClick = (
|
||||||
|
e: React.MouseEvent<HTMLElement>,
|
||||||
|
booking: FahrzeugBuchungListItem
|
||||||
|
) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDetailBooking(booking);
|
||||||
|
setDetailAnchor(e.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = () => {
|
||||||
|
if (!detailBooking) return;
|
||||||
|
setEditingBooking(detailBooking);
|
||||||
|
setForm({
|
||||||
|
fahrzeugId: detailBooking.fahrzeug_id,
|
||||||
|
titel: detailBooking.titel,
|
||||||
|
beschreibung: '',
|
||||||
|
beginn: format(parseISO(detailBooking.beginn), "yyyy-MM-dd'T'HH:mm"),
|
||||||
|
ende: format(parseISO(detailBooking.ende), "yyyy-MM-dd'T'HH:mm"),
|
||||||
|
buchungsArt: detailBooking.buchungs_art,
|
||||||
|
kontaktPerson: '',
|
||||||
|
kontaktTelefon: '',
|
||||||
|
});
|
||||||
|
setDialogError(null);
|
||||||
|
setAvailability(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
setDetailAnchor(null);
|
||||||
|
setDetailBooking(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCancel = () => {
|
||||||
|
if (!detailBooking) return;
|
||||||
|
setCancelId(detailBooking.id);
|
||||||
|
setCancelGrund('');
|
||||||
|
setDetailAnchor(null);
|
||||||
|
setDetailBooking(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── iCal dialog ───────────────────────────────────────────────────────────
|
||||||
|
const [icalOpen, setIcalOpen] = useState(false);
|
||||||
|
const [icalUrl, setIcalUrl] = useState('');
|
||||||
|
|
||||||
|
const handleIcalOpen = async () => {
|
||||||
|
try {
|
||||||
|
const { subscribeUrl } = await bookingApi.getCalendarToken();
|
||||||
|
setIcalUrl(subscribeUrl);
|
||||||
|
setIcalOpen(true);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Fehler beim Laden des Tokens';
|
||||||
|
notification.showError(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4" fontWeight={700}>
|
||||||
|
Fahrzeugbuchungen
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
startIcon={<IosShare />}
|
||||||
|
onClick={handleIcalOpen}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Kalender
|
||||||
|
</Button>
|
||||||
|
{canWrite && (
|
||||||
|
<Button
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={openCreateDialog}
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Neue Buchung
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Week navigation ── */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<IconButton onClick={() => setCurrentWeekStart((d) => subWeeks(d, 1))}>
|
||||||
|
<ChevronLeft />
|
||||||
|
</IconButton>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{ minWidth: 280, textAlign: 'center', userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
{weekLabel}
|
||||||
|
</Typography>
|
||||||
|
<IconButton onClick={() => setCurrentWeekStart((d) => addWeeks(d, 1))}>
|
||||||
|
<ChevronRight />
|
||||||
|
</IconButton>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }))
|
||||||
|
}
|
||||||
|
startIcon={<Today />}
|
||||||
|
>
|
||||||
|
Heute
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Loading / error ── */}
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!loading && error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Timeline table ── */}
|
||||||
|
{!loading && !error && (
|
||||||
|
<TableContainer component={Paper} elevation={1}>
|
||||||
|
<Table size="small" sx={{ tableLayout: 'fixed' }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: 'grey.100' }}>
|
||||||
|
<TableCell sx={{ width: 160, fontWeight: 700 }}>
|
||||||
|
Fahrzeug
|
||||||
|
</TableCell>
|
||||||
|
{weekDays.map((day) => (
|
||||||
|
<TableCell
|
||||||
|
key={day.toISOString()}
|
||||||
|
align="center"
|
||||||
|
sx={{
|
||||||
|
fontWeight: isToday(day) ? 700 : 400,
|
||||||
|
color: isToday(day) ? 'primary.main' : 'text.primary',
|
||||||
|
bgcolor: isToday(day) ? 'primary.50' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" display="block">
|
||||||
|
{format(day, 'EEE', { locale: de })}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight="inherit">
|
||||||
|
{format(day, 'dd.MM.')}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{vehicles.map((vehicle) => (
|
||||||
|
<TableRow key={vehicle.id} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{vehicle.name}
|
||||||
|
</Typography>
|
||||||
|
{vehicle.kennzeichen && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{vehicle.kennzeichen}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
{weekDays.map((day) => {
|
||||||
|
const cellBookings = getBookingsForCell(vehicle.id, day);
|
||||||
|
const isFree = cellBookings.length === 0;
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={day.toISOString()}
|
||||||
|
onClick={() =>
|
||||||
|
isFree ? handleCellClick(vehicle.id, day) : undefined
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
bgcolor: isFree ? 'success.50' : undefined,
|
||||||
|
cursor: isFree && canWrite ? 'pointer' : 'default',
|
||||||
|
'&:hover': isFree && canWrite
|
||||||
|
? { bgcolor: 'success.100' }
|
||||||
|
: {},
|
||||||
|
p: 0.5,
|
||||||
|
verticalAlign: 'top',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cellBookings.map((b) => (
|
||||||
|
<Tooltip
|
||||||
|
key={b.id}
|
||||||
|
title={`${b.titel} (${BUCHUNGS_ART_LABELS[b.buchungs_art]})`}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
label={
|
||||||
|
b.titel.length > 12
|
||||||
|
? b.titel.slice(0, 12) + '…'
|
||||||
|
: b.titel
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => handleBookingClick(e, b)}
|
||||||
|
sx={{
|
||||||
|
bgcolor: BUCHUNGS_ART_COLORS[b.buchungs_art],
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
height: 20,
|
||||||
|
mb: 0.25,
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{vehicles.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center">
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ py: 2 }}
|
||||||
|
>
|
||||||
|
Keine aktiven Fahrzeuge
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Legend ── */}
|
||||||
|
{!loading && !error && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mt: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
bgcolor: 'success.50',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'success.300',
|
||||||
|
borderRadius: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption">Frei</Typography>
|
||||||
|
</Box>
|
||||||
|
{(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map(
|
||||||
|
([art, label]) => (
|
||||||
|
<Box key={art} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
bgcolor: BUCHUNGS_ART_COLORS[art],
|
||||||
|
borderRadius: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption">{label}</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── FAB ── */}
|
||||||
|
{canWrite && (
|
||||||
|
<Fab
|
||||||
|
color="primary"
|
||||||
|
aria-label="Buchung erstellen"
|
||||||
|
sx={{ position: 'fixed', bottom: 32, right: 32 }}
|
||||||
|
onClick={openCreateDialog}
|
||||||
|
>
|
||||||
|
<Add />
|
||||||
|
</Fab>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Booking detail popover ── */}
|
||||||
|
<Popover
|
||||||
|
open={Boolean(detailAnchor)}
|
||||||
|
anchorEl={detailAnchor}
|
||||||
|
onClose={() => {
|
||||||
|
setDetailAnchor(null);
|
||||||
|
setDetailBooking(null);
|
||||||
|
}}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
{detailBooking && (
|
||||||
|
<Box sx={{ p: 2, maxWidth: 280 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={700}>
|
||||||
|
{detailBooking.titel}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={BUCHUNGS_ART_LABELS[detailBooking.buchungs_art]}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: BUCHUNGS_ART_COLORS[detailBooking.buchungs_art],
|
||||||
|
color: 'white',
|
||||||
|
mb: 1,
|
||||||
|
mt: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{format(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')}
|
||||||
|
{' – '}
|
||||||
|
{format(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')}
|
||||||
|
</Typography>
|
||||||
|
{detailBooking.gebucht_von_name && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
|
Von: {detailBooking.gebucht_von_name}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{canWrite && (
|
||||||
|
<Box sx={{ mt: 1.5, display: 'flex', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<Edit />}
|
||||||
|
onClick={handleOpenEdit}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
startIcon={<Cancel />}
|
||||||
|
onClick={handleOpenCancel}
|
||||||
|
>
|
||||||
|
Stornieren
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* ── Create / Edit dialog ── */}
|
||||||
|
<Dialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingBooking ? 'Buchung bearbeiten' : 'Neue Buchung'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{dialogError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2, mt: 1 }}>
|
||||||
|
{dialogError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<FormControl fullWidth size="small" required>
|
||||||
|
<InputLabel>Fahrzeug</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.fahrzeugId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, fahrzeugId: e.target.value }))
|
||||||
|
}
|
||||||
|
label="Fahrzeug"
|
||||||
|
>
|
||||||
|
{vehicles.map((v) => (
|
||||||
|
<MenuItem key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
{v.kennzeichen ? ` (${v.kennzeichen})` : ''}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Titel"
|
||||||
|
required
|
||||||
|
value={form.titel}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, titel: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Beschreibung"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={form.beschreibung || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, beschreibung: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Beginn"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
value={form.beginn}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, beginn: e.target.value }))
|
||||||
|
}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Ende"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
value={form.ende}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, ende: e.target.value }))
|
||||||
|
}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{availability !== null && (
|
||||||
|
<Chip
|
||||||
|
icon={availability ? <CheckCircle /> : <Warning />}
|
||||||
|
label={
|
||||||
|
availability
|
||||||
|
? 'Fahrzeug verfügbar'
|
||||||
|
: 'Konflikt: bereits gebucht'
|
||||||
|
}
|
||||||
|
color={availability ? 'success' : 'error'}
|
||||||
|
size="small"
|
||||||
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Buchungsart</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.buchungsArt}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
buchungsArt: e.target.value as BuchungsArt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
label="Buchungsart"
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]
|
||||||
|
).map(([art, label]) => (
|
||||||
|
<MenuItem key={art} value={art}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{form.buchungsArt === 'extern' && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Kontaktperson"
|
||||||
|
value={form.kontaktPerson || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, kontaktPerson: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Kontakttelefon"
|
||||||
|
value={form.kontaktTelefon || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
kontaktTelefon: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={
|
||||||
|
dialogLoading ||
|
||||||
|
!form.titel ||
|
||||||
|
!form.fahrzeugId ||
|
||||||
|
!form.beginn ||
|
||||||
|
!form.ende
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dialogLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : editingBooking ? (
|
||||||
|
'Speichern'
|
||||||
|
) : (
|
||||||
|
'Buchen'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── Cancel dialog ── */}
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(cancelId)}
|
||||||
|
onClose={() => setCancelId(null)}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Buchung stornieren</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
label="Stornierungsgrund"
|
||||||
|
value={cancelGrund}
|
||||||
|
onChange={(e) => setCancelGrund(e.target.value)}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
helperText={`${cancelGrund.length}/1000 (min. 5 Zeichen)`}
|
||||||
|
inputProps={{ maxLength: 1000 }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
onClick={() => setCancelId(null)}
|
||||||
|
disabled={cancelLoading}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={cancelGrund.length < 5 || cancelLoading}
|
||||||
|
startIcon={
|
||||||
|
cancelLoading ? <CircularProgress size={16} /> : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Stornieren
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* ── iCal dialog ── */}
|
||||||
|
<Dialog
|
||||||
|
open={icalOpen}
|
||||||
|
onClose={() => setIcalOpen(false)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>Kalender abonnieren</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Abonniere den Fahrzeugbuchungskalender in deiner
|
||||||
|
Kalenderanwendung. Kopiere die URL und füge sie als neuen
|
||||||
|
Kalender (per URL) in Apple Kalender, Google Kalender oder
|
||||||
|
Outlook ein.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
value={icalUrl}
|
||||||
|
InputProps={{
|
||||||
|
readOnly: true,
|
||||||
|
endAdornment: (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(icalUrl);
|
||||||
|
notification.showSuccess('URL kopiert!');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentCopy />
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setIcalOpen(false)}>Schließen</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FahrzeugBuchungen;
|
||||||
439
frontend/src/pages/VeranstaltungKategorien.tsx
Normal file
439
frontend/src/pages/VeranstaltungKategorien.tsx
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
Tooltip,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Category as CategoryIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
|
import { eventsApi } from '../services/events';
|
||||||
|
import type { VeranstaltungKategorie } from '../types/events.types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Category Form Dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface KategorieFormData {
|
||||||
|
name: string;
|
||||||
|
beschreibung: string;
|
||||||
|
farbe: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: KategorieFormData = {
|
||||||
|
name: '',
|
||||||
|
beschreibung: '',
|
||||||
|
farbe: '#1976d2',
|
||||||
|
icon: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface KategorieDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
editing: VeranstaltungKategorie | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogProps) {
|
||||||
|
const notification = useNotification();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form, setForm] = useState<KategorieFormData>({ ...EMPTY_FORM });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (editing) {
|
||||||
|
setForm({
|
||||||
|
name: editing.name,
|
||||||
|
beschreibung: editing.beschreibung ?? '',
|
||||||
|
farbe: editing.farbe,
|
||||||
|
icon: editing.icon ?? '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({ ...EMPTY_FORM });
|
||||||
|
}
|
||||||
|
}, [open, editing]);
|
||||||
|
|
||||||
|
const handleChange = (field: keyof KategorieFormData, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
notification.showError('Name ist erforderlich');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
beschreibung: form.beschreibung.trim() || undefined,
|
||||||
|
farbe: form.farbe,
|
||||||
|
icon: form.icon.trim() || undefined,
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
await eventsApi.updateKategorie(editing.id, payload);
|
||||||
|
notification.showSuccess('Kategorie aktualisiert');
|
||||||
|
} else {
|
||||||
|
await eventsApi.createKategorie(payload);
|
||||||
|
notification.showSuccess('Kategorie erstellt');
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern';
|
||||||
|
notification.showError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? 'Kategorie bearbeiten' : 'Neue Kategorie'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Beschreibung"
|
||||||
|
value={form.beschreibung}
|
||||||
|
onChange={(e) => handleChange('beschreibung', e.target.value)}
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
{/* Color picker */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Farbe"
|
||||||
|
type="color"
|
||||||
|
value={form.farbe}
|
||||||
|
onChange={(e) => handleChange('farbe', e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{ width: 120 }}
|
||||||
|
inputProps={{ style: { height: 40, cursor: 'pointer', padding: '4px 8px' } }}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: form.farbe,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
|
||||||
|
{form.farbe.toUpperCase()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label="Icon (MUI Icon Name, optional)"
|
||||||
|
value={form.icon}
|
||||||
|
onChange={(e) => handleChange('icon', e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
placeholder="z.B. EmojiEvents"
|
||||||
|
helperText="Name eines MUI Material Icons"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" onClick={handleSave} disabled={loading}>
|
||||||
|
{loading ? <CircularProgress size={20} /> : editing ? 'Speichern' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete Confirm Dialog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface DeleteDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
kategorie: VeranstaltungKategorie | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteDialog({ open, kategorie, onClose, onDeleted }: DeleteDialogProps) {
|
||||||
|
const notification = useNotification();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!kategorie) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await eventsApi.deleteKategorie(kategorie.id);
|
||||||
|
notification.showSuccess('Kategorie gelöscht');
|
||||||
|
onDeleted();
|
||||||
|
onClose();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Fehler beim Löschen';
|
||||||
|
notification.showError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Kategorie löschen</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Soll die Kategorie <strong>{kategorie?.name}</strong> wirklich gelöscht werden?
|
||||||
|
Bestehende Veranstaltungen behalten ihre Farbzuweisung.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Abbrechen</Button>
|
||||||
|
<Button variant="contained" color="error" onClick={handleDelete} disabled={loading}>
|
||||||
|
{loading ? <CircularProgress size={20} /> : 'Löschen'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function VeranstaltungKategorien() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const canManage =
|
||||||
|
user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false;
|
||||||
|
|
||||||
|
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form dialog
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editingKat, setEditingKat] = useState<VeranstaltungKategorie | null>(null);
|
||||||
|
|
||||||
|
// Delete dialog
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<VeranstaltungKategorie | null>(null);
|
||||||
|
|
||||||
|
const loadKategorien = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await eventsApi.getKategorien();
|
||||||
|
setKategorien(data);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Kategorien';
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadKategorien();
|
||||||
|
}, [loadKategorien]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Container maxWidth="lg" sx={{ py: 3 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 1,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CategoryIcon color="primary" />
|
||||||
|
<Typography variant="h5" sx={{ flexGrow: 1, fontWeight: 700 }}>
|
||||||
|
Veranstaltungskategorien
|
||||||
|
</Typography>
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
startIcon={<Add />}
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => { setEditingKat(null); setFormOpen(true); }}
|
||||||
|
>
|
||||||
|
Neue Kategorie
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{!loading && !error && (
|
||||||
|
<TableContainer component={Paper} elevation={1}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Farbe</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Name</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Beschreibung</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Icon</TableCell>
|
||||||
|
{canManage && <TableCell align="right" sx={{ fontWeight: 700 }}>Aktionen</TableCell>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{kategorien.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={canManage ? 5 : 4} align="center" sx={{ py: 4 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Noch keine Kategorien vorhanden.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
kategorien.map((kat) => (
|
||||||
|
<TableRow
|
||||||
|
key={kat.id}
|
||||||
|
hover
|
||||||
|
sx={{ '&:last-child td': { border: 0 } }}
|
||||||
|
>
|
||||||
|
{/* Color swatch */}
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title={kat.farbe.toUpperCase()}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: kat.farbe,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={kat.name}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: kat.farbe,
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{kat.beschreibung ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
{kat.icon ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{canManage && (
|
||||||
|
<TableCell align="right">
|
||||||
|
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||||
|
<Tooltip title="Bearbeiten">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setEditingKat(kat); setFormOpen(true); }}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Löschen">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setDeleteTarget(kat)}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<KategorieDialog
|
||||||
|
open={formOpen}
|
||||||
|
onClose={() => { setFormOpen(false); setEditingKat(null); }}
|
||||||
|
onSaved={loadKategorien}
|
||||||
|
editing={editingKat}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirm Dialog */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
kategorie={deleteTarget}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onDeleted={loadKategorien}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
1325
frontend/src/pages/Veranstaltungen.tsx
Normal file
1325
frontend/src/pages/Veranstaltungen.tsx
Normal file
File diff suppressed because it is too large
Load Diff
117
frontend/src/services/bookings.ts
Normal file
117
frontend/src/services/bookings.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type {
|
||||||
|
FahrzeugBuchungListItem,
|
||||||
|
FahrzeugBuchung,
|
||||||
|
Fahrzeug,
|
||||||
|
CreateBuchungInput,
|
||||||
|
} from '../types/booking.types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response shapes from the backend
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Booking API service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const bookingApi = {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Calendar / listing
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getCalendarRange(from: Date, to: Date, fahrzeugId?: string): Promise<FahrzeugBuchungListItem[]> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<FahrzeugBuchungListItem[]>>('/api/bookings/calendar', {
|
||||||
|
params: {
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString(),
|
||||||
|
...(fahrzeugId ? { fahrzeugId } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getUpcoming(limit = 20): Promise<FahrzeugBuchungListItem[]> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<FahrzeugBuchungListItem[]>>('/api/bookings/upcoming', {
|
||||||
|
params: { limit },
|
||||||
|
})
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Availability check
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
checkAvailability(
|
||||||
|
fahrzeugId: string,
|
||||||
|
from: Date,
|
||||||
|
to: Date
|
||||||
|
): Promise<{ available: boolean }> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<{ available: boolean }>>('/api/bookings/availability', {
|
||||||
|
params: {
|
||||||
|
fahrzeugId,
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Single booking
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getById(id: string): Promise<FahrzeugBuchung> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<FahrzeugBuchung>>(`/api/bookings/${id}`)
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create(data: CreateBuchungInput): Promise<FahrzeugBuchung> {
|
||||||
|
return api
|
||||||
|
.post<ApiResponse<FahrzeugBuchung>>('/api/bookings', data)
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
update(id: string, data: Partial<CreateBuchungInput>): Promise<FahrzeugBuchung> {
|
||||||
|
return api
|
||||||
|
.patch<ApiResponse<FahrzeugBuchung>>(`/api/bookings/${id}`, data)
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel(id: string, abgesagt_grund: string): Promise<void> {
|
||||||
|
return api
|
||||||
|
.delete(`/api/bookings/${id}`, { data: { abgesagt_grund } })
|
||||||
|
.then(() => undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// iCal
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getCalendarToken(): Promise<{ token: string; subscribeUrl: string }> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<{ token: string; subscribeUrl: string }>>(
|
||||||
|
'/api/bookings/calendar-token'
|
||||||
|
)
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Vehicle helper (shared with booking page)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function fetchVehicles(): Promise<Fahrzeug[]> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<Fahrzeug[]>>('/api/vehicles')
|
||||||
|
.then((r) => r.data.data.filter((v: Fahrzeug) => !v.archived_at));
|
||||||
|
}
|
||||||
143
frontend/src/services/events.ts
Normal file
143
frontend/src/services/events.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type {
|
||||||
|
VeranstaltungKategorie,
|
||||||
|
VeranstaltungListItem,
|
||||||
|
Veranstaltung,
|
||||||
|
GroupInfo,
|
||||||
|
CreateVeranstaltungInput,
|
||||||
|
} from '../types/events.types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response shapes from the backend
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Events API service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const eventsApi = {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Kategorien (Categories)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** List all event categories */
|
||||||
|
getKategorien(): Promise<VeranstaltungKategorie[]> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<VeranstaltungKategorie[]>>('/api/events/kategorien')
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a new event category */
|
||||||
|
createKategorie(data: {
|
||||||
|
name: string;
|
||||||
|
beschreibung?: string;
|
||||||
|
farbe?: string;
|
||||||
|
icon?: string;
|
||||||
|
}): Promise<VeranstaltungKategorie> {
|
||||||
|
return api
|
||||||
|
.post<ApiResponse<VeranstaltungKategorie>>('/api/events/kategorien', data)
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update an existing event category */
|
||||||
|
updateKategorie(
|
||||||
|
id: string,
|
||||||
|
data: Partial<{ name: string; beschreibung?: string; farbe?: string; icon?: string }>
|
||||||
|
): Promise<VeranstaltungKategorie> {
|
||||||
|
return api
|
||||||
|
.patch<ApiResponse<VeranstaltungKategorie>>(`/api/events/kategorien/${id}`, data)
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Delete an event category */
|
||||||
|
deleteKategorie(id: string): Promise<void> {
|
||||||
|
return api
|
||||||
|
.delete(`/api/events/kategorien/${id}`)
|
||||||
|
.then(() => undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Groups
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** List all available target groups */
|
||||||
|
getGroups(): Promise<GroupInfo[]> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<GroupInfo[]>>('/api/events/groups')
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Event listing
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Events in a date range for the month calendar view */
|
||||||
|
getCalendarRange(from: Date, to: Date): Promise<VeranstaltungListItem[]> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<VeranstaltungListItem[]>>('/api/events/calendar', {
|
||||||
|
params: {
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Upcoming events (dashboard widget, list view) */
|
||||||
|
getUpcoming(limit = 10): Promise<VeranstaltungListItem[]> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<VeranstaltungListItem[]>>('/api/events/upcoming', {
|
||||||
|
params: { limit },
|
||||||
|
})
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Full event detail */
|
||||||
|
getById(id: string): Promise<Veranstaltung> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<Veranstaltung>>(`/api/events/${id}`)
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Create a new event */
|
||||||
|
createEvent(data: CreateVeranstaltungInput): Promise<Veranstaltung> {
|
||||||
|
return api
|
||||||
|
.post<ApiResponse<Veranstaltung>>('/api/events', data)
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update an existing event */
|
||||||
|
updateEvent(id: string, data: Partial<CreateVeranstaltungInput>): Promise<Veranstaltung> {
|
||||||
|
return api
|
||||||
|
.patch<ApiResponse<Veranstaltung>>(`/api/events/${id}`, data)
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Cancel (soft-delete) an event with a reason */
|
||||||
|
cancelEvent(id: string, abgesagt_grund: string): Promise<void> {
|
||||||
|
return api
|
||||||
|
.delete(`/api/events/${id}`, { data: { abgesagt_grund } })
|
||||||
|
.then(() => undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// iCal
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Get the user's personal calendar subscribe URL */
|
||||||
|
getCalendarToken(): Promise<{ token: string; subscribeUrl: string }> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<{ token: string; subscribeUrl: string }>>(
|
||||||
|
'/api/events/calendar-token'
|
||||||
|
)
|
||||||
|
.then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
60
frontend/src/types/booking.types.ts
Normal file
60
frontend/src/types/booking.types.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
export type BuchungsArt = 'intern' | 'extern' | 'wartung' | 'reservierung' | 'sonstiges';
|
||||||
|
|
||||||
|
export const BUCHUNGS_ART_LABELS: Record<BuchungsArt, string> = {
|
||||||
|
intern: 'Intern',
|
||||||
|
extern: 'Extern',
|
||||||
|
wartung: 'Wartung/Service',
|
||||||
|
reservierung: 'Reservierung',
|
||||||
|
sonstiges: 'Sonstiges',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BUCHUNGS_ART_COLORS: Record<BuchungsArt, string> = {
|
||||||
|
intern: '#1976d2',
|
||||||
|
extern: '#e65100',
|
||||||
|
wartung: '#616161',
|
||||||
|
reservierung: '#7b1fa2',
|
||||||
|
sonstiges: '#00695c',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FahrzeugBuchungListItem {
|
||||||
|
id: string;
|
||||||
|
fahrzeug_id: string;
|
||||||
|
fahrzeug_name: string;
|
||||||
|
fahrzeug_kennzeichen?: string | null;
|
||||||
|
titel: string;
|
||||||
|
buchungs_art: BuchungsArt;
|
||||||
|
beginn: string; // ISO
|
||||||
|
ende: string; // ISO
|
||||||
|
abgesagt: boolean;
|
||||||
|
gebucht_von_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FahrzeugBuchung extends FahrzeugBuchungListItem {
|
||||||
|
beschreibung?: string | null;
|
||||||
|
kontakt_person?: string | null;
|
||||||
|
kontakt_telefon?: string | null;
|
||||||
|
gebucht_von: string;
|
||||||
|
abgesagt_grund?: string | null;
|
||||||
|
erstellt_am: string;
|
||||||
|
aktualisiert_am: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Fahrzeug {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kennzeichen?: string | null;
|
||||||
|
typ: string;
|
||||||
|
is_active: boolean;
|
||||||
|
archived_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBuchungInput {
|
||||||
|
fahrzeugId: string;
|
||||||
|
titel: string;
|
||||||
|
beschreibung?: string | null;
|
||||||
|
beginn: string; // ISO
|
||||||
|
ende: string; // ISO
|
||||||
|
buchungsArt: BuchungsArt;
|
||||||
|
kontaktPerson?: string | null;
|
||||||
|
kontaktTelefon?: string | null;
|
||||||
|
}
|
||||||
64
frontend/src/types/events.types.ts
Normal file
64
frontend/src/types/events.types.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Frontend events types — mirrors backend events model
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface VeranstaltungKategorie {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
beschreibung?: string | null;
|
||||||
|
farbe: string; // hex color e.g. '#1976d2'
|
||||||
|
icon?: string | null; // MUI icon name
|
||||||
|
erstellt_am: string;
|
||||||
|
aktualisiert_am: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VeranstaltungListItem {
|
||||||
|
id: string;
|
||||||
|
titel: string;
|
||||||
|
beschreibung?: string | null;
|
||||||
|
ort?: string | null;
|
||||||
|
kategorie_id?: string | null;
|
||||||
|
kategorie_name?: string | null;
|
||||||
|
kategorie_farbe?: string | null;
|
||||||
|
kategorie_icon?: string | null;
|
||||||
|
datum_von: string; // ISO string
|
||||||
|
datum_bis: string; // ISO string
|
||||||
|
ganztaegig: boolean;
|
||||||
|
zielgruppen: string[];
|
||||||
|
alle_gruppen: boolean;
|
||||||
|
abgesagt: boolean;
|
||||||
|
anmeldung_erforderlich: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Veranstaltung extends VeranstaltungListItem {
|
||||||
|
ort_url?: string | null;
|
||||||
|
max_teilnehmer?: number | null;
|
||||||
|
anmeldung_bis?: string | null;
|
||||||
|
erstellt_von: string;
|
||||||
|
erstellt_von_name?: string | null;
|
||||||
|
abgesagt_grund?: string | null;
|
||||||
|
abgesagt_am?: string | null;
|
||||||
|
erstellt_am: string;
|
||||||
|
aktualisiert_am: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupInfo {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateVeranstaltungInput {
|
||||||
|
titel: string;
|
||||||
|
beschreibung?: string | null;
|
||||||
|
ort?: string | null;
|
||||||
|
ort_url?: string | null;
|
||||||
|
kategorie_id?: string | null;
|
||||||
|
datum_von: string; // ISO
|
||||||
|
datum_bis: string; // ISO
|
||||||
|
ganztaegig: boolean;
|
||||||
|
zielgruppen: string[];
|
||||||
|
alle_gruppen: boolean;
|
||||||
|
max_teilnehmer?: number | null;
|
||||||
|
anmeldung_erforderlich: boolean;
|
||||||
|
anmeldung_bis?: string | null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user