diff --git a/backend/src/controllers/booking.controller.ts b/backend/src/controllers/booking.controller.ts new file mode 100644 index 0000000..3cbdef2 --- /dev/null +++ b/backend/src/controllers/booking.controller.ts @@ -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 { + 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 { + 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 { + 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 { + try { + const { id } = req.params as Record; + 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 { + 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 { + try { + const { id } = req.params as Record; + 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 { + try { + const { id } = req.params as Record; + 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 { + try { + const { id } = req.params as Record; + 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 { + 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 { + 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(); diff --git a/backend/src/controllers/events.controller.ts b/backend/src/controllers/events.controller.ts new file mode 100644 index 0000000..981a37d --- /dev/null +++ b/backend/src/controllers/events.controller.ts @@ -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 => { + 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 => { + 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 => { + try { + const { id } = req.params as Record; + 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 => { + try { + const { id } = req.params as Record; + 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 => { + res.json({ success: true, data: KNOWN_GROUPS }); + }; + + // ------------------------------------------------------------------------- + // GET /api/events/calendar?from=&to= + // ------------------------------------------------------------------------- + getCalendarRange = async (req: Request, res: Response): Promise => { + 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 => { + 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 => { + try { + const { id } = req.params as Record; + 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 => { + 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 => { + try { + const { id } = req.params as Record; + 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 => { + try { + const { id } = req.params as Record; + 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 => { + 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= + // ------------------------------------------------------------------------- + getIcalExport = async (req: Request, res: Response): Promise => { + 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(); diff --git a/backend/src/database/migrations/015_create_veranstaltungen.sql b/backend/src/database/migrations/015_create_veranstaltungen.sql new file mode 100644 index 0000000..a78881f --- /dev/null +++ b/backend/src/database/migrations/015_create_veranstaltungen.sql @@ -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; diff --git a/backend/src/database/migrations/016_create_fahrzeugbuchungen.sql b/backend/src/database/migrations/016_create_fahrzeugbuchungen.sql new file mode 100644 index 0000000..ebe23b2 --- /dev/null +++ b/backend/src/database/migrations/016_create_fahrzeugbuchungen.sql @@ -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); diff --git a/backend/src/models/booking.model.ts b/backend/src/models/booking.model.ts new file mode 100644 index 0000000..296f6ce --- /dev/null +++ b/backend/src/models/booking.model.ts @@ -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; + +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; + +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; diff --git a/backend/src/models/events.model.ts b/backend/src/models/events.model.ts new file mode 100644 index 0000000..470b487 --- /dev/null +++ b/backend/src/models/events.model.ts @@ -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; + +export const UpdateKategorieSchema = CreateKategorieSchema.partial(); + +export type UpdateKategorieData = z.infer; + +// --------------------------------------------------------------------------- +// 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; + +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; + +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; diff --git a/backend/src/routes/booking.routes.ts b/backend/src/routes/booking.routes.ts new file mode 100644 index 0000000..7dbb409 --- /dev/null +++ b/backend/src/routes/booking.routes.ts @@ -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; diff --git a/backend/src/routes/events.routes.ts b/backend/src/routes/events.routes.ts new file mode 100644 index 0000000..50a535c --- /dev/null +++ b/backend/src/routes/events.routes.ts @@ -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=&to= + * 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= + * 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; diff --git a/backend/src/services/booking.service.ts b/backend/src/services/booking.service.ts new file mode 100644 index 0000000..04a087c --- /dev/null +++ b/backend/src/services/booking.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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(); diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts new file mode 100644 index 0000000..2addce2 --- /dev/null +++ b/backend/src/services/events.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const fields: string[] = []; + const values: any[] = []; + let idx = 1; + + const fieldMap: Record = { + 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 { + 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 { + // 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(); diff --git a/frontend/src/pages/FahrzeugBuchungen.tsx b/frontend/src/pages/FahrzeugBuchungen.tsx new file mode 100644 index 0000000..8dbd364 --- /dev/null +++ b/frontend/src/pages/FahrzeugBuchungen.tsx @@ -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(() => + 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([]); + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(null); + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [dialogLoading, setDialogLoading] = useState(false); + const [dialogError, setDialogError] = useState(null); + const [availability, setAvailability] = useState(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(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(null); + const [detailBooking, setDetailBooking] = + useState(null); + + const handleBookingClick = ( + e: React.MouseEvent, + 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 ( + + + {/* ── Header ── */} + + + Fahrzeugbuchungen + + + + {canWrite && ( + + )} + + + + {/* ── Week navigation ── */} + + setCurrentWeekStart((d) => subWeeks(d, 1))}> + + + + {weekLabel} + + setCurrentWeekStart((d) => addWeeks(d, 1))}> + + + + + + {/* ── Loading / error ── */} + {loading && ( + + + + )} + {!loading && error && ( + + {error} + + )} + + {/* ── Timeline table ── */} + {!loading && !error && ( + + + + + + Fahrzeug + + {weekDays.map((day) => ( + + + {format(day, 'EEE', { locale: de })} + + + {format(day, 'dd.MM.')} + + + ))} + + + + {vehicles.map((vehicle) => ( + + + + {vehicle.name} + + {vehicle.kennzeichen && ( + + {vehicle.kennzeichen} + + )} + + {weekDays.map((day) => { + const cellBookings = getBookingsForCell(vehicle.id, day); + const isFree = cellBookings.length === 0; + return ( + + 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) => ( + + 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', + }} + /> + + ))} + + ); + })} + + ))} + {vehicles.length === 0 && ( + + + + Keine aktiven Fahrzeuge + + + + )} + +
+
+ )} + + {/* ── Legend ── */} + {!loading && !error && ( + + + + Frei + + {(Object.entries(BUCHUNGS_ART_LABELS) as [BuchungsArt, string][]).map( + ([art, label]) => ( + + + {label} + + ) + )} + + )} + + {/* ── FAB ── */} + {canWrite && ( + + + + )} + + {/* ── Booking detail popover ── */} + { + setDetailAnchor(null); + setDetailBooking(null); + }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + transformOrigin={{ vertical: 'top', horizontal: 'center' }} + > + {detailBooking && ( + + + {detailBooking.titel} + + + + {format(parseISO(detailBooking.beginn), 'dd.MM.yyyy HH:mm')} + {' – '} + {format(parseISO(detailBooking.ende), 'dd.MM.yyyy HH:mm')} + + {detailBooking.gebucht_von_name && ( + + Von: {detailBooking.gebucht_von_name} + + )} + {canWrite && ( + + + + + )} + + )} + + + {/* ── Create / Edit dialog ── */} + setDialogOpen(false)} + maxWidth="sm" + fullWidth + > + + {editingBooking ? 'Buchung bearbeiten' : 'Neue Buchung'} + + + {dialogError && ( + + {dialogError} + + )} + + + Fahrzeug + + + + + setForm((f) => ({ ...f, titel: e.target.value })) + } + /> + + + setForm((f) => ({ ...f, beschreibung: e.target.value })) + } + /> + + + setForm((f) => ({ ...f, beginn: e.target.value })) + } + InputLabelProps={{ shrink: true }} + /> + + + setForm((f) => ({ ...f, ende: e.target.value })) + } + InputLabelProps={{ shrink: true }} + /> + + {availability !== null && ( + : } + label={ + availability + ? 'Fahrzeug verfügbar' + : 'Konflikt: bereits gebucht' + } + color={availability ? 'success' : 'error'} + size="small" + sx={{ alignSelf: 'flex-start' }} + /> + )} + + + Buchungsart + + + + {form.buchungsArt === 'extern' && ( + <> + + setForm((f) => ({ ...f, kontaktPerson: e.target.value })) + } + /> + + setForm((f) => ({ + ...f, + kontaktTelefon: e.target.value, + })) + } + /> + + )} + + + + + + + + + {/* ── Cancel dialog ── */} + setCancelId(null)} + maxWidth="xs" + fullWidth + > + Buchung stornieren + + setCancelGrund(e.target.value)} + sx={{ mt: 1 }} + helperText={`${cancelGrund.length}/1000 (min. 5 Zeichen)`} + inputProps={{ maxLength: 1000 }} + /> + + + + + + + + {/* ── iCal dialog ── */} + setIcalOpen(false)} + maxWidth="sm" + fullWidth + > + Kalender abonnieren + + + 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. + + { + navigator.clipboard.writeText(icalUrl); + notification.showSuccess('URL kopiert!'); + }} + > + + + ), + }} + /> + + + + + +
+
+ ); +} + +export default FahrzeugBuchungen; diff --git a/frontend/src/pages/VeranstaltungKategorien.tsx b/frontend/src/pages/VeranstaltungKategorien.tsx new file mode 100644 index 0000000..47f2071 --- /dev/null +++ b/frontend/src/pages/VeranstaltungKategorien.tsx @@ -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({ ...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 ( + + + {editing ? 'Kategorie bearbeiten' : 'Neue Kategorie'} + + + + handleChange('name', e.target.value)} + required + fullWidth + autoFocus + /> + handleChange('beschreibung', e.target.value)} + multiline + rows={2} + fullWidth + /> + {/* Color picker */} + + handleChange('farbe', e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ width: 120 }} + inputProps={{ style: { height: 40, cursor: 'pointer', padding: '4px 8px' } }} + /> + + + {form.farbe.toUpperCase()} + + + handleChange('icon', e.target.value)} + fullWidth + placeholder="z.B. EmojiEvents" + helperText="Name eines MUI Material Icons" + /> + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( + + Kategorie löschen + + + Soll die Kategorie {kategorie?.name} wirklich gelöscht werden? + Bestehende Veranstaltungen behalten ihre Farbzuweisung. + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Form dialog + const [formOpen, setFormOpen] = useState(false); + const [editingKat, setEditingKat] = useState(null); + + // Delete dialog + const [deleteTarget, setDeleteTarget] = useState(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 ( + + + {/* Header */} + + + + Veranstaltungskategorien + + {canManage && ( + + )} + + + {/* Error */} + {error && ( + setError(null)}> + {error} + + )} + + {/* Loading */} + {loading && ( + + + + )} + + {/* Table */} + {!loading && !error && ( + + + + + Farbe + Name + Beschreibung + Icon + {canManage && Aktionen} + + + + {kategorien.length === 0 ? ( + + + + Noch keine Kategorien vorhanden. + + + + ) : ( + kategorien.map((kat) => ( + + {/* Color swatch */} + + + + + + + {/* Name */} + + + + + {/* Description */} + + + {kat.beschreibung ?? '—'} + + + + {/* Icon */} + + + {kat.icon ?? '—'} + + + + {/* Actions */} + {canManage && ( + + + + { setEditingKat(kat); setFormOpen(true); }} + > + + + + + setDeleteTarget(kat)} + > + + + + + + )} + + )) + )} + +
+
+ )} + + {/* Create/Edit Dialog */} + { setFormOpen(false); setEditingKat(null); }} + onSaved={loadKategorien} + editing={editingKat} + /> + + {/* Delete Confirm Dialog */} + setDeleteTarget(null)} + onDeleted={loadKategorien} + /> +
+
+ ); +} diff --git a/frontend/src/pages/Veranstaltungen.tsx b/frontend/src/pages/Veranstaltungen.tsx new file mode 100644 index 0000000..108f6f3 --- /dev/null +++ b/frontend/src/pages/Veranstaltungen.tsx @@ -0,0 +1,1325 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Box, + Container, + Typography, + Button, + IconButton, + Chip, + Tooltip, + CircularProgress, + Alert, + Popover, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + TextField, + Select, + MenuItem, + FormControl, + InputLabel, + FormControlLabel, + Switch, + Checkbox, + FormGroup, + Fab, + Stack, + List, + ListItem, + ListItemText, + Divider, + Paper, + Skeleton, + ButtonGroup, + useTheme, + useMediaQuery, + Snackbar, +} from '@mui/material'; +import { + Add, + ChevronLeft, + ChevronRight, + CalendarViewMonth as CalendarIcon, + ViewList as ListViewIcon, + ContentCopy as CopyIcon, + Cancel as CancelIcon, + Edit as EditIcon, + Today as TodayIcon, + IosShare, + Event as EventIcon, +} 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 { + VeranstaltungListItem, + VeranstaltungKategorie, + GroupInfo, + CreateVeranstaltungInput, +} from '../types/events.types'; + +// --------------------------------------------------------------------------- +// Constants & helpers +// --------------------------------------------------------------------------- + +const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + +const MONTH_LABELS = [ + 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', +]; + +function startOfDay(d: Date): Date { + const c = new Date(d); + c.setHours(0, 0, 0, 0); + return c; +} + +function sameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +/** Returns calendar grid cells for the month view — always 6×7 (42 cells), starting Monday */ +function buildMonthGrid(year: number, month: number): Date[] { + const firstDay = new Date(year, month, 1); + // ISO week starts Monday; getDay() returns 0=Sun → convert to Mon=0 + const dayOfWeek = (firstDay.getDay() + 6) % 7; + const start = new Date(firstDay); + start.setDate(start.getDate() - dayOfWeek); + + const cells: Date[] = []; + for (let i = 0; i < 42; i++) { + const d = new Date(start); + d.setDate(start.getDate() + i); + cells.push(d); + } + return cells; +} + +function formatTime(isoString: string): string { + const d = new Date(isoString); + const h = String(d.getHours()).padStart(2, '0'); + const m = String(d.getMinutes()).padStart(2, '0'); + return `${h}:${m}`; +} + +function formatDateLong(d: Date): string { + const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; + return `${days[d.getDay()]}, ${d.getDate()}. ${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`; +} + +function formatDateShort(isoString: string): string { + const d = new Date(isoString); + return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; +} + +/** Convert a Date to datetime-local input value "YYYY-MM-DDTHH:MM" */ +function toDatetimeLocal(isoString: string): string { + const d = new Date(isoString); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** Convert datetime-local value back to ISO string */ +function fromDatetimeLocal(value: string): string { + return new Date(value).toISOString(); +} + +const EMPTY_FORM: CreateVeranstaltungInput = { + titel: '', + beschreibung: null, + ort: null, + ort_url: null, + kategorie_id: null, + datum_von: new Date().toISOString(), + datum_bis: new Date().toISOString(), + ganztaegig: false, + zielgruppen: [], + alle_gruppen: true, + max_teilnehmer: null, + anmeldung_erforderlich: false, + anmeldung_bis: null, +}; + +// --------------------------------------------------------------------------- +// iCal Subscribe Dialog +// --------------------------------------------------------------------------- + +interface IcalDialogProps { + open: boolean; + onClose: () => void; +} + +function IcalDialog({ open, onClose }: IcalDialogProps) { + const [snackOpen, setSnackOpen] = useState(false); + const [subscribeUrl, setSubscribeUrl] = useState(null); + const [loading, setLoading] = useState(false); + + const handleOpen = async () => { + if (subscribeUrl) return; + setLoading(true); + try { + const { subscribeUrl: url } = await eventsApi.getCalendarToken(); + setSubscribeUrl(url); + } catch (_) { + setSubscribeUrl(null); + } finally { + setLoading(false); + } + }; + + const handleCopy = async () => { + if (!subscribeUrl) return; + await navigator.clipboard.writeText(subscribeUrl); + setSnackOpen(true); + }; + + return ( + <> + + Kalender abonnieren + + + Kopiere die URL und füge sie in deiner Kalender-App unter + "Kalender abonnieren" ein. Der Kalender wird automatisch + aktualisiert, sobald neue Veranstaltungen eingetragen werden. + + + {loading && } + + {!loading && subscribeUrl && ( + + {subscribeUrl} + + + + + + + )} + + + Apple Kalender: Ablage → Neues Kalenderabonnement
+ Google Kalender: Andere Kalender → Per URL
+ Thunderbird: Neu → Kalender → Im Netzwerk +
+
+ + + {subscribeUrl && ( + + )} + +
+ + setSnackOpen(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackOpen(false)}> + URL kopiert! + + + + ); +} + +// --------------------------------------------------------------------------- +// Month Calendar Grid +// --------------------------------------------------------------------------- + +interface MonthCalendarProps { + year: number; + month: number; + events: VeranstaltungListItem[]; + selectedKategorie: string | 'all'; + onDayClick: (day: Date, anchor: Element) => void; +} + +function MonthCalendar({ + year, + month, + events, + selectedKategorie, + onDayClick, +}: MonthCalendarProps) { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const today = startOfDay(new Date()); + const cells = useMemo(() => buildMonthGrid(year, month), [year, month]); + + // Build a map: "YYYY-MM-DD" → events (including multi-day events) + const eventsByDay = useMemo(() => { + const map = new Map(); + for (const ev of events) { + if (selectedKategorie !== 'all' && ev.kategorie_id !== selectedKategorie) continue; + const start = startOfDay(new Date(ev.datum_von)); + const end = startOfDay(new Date(ev.datum_bis)); + // Add event to every day it spans + const cur = new Date(start); + while (cur <= end) { + const key = cur.toISOString().slice(0, 10); + const arr = map.get(key) ?? []; + arr.push(ev); + map.set(key, arr); + cur.setDate(cur.getDate() + 1); + } + } + return map; + }, [events, selectedKategorie]); + + return ( + + {/* Weekday headers */} + + {WEEKDAY_LABELS.map((wd) => ( + + {wd} + + ))} + + + {/* Day cells — 6 rows × 7 cols */} + + {cells.map((cell, idx) => { + const isCurrentMonth = cell.getMonth() === month; + const isTodayDate = sameDay(cell, today); + const key = cell.toISOString().slice(0, 10); + const dayEvents = eventsByDay.get(key) ?? []; + const hasEvents = dayEvents.length > 0; + const maxDots = isMobile ? 3 : 5; + + return ( + hasEvents && onDayClick(cell, e.currentTarget)} + sx={{ + minHeight: isMobile ? 44 : 72, + borderRadius: 1, + p: '4px', + cursor: hasEvents ? 'pointer' : 'default', + bgcolor: isTodayDate + ? 'primary.main' + : isCurrentMonth + ? 'background.paper' + : 'action.disabledBackground', + border: '1px solid', + borderColor: isTodayDate ? 'primary.dark' : 'divider', + transition: 'background 0.1s', + '&:hover': hasEvents + ? { bgcolor: isTodayDate ? 'primary.dark' : 'action.hover' } + : {}, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + overflow: 'hidden', + }} + > + + {cell.getDate()} + + + {/* Event dots */} + {hasEvents && ( + + {dayEvents.slice(0, maxDots).map((ev, i) => ( + + ))} + {dayEvents.length > maxDots && ( + + +{dayEvents.length - maxDots} + + )} + + )} + + {/* On desktop: show short event titles */} + {!isMobile && hasEvents && ( + + {dayEvents.slice(0, 2).map((ev, i) => ( + + {ev.titel} + + ))} + + )} + + ); + })} + + + ); +} + +// --------------------------------------------------------------------------- +// Day Popover +// --------------------------------------------------------------------------- + +interface DayPopoverProps { + anchorEl: Element | null; + day: Date | null; + events: VeranstaltungListItem[]; + onClose: () => void; + onEdit: (ev: VeranstaltungListItem) => void; + canWrite: boolean; +} + +function DayPopover({ anchorEl, day, events, onClose, onEdit, canWrite }: DayPopoverProps) { + if (!day) return null; + + return ( + + + {formatDateLong(day)} + + + {events.map((ev) => ( + + + + {ev.titel} + {ev.abgesagt && ( + + )} + + } + secondary={ + + {ev.ganztaegig ? ( + Ganztägig + ) : ( + + {formatTime(ev.datum_von)} – {formatTime(ev.datum_bis)} Uhr + + )} + {ev.ort && ( + + {ev.ort} + + )} + + } + /> + {canWrite && !ev.abgesagt && ( + { onEdit(ev); onClose(); }} + sx={{ mt: 0.25 }} + > + + + )} + + ))} + + + ); +} + +// --------------------------------------------------------------------------- +// Event Form Dialog +// --------------------------------------------------------------------------- + +interface EventFormDialogProps { + open: boolean; + onClose: () => void; + onSaved: () => void; + editingEvent: VeranstaltungListItem | null; + kategorien: VeranstaltungKategorie[]; + groups: GroupInfo[]; +} + +function EventFormDialog({ + open, + onClose, + onSaved, + editingEvent, + kategorien, + groups, +}: EventFormDialogProps) { + const notification = useNotification(); + const [loading, setLoading] = useState(false); + const [form, setForm] = useState({ ...EMPTY_FORM }); + + // Reset/populate form whenever dialog opens + useEffect(() => { + if (!open) return; + if (editingEvent) { + setForm({ + titel: editingEvent.titel, + beschreibung: editingEvent.beschreibung ?? null, + ort: editingEvent.ort ?? null, + ort_url: null, + kategorie_id: editingEvent.kategorie_id ?? null, + datum_von: editingEvent.datum_von, + datum_bis: editingEvent.datum_bis, + ganztaegig: editingEvent.ganztaegig, + zielgruppen: editingEvent.zielgruppen, + alle_gruppen: editingEvent.alle_gruppen, + max_teilnehmer: null, + anmeldung_erforderlich: editingEvent.anmeldung_erforderlich, + anmeldung_bis: null, + }); + } else { + const now = new Date(); + now.setMinutes(0, 0, 0); + const later = new Date(now); + later.setHours(later.getHours() + 2); + setForm({ + ...EMPTY_FORM, + datum_von: now.toISOString(), + datum_bis: later.toISOString(), + }); + } + }, [open, editingEvent]); + + const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => { + setForm((prev) => ({ ...prev, [field]: value })); + }; + + const handleGroupToggle = (groupId: string) => { + setForm((prev) => { + const current = prev.zielgruppen; + const updated = current.includes(groupId) + ? current.filter((g) => g !== groupId) + : [...current, groupId]; + return { ...prev, zielgruppen: updated }; + }); + }; + + const handleSave = async () => { + if (!form.titel.trim()) { + notification.showError('Titel ist erforderlich'); + return; + } + setLoading(true); + try { + if (editingEvent) { + await eventsApi.updateEvent(editingEvent.id, form); + notification.showSuccess('Veranstaltung aktualisiert'); + } else { + await eventsApi.createEvent(form); + notification.showSuccess('Veranstaltung erstellt'); + } + onSaved(); + onClose(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Fehler beim Speichern'; + notification.showError(msg); + } finally { + setLoading(false); + } + }; + + return ( + + + {editingEvent ? 'Veranstaltung bearbeiten' : 'Neue Veranstaltung'} + + + + {/* Titel */} + handleChange('titel', e.target.value)} + required + fullWidth + /> + + {/* Beschreibung */} + handleChange('beschreibung', e.target.value || null)} + multiline + rows={3} + fullWidth + /> + + {/* Kategorie */} + + Kategorie + + + + {/* Ganztägig toggle */} + handleChange('ganztaegig', e.target.checked)} + /> + } + label="Ganztägig" + /> + + {/* Datum von */} + { + const raw = e.target.value; + const iso = form.ganztaegig + ? fromDatetimeLocal(`${raw}T00:00`) + : fromDatetimeLocal(raw); + handleChange('datum_von', iso); + }} + InputLabelProps={{ shrink: true }} + fullWidth + /> + + {/* Datum bis */} + { + const raw = e.target.value; + const iso = form.ganztaegig + ? fromDatetimeLocal(`${raw}T23:59`) + : fromDatetimeLocal(raw); + handleChange('datum_bis', iso); + }} + InputLabelProps={{ shrink: true }} + fullWidth + /> + + {/* Ort */} + handleChange('ort', e.target.value || null)} + fullWidth + /> + + {/* Ort URL */} + handleChange('ort_url', e.target.value || null)} + fullWidth + /> + + + + {/* Alle Gruppen toggle */} + handleChange('alle_gruppen', e.target.checked)} + /> + } + label="Für alle Mitglieder sichtbar" + /> + + {/* Zielgruppen checkboxes */} + {!form.alle_gruppen && groups.length > 0 && ( + + + Zielgruppen + + + {groups.map((g) => ( + handleGroupToggle(g.id)} + size="small" + /> + } + label={g.label} + /> + ))} + + + )} + + + + {/* Anmeldung erforderlich */} + handleChange('anmeldung_erforderlich', e.target.checked)} + /> + } + label="Anmeldung erforderlich" + /> + + {/* Anmeldung bis */} + {form.anmeldung_erforderlich && ( + handleChange('anmeldung_bis', e.target.value ? fromDatetimeLocal(e.target.value) : null)} + InputLabelProps={{ shrink: true }} + fullWidth + /> + )} + + {/* Max Teilnehmer */} + handleChange('max_teilnehmer', e.target.value ? Number(e.target.value) : null)} + inputProps={{ min: 1 }} + fullWidth + /> + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// List View +// --------------------------------------------------------------------------- + +interface ListViewProps { + events: VeranstaltungListItem[]; + canWrite: boolean; + onEdit: (ev: VeranstaltungListItem) => void; + onCancel: (id: string) => void; +} + +function EventListView({ events, canWrite, onEdit, onCancel }: ListViewProps) { + if (events.length === 0) { + return ( + + Keine Veranstaltungen in diesem Zeitraum. + + ); + } + + return ( + + {events.map((ev, idx) => ( + + {idx > 0 && } + + {/* Date badge */} + + + {new Date(ev.datum_von).getDate()}. + {new Date(ev.datum_von).getMonth() + 1}. + + {ev.ganztaegig ? ( + + Ganztägig + + ) : ( + + {formatTime(ev.datum_von)} + + )} + + + + + {ev.titel} + + {ev.abgesagt && ( + + )} + + } + secondary={ + + {ev.kategorie_name && ( + + )} + {ev.ort && ( + + {ev.ort} + + )} + {!ev.ganztaegig && ( + + bis {formatDateShort(ev.datum_bis)} {formatTime(ev.datum_bis)} Uhr + + )} + + } + sx={{ my: 0 }} + /> + + {canWrite && !ev.abgesagt && ( + + onEdit(ev)}> + + + onCancel(ev.id)}> + + + + )} + + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// Main Page +// --------------------------------------------------------------------------- + +export default function Veranstaltungen() { + const { user } = useAuth(); + const notification = useNotification(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + const canWrite = + user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false; + + const today = new Date(); + const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() }); + const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar'); + const [selectedKategorie, setSelectedKategorie] = useState('all'); + + // Data + const [events, setEvents] = useState([]); + const [kategorien, setKategorien] = useState([]); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Popover state + const [popoverAnchor, setPopoverAnchor] = useState(null); + const [popoverDay, setPopoverDay] = useState(null); + const [popoverEvents, setPopoverEvents] = useState([]); + + // Event form dialog + const [formOpen, setFormOpen] = useState(false); + const [editingEvent, setEditingEvent] = useState(null); + + // Cancel dialog + const [cancelId, setCancelId] = useState(null); + const [cancelGrund, setCancelGrund] = useState(''); + const [cancelLoading, setCancelLoading] = useState(false); + + // iCal dialog + const [icalOpen, setIcalOpen] = useState(false); + + // --------------------------------------------------------------------------- + // Data loading + // --------------------------------------------------------------------------- + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + // Compute grid range (same as buildMonthGrid) + const firstDay = new Date(viewMonth.year, viewMonth.month, 1); + const dayOfWeek = (firstDay.getDay() + 6) % 7; + const gridStart = new Date(firstDay); + gridStart.setDate(gridStart.getDate() - dayOfWeek); + const gridEnd = new Date(gridStart); + gridEnd.setDate(gridStart.getDate() + 41); + + const [eventsData, kategorienData, groupsData] = await Promise.all([ + eventsApi.getCalendarRange(gridStart, gridEnd), + eventsApi.getKategorien(), + eventsApi.getGroups(), + ]); + setEvents(eventsData); + setKategorien(kategorienData); + setGroups(groupsData); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Veranstaltungen'; + setError(msg); + } finally { + setLoading(false); + } + }, [viewMonth]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // --------------------------------------------------------------------------- + // Navigation + // --------------------------------------------------------------------------- + + const handlePrev = () => { + setViewMonth((prev) => { + const m = prev.month === 0 ? 11 : prev.month - 1; + const y = prev.month === 0 ? prev.year - 1 : prev.year; + return { year: y, month: m }; + }); + }; + + const handleNext = () => { + setViewMonth((prev) => { + const m = prev.month === 11 ? 0 : prev.month + 1; + const y = prev.month === 11 ? prev.year + 1 : prev.year; + return { year: y, month: m }; + }); + }; + + const handleToday = () => { + setViewMonth({ year: today.getFullYear(), month: today.getMonth() }); + }; + + // --------------------------------------------------------------------------- + // Day popover + // --------------------------------------------------------------------------- + + const handleDayClick = useCallback( + (day: Date, anchor: Element) => { + const key = day.toISOString().slice(0, 10); + const dayEvs = events.filter((ev) => { + if (selectedKategorie !== 'all' && ev.kategorie_id !== selectedKategorie) return false; + const start = startOfDay(new Date(ev.datum_von)).toISOString().slice(0, 10); + const end = startOfDay(new Date(ev.datum_bis)).toISOString().slice(0, 10); + return key >= start && key <= end; + }); + setPopoverDay(day); + setPopoverAnchor(anchor); + setPopoverEvents(dayEvs); + }, + [events, selectedKategorie] + ); + + // --------------------------------------------------------------------------- + // Cancel event + // --------------------------------------------------------------------------- + + const handleCancelEvent = async () => { + if (!cancelId || cancelGrund.trim().length < 5) return; + setCancelLoading(true); + try { + await eventsApi.cancelEvent(cancelId, cancelGrund.trim()); + notification.showSuccess('Veranstaltung wurde abgesagt'); + setCancelId(null); + setCancelGrund(''); + loadData(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Fehler beim Absagen'; + notification.showError(msg); + } finally { + setCancelLoading(false); + } + }; + + // --------------------------------------------------------------------------- + // Filtered events for list view + // --------------------------------------------------------------------------- + + const filteredListEvents = useMemo(() => { + return events + .filter((ev) => { + if (selectedKategorie !== 'all' && ev.kategorie_id !== selectedKategorie) return false; + const d = new Date(ev.datum_von); + return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year; + }) + .sort((a, b) => a.datum_von.localeCompare(b.datum_von)); + }, [events, selectedKategorie, viewMonth]); + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + + return ( + + + {/* Page header */} + + + + Veranstaltungen + + + {/* View toggle */} + + + + + + + + + + + + + {/* Category filter chips */} + + setSelectedKategorie('all')} + color={selectedKategorie === 'all' ? 'primary' : 'default'} + variant={selectedKategorie === 'all' ? 'filled' : 'outlined'} + size="small" + /> + {kategorien.map((k) => ( + setSelectedKategorie(selectedKategorie === k.id ? 'all' : k.id)} + size="small" + sx={{ + bgcolor: selectedKategorie === k.id ? k.farbe : undefined, + color: selectedKategorie === k.id ? 'white' : undefined, + '&:hover': { opacity: 0.85 }, + }} + variant={selectedKategorie === k.id ? 'filled' : 'outlined'} + /> + ))} + + + {/* Month navigation */} + + + + + + {MONTH_LABELS[viewMonth.month]} {viewMonth.year} + + + + + + + + {/* Error */} + {error && ( + setError(null)}> + {error} + + )} + + {/* Calendar / List body */} + {loading ? ( + + ) : viewMode === 'calendar' ? ( + + + + ) : ( + + { setEditingEvent(ev); setFormOpen(true); }} + onCancel={(id) => { setCancelId(id); setCancelGrund(''); }} + /> + + )} + + {/* FAB for creating events */} + {canWrite && ( + { setEditingEvent(null); setFormOpen(true); }} + > + + + )} + + {/* Day Popover */} + setPopoverAnchor(null)} + onEdit={(ev) => { setEditingEvent(ev); setFormOpen(true); }} + canWrite={canWrite} + /> + + {/* Create/Edit Event Dialog */} + { setFormOpen(false); setEditingEvent(null); }} + onSaved={loadData} + editingEvent={editingEvent} + kategorien={kategorien} + groups={groups} + /> + + {/* Cancel Dialog */} + setCancelId(null)} + maxWidth="xs" + fullWidth + > + Veranstaltung absagen + + + Bitte gib einen Grund für die Absage an (mind. 5 Zeichen). + + setCancelGrund(e.target.value)} + autoFocus + /> + + + + + + + + {/* iCal Subscribe Dialog */} + setIcalOpen(false)} /> + + + ); +} diff --git a/frontend/src/services/bookings.ts b/frontend/src/services/bookings.ts new file mode 100644 index 0000000..f47c227 --- /dev/null +++ b/frontend/src/services/bookings.ts @@ -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 { + success: boolean; + data: T; +} + +// --------------------------------------------------------------------------- +// Booking API service +// --------------------------------------------------------------------------- +export const bookingApi = { + // ------------------------------------------------------------------------- + // Calendar / listing + // ------------------------------------------------------------------------- + + getCalendarRange(from: Date, to: Date, fahrzeugId?: string): Promise { + return api + .get>('/api/bookings/calendar', { + params: { + from: from.toISOString(), + to: to.toISOString(), + ...(fahrzeugId ? { fahrzeugId } : {}), + }, + }) + .then((r) => r.data.data); + }, + + getUpcoming(limit = 20): Promise { + return api + .get>('/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>('/api/bookings/availability', { + params: { + fahrzeugId, + from: from.toISOString(), + to: to.toISOString(), + }, + }) + .then((r) => r.data.data); + }, + + // ------------------------------------------------------------------------- + // Single booking + // ------------------------------------------------------------------------- + + getById(id: string): Promise { + return api + .get>(`/api/bookings/${id}`) + .then((r) => r.data.data); + }, + + // ------------------------------------------------------------------------- + // CRUD + // ------------------------------------------------------------------------- + + create(data: CreateBuchungInput): Promise { + return api + .post>('/api/bookings', data) + .then((r) => r.data.data); + }, + + update(id: string, data: Partial): Promise { + return api + .patch>(`/api/bookings/${id}`, data) + .then((r) => r.data.data); + }, + + cancel(id: string, abgesagt_grund: string): Promise { + return api + .delete(`/api/bookings/${id}`, { data: { abgesagt_grund } }) + .then(() => undefined); + }, + + // ------------------------------------------------------------------------- + // iCal + // ------------------------------------------------------------------------- + + getCalendarToken(): Promise<{ token: string; subscribeUrl: string }> { + return api + .get>( + '/api/bookings/calendar-token' + ) + .then((r) => r.data.data); + }, +}; + +// --------------------------------------------------------------------------- +// Vehicle helper (shared with booking page) +// --------------------------------------------------------------------------- +export function fetchVehicles(): Promise { + return api + .get>('/api/vehicles') + .then((r) => r.data.data.filter((v: Fahrzeug) => !v.archived_at)); +} diff --git a/frontend/src/services/events.ts b/frontend/src/services/events.ts new file mode 100644 index 0000000..f65d798 --- /dev/null +++ b/frontend/src/services/events.ts @@ -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 { + success: boolean; + data: T; + message?: string; +} + +// --------------------------------------------------------------------------- +// Events API service +// --------------------------------------------------------------------------- +export const eventsApi = { + // ------------------------------------------------------------------------- + // Kategorien (Categories) + // ------------------------------------------------------------------------- + + /** List all event categories */ + getKategorien(): Promise { + return api + .get>('/api/events/kategorien') + .then((r) => r.data.data); + }, + + /** Create a new event category */ + createKategorie(data: { + name: string; + beschreibung?: string; + farbe?: string; + icon?: string; + }): Promise { + return api + .post>('/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 { + return api + .patch>(`/api/events/kategorien/${id}`, data) + .then((r) => r.data.data); + }, + + /** Delete an event category */ + deleteKategorie(id: string): Promise { + return api + .delete(`/api/events/kategorien/${id}`) + .then(() => undefined); + }, + + // ------------------------------------------------------------------------- + // Groups + // ------------------------------------------------------------------------- + + /** List all available target groups */ + getGroups(): Promise { + return api + .get>('/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 { + return api + .get>('/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 { + return api + .get>('/api/events/upcoming', { + params: { limit }, + }) + .then((r) => r.data.data); + }, + + /** Full event detail */ + getById(id: string): Promise { + return api + .get>(`/api/events/${id}`) + .then((r) => r.data.data); + }, + + // ------------------------------------------------------------------------- + // CRUD + // ------------------------------------------------------------------------- + + /** Create a new event */ + createEvent(data: CreateVeranstaltungInput): Promise { + return api + .post>('/api/events', data) + .then((r) => r.data.data); + }, + + /** Update an existing event */ + updateEvent(id: string, data: Partial): Promise { + return api + .patch>(`/api/events/${id}`, data) + .then((r) => r.data.data); + }, + + /** Cancel (soft-delete) an event with a reason */ + cancelEvent(id: string, abgesagt_grund: string): Promise { + 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>( + '/api/events/calendar-token' + ) + .then((r) => r.data.data); + }, +}; diff --git a/frontend/src/types/booking.types.ts b/frontend/src/types/booking.types.ts new file mode 100644 index 0000000..f399499 --- /dev/null +++ b/frontend/src/types/booking.types.ts @@ -0,0 +1,60 @@ +export type BuchungsArt = 'intern' | 'extern' | 'wartung' | 'reservierung' | 'sonstiges'; + +export const BUCHUNGS_ART_LABELS: Record = { + intern: 'Intern', + extern: 'Extern', + wartung: 'Wartung/Service', + reservierung: 'Reservierung', + sonstiges: 'Sonstiges', +}; + +export const BUCHUNGS_ART_COLORS: Record = { + 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; +} diff --git a/frontend/src/types/events.types.ts b/frontend/src/types/events.types.ts new file mode 100644 index 0000000..8f1df1a --- /dev/null +++ b/frontend/src/types/events.types.ts @@ -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; +}