From 620bacc6b5ed2a3c9bf89ae5290a273ec9b0f00c Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 27 Feb 2026 19:50:14 +0100 Subject: [PATCH] add features --- backend/package.json | 1 + .../src/controllers/incident.controller.ts | 302 ++++++ backend/src/controllers/member.controller.ts | 234 +++++ .../src/controllers/training.controller.ts | 346 +++++++ backend/src/controllers/vehicle.controller.ts | 344 +++++++ .../migrations/002_create_audit_log.sql | 146 +++ .../003_create_mitglieder_profile.sql | 138 +++ .../migrations/004_create_einsaetze.sql | 270 ++++++ .../migrations/005_create_fahrzeuge.sql | 331 +++++++ .../migrations/006_create_uebungen.sql | 201 ++++ .../database/seeds/einsaetze_test_data.sql | 197 ++++ backend/src/jobs/audit-cleanup.job.ts | 114 +++ backend/src/middleware/audit.middleware.ts | 235 +++++ backend/src/middleware/rbac.middleware.ts | 136 +++ backend/src/models/incident.model.ts | 261 +++++ backend/src/models/member.model.ts | 230 +++++ backend/src/models/training.model.ts | 197 ++++ backend/src/models/vehicle.model.ts | 269 ++++++ backend/src/routes/admin.routes.ts | 169 ++++ backend/src/routes/incident.routes.ts | 139 +++ backend/src/routes/member.routes.ts | 139 +++ backend/src/routes/training.routes.ts | 149 +++ backend/src/routes/vehicle.routes.ts | 147 +++ backend/src/server.ts | 5 +- backend/src/services/audit.service.ts | 394 ++++++++ backend/src/services/incident.service.ts | 699 ++++++++++++++ backend/src/services/member.service.ts | 594 ++++++++++++ backend/src/services/training.service.ts | 614 ++++++++++++ backend/src/services/vehicle.service.ts | 572 +++++++++++ .../incidents/CreateEinsatzDialog.tsx | 266 ++++++ .../incidents/IncidentStatsChart.tsx | 258 +++++ .../components/training/UpcomingEvents.tsx | 247 +++++ .../components/vehicles/InspectionAlerts.tsx | 159 ++++ frontend/src/pages/EinsatzDetail.tsx | 643 +++++++++++++ frontend/src/pages/FahrzeugDetail.tsx | 898 ++++++++++++++++++ frontend/src/pages/Kalender.tsx | 804 ++++++++++++++++ frontend/src/pages/MitgliedDetail.tsx | 792 +++++++++++++++ frontend/src/pages/UebungDetail.tsx | 551 +++++++++++ frontend/src/pages/admin/AuditLog.tsx | 733 ++++++++++++++ frontend/src/services/incidents.ts | 299 ++++++ frontend/src/services/members.ts | 113 +++ frontend/src/services/training.ts | 131 +++ frontend/src/services/vehicles.ts | 115 +++ frontend/src/types/member.types.ts | 194 ++++ frontend/src/types/training.types.ts | 115 +++ frontend/src/types/vehicle.types.ts | 205 ++++ 46 files changed, 14095 insertions(+), 1 deletion(-) create mode 100644 backend/src/controllers/incident.controller.ts create mode 100644 backend/src/controllers/member.controller.ts create mode 100644 backend/src/controllers/training.controller.ts create mode 100644 backend/src/controllers/vehicle.controller.ts create mode 100644 backend/src/database/migrations/002_create_audit_log.sql create mode 100644 backend/src/database/migrations/003_create_mitglieder_profile.sql create mode 100644 backend/src/database/migrations/004_create_einsaetze.sql create mode 100644 backend/src/database/migrations/005_create_fahrzeuge.sql create mode 100644 backend/src/database/migrations/006_create_uebungen.sql create mode 100644 backend/src/database/seeds/einsaetze_test_data.sql create mode 100644 backend/src/jobs/audit-cleanup.job.ts create mode 100644 backend/src/middleware/audit.middleware.ts create mode 100644 backend/src/middleware/rbac.middleware.ts create mode 100644 backend/src/models/incident.model.ts create mode 100644 backend/src/models/member.model.ts create mode 100644 backend/src/models/training.model.ts create mode 100644 backend/src/models/vehicle.model.ts create mode 100644 backend/src/routes/admin.routes.ts create mode 100644 backend/src/routes/incident.routes.ts create mode 100644 backend/src/routes/member.routes.ts create mode 100644 backend/src/routes/training.routes.ts create mode 100644 backend/src/routes/vehicle.routes.ts create mode 100644 backend/src/services/audit.service.ts create mode 100644 backend/src/services/incident.service.ts create mode 100644 backend/src/services/member.service.ts create mode 100644 backend/src/services/training.service.ts create mode 100644 backend/src/services/vehicle.service.ts create mode 100644 frontend/src/components/incidents/CreateEinsatzDialog.tsx create mode 100644 frontend/src/components/incidents/IncidentStatsChart.tsx create mode 100644 frontend/src/components/training/UpcomingEvents.tsx create mode 100644 frontend/src/components/vehicles/InspectionAlerts.tsx create mode 100644 frontend/src/pages/EinsatzDetail.tsx create mode 100644 frontend/src/pages/FahrzeugDetail.tsx create mode 100644 frontend/src/pages/Kalender.tsx create mode 100644 frontend/src/pages/MitgliedDetail.tsx create mode 100644 frontend/src/pages/UebungDetail.tsx create mode 100644 frontend/src/pages/admin/AuditLog.tsx create mode 100644 frontend/src/services/incidents.ts create mode 100644 frontend/src/services/members.ts create mode 100644 frontend/src/services/training.ts create mode 100644 frontend/src/services/vehicles.ts create mode 100644 frontend/src/types/member.types.ts create mode 100644 frontend/src/types/training.types.ts create mode 100644 frontend/src/types/vehicle.types.ts diff --git a/backend/package.json b/backend/package.json index 3442bb3..aeea1f9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,7 @@ "dev": "nodemon", "build": "tsc", "start": "node dist/server.js", + "migrate": "ts-node -e \"require('./src/config/database').runMigrations().then(() => { console.log('Done'); process.exit(0); }).catch((e) => { console.error(e.message); process.exit(1); })\"", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/backend/src/controllers/incident.controller.ts b/backend/src/controllers/incident.controller.ts new file mode 100644 index 0000000..114b4e9 --- /dev/null +++ b/backend/src/controllers/incident.controller.ts @@ -0,0 +1,302 @@ +import { Request, Response } from 'express'; +import incidentService from '../services/incident.service'; +import logger from '../utils/logger'; +import { AppError } from '../middleware/error.middleware'; +import { AppRole, hasPermission } from '../middleware/rbac.middleware'; +import { + CreateEinsatzSchema, + UpdateEinsatzSchema, + AssignPersonnelSchema, + AssignVehicleSchema, + IncidentFiltersSchema, +} from '../models/incident.model'; + +// Extend Request type to carry the resolved role (set by requirePermission) +interface AuthenticatedRequest extends Request { + userRole?: AppRole; +} + +class IncidentController { + // ------------------------------------------------------------------------- + // GET /api/incidents + // ------------------------------------------------------------------------- + async listIncidents(req: AuthenticatedRequest, res: Response): Promise { + try { + const parseResult = IncidentFiltersSchema.safeParse(req.query); + if (!parseResult.success) { + res.status(400).json({ + success: false, + message: 'Ungültige Filter-Parameter', + errors: parseResult.error.issues, + }); + return; + } + + const { items, total } = await incidentService.getAllIncidents(parseResult.data); + + res.status(200).json({ + success: true, + data: { + items, + total, + limit: parseResult.data.limit, + offset: parseResult.data.offset, + }, + }); + } catch (error) { + logger.error('List incidents error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Einsätze' }); + } + } + + // ------------------------------------------------------------------------- + // GET /api/incidents/stats + // ------------------------------------------------------------------------- + async getStats(req: Request, res: Response): Promise { + try { + const year = req.query.year ? parseInt(req.query.year as string, 10) : undefined; + + if (year !== undefined && (isNaN(year) || year < 2000 || year > 2100)) { + res.status(400).json({ success: false, message: 'Ungültiges Jahr' }); + return; + } + + const stats = await incidentService.getIncidentStats(year); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + logger.error('Get incident stats error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistik' }); + } + } + + // ------------------------------------------------------------------------- + // GET /api/incidents/:id + // ------------------------------------------------------------------------- + async getIncident(req: AuthenticatedRequest, res: Response): Promise { + try { + const { id } = req.params; + const incident = await incidentService.getIncidentById(id); + + if (!incident) { + throw new AppError('Einsatz nicht gefunden', 404); + } + + // Role-based redaction: only Kommandant+ can see full bericht_text + const canReadBerichtText = + req.userRole !== undefined && + hasPermission(req.userRole, 'incidents:read_bericht_text'); + + const responseData = { + ...incident, + bericht_text: canReadBerichtText ? incident.bericht_text : undefined, + }; + + res.status(200).json({ success: true, data: responseData }); + } catch (error) { + if (error instanceof AppError) { + res.status(error.statusCode).json({ success: false, message: error.message }); + return; + } + logger.error('Get incident error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Fehler beim Laden des Einsatzes' }); + } + } + + // ------------------------------------------------------------------------- + // POST /api/incidents + // ------------------------------------------------------------------------- + async createIncident(req: AuthenticatedRequest, res: Response): Promise { + try { + if (!req.user) { + res.status(401).json({ success: false, message: 'Nicht authentifiziert' }); + return; + } + + const parseResult = CreateEinsatzSchema.safeParse(req.body); + if (!parseResult.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parseResult.error.issues, + }); + return; + } + + const einsatz = await incidentService.createIncident(parseResult.data, req.user.id); + + logger.info('Incident created via API', { + einsatzId: einsatz.id, + einsatz_nr: einsatz.einsatz_nr, + createdBy: req.user.id, + }); + + res.status(201).json({ success: true, data: einsatz }); + } catch (error) { + logger.error('Create incident error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Einsatzes' }); + } + } + + // ------------------------------------------------------------------------- + // PATCH /api/incidents/:id + // ------------------------------------------------------------------------- + async updateIncident(req: AuthenticatedRequest, res: Response): Promise { + try { + if (!req.user) { + res.status(401).json({ success: false, message: 'Nicht authentifiziert' }); + return; + } + + const { id } = req.params; + const parseResult = UpdateEinsatzSchema.safeParse(req.body); + if (!parseResult.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parseResult.error.issues, + }); + return; + } + + const einsatz = await incidentService.updateIncident(id, parseResult.data, req.user.id); + + res.status(200).json({ success: true, data: einsatz }); + } catch (error) { + if (error instanceof Error && error.message === 'Incident not found') { + res.status(404).json({ success: false, message: 'Einsatz nicht gefunden' }); + return; + } + logger.error('Update incident error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Einsatzes' }); + } + } + + // ------------------------------------------------------------------------- + // DELETE /api/incidents/:id (soft delete) + // ------------------------------------------------------------------------- + async deleteIncident(req: AuthenticatedRequest, res: Response): Promise { + try { + if (!req.user) { + res.status(401).json({ success: false, message: 'Nicht authentifiziert' }); + return; + } + + const { id } = req.params; + await incidentService.deleteIncident(id, req.user.id); + + res.status(200).json({ success: true, message: 'Einsatz archiviert' }); + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + res.status(404).json({ success: false, message: 'Einsatz nicht gefunden' }); + return; + } + logger.error('Delete incident error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Fehler beim Archivieren des Einsatzes' }); + } + } + + // ------------------------------------------------------------------------- + // POST /api/incidents/:id/personnel + // ------------------------------------------------------------------------- + async assignPersonnel(req: AuthenticatedRequest, res: Response): Promise { + try { + const { id } = req.params; + + const parseResult = AssignPersonnelSchema.safeParse(req.body); + if (!parseResult.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parseResult.error.issues, + }); + return; + } + + await incidentService.assignPersonnel(id, parseResult.data); + + res.status(200).json({ success: true, message: 'Person zugewiesen' }); + } catch (error) { + logger.error('Assign personnel error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Fehler beim Zuweisen der Person' }); + } + } + + // ------------------------------------------------------------------------- + // DELETE /api/incidents/:id/personnel/:userId + // ------------------------------------------------------------------------- + async removePersonnel(req: AuthenticatedRequest, res: Response): Promise { + try { + const { id, userId } = req.params; + await incidentService.removePersonnel(id, userId); + + res.status(200).json({ success: true, message: 'Person entfernt' }); + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + res.status(404).json({ success: false, message: 'Zuweisung nicht gefunden' }); + return; + } + logger.error('Remove personnel error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Fehler beim Entfernen der Person' }); + } + } + + // ------------------------------------------------------------------------- + // POST /api/incidents/:id/vehicles + // ------------------------------------------------------------------------- + async assignVehicle(req: AuthenticatedRequest, res: Response): Promise { + try { + const { id } = req.params; + + const parseResult = AssignVehicleSchema.safeParse(req.body); + if (!parseResult.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parseResult.error.issues, + }); + return; + } + + await incidentService.assignVehicle(id, parseResult.data); + + res.status(200).json({ success: true, message: 'Fahrzeug zugewiesen' }); + } catch (error) { + logger.error('Assign vehicle error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Fehler beim Zuweisen des Fahrzeugs' }); + } + } + + // ------------------------------------------------------------------------- + // DELETE /api/incidents/:id/vehicles/:fahrzeugId + // ------------------------------------------------------------------------- + async removeVehicle(req: AuthenticatedRequest, res: Response): Promise { + try { + const { id, fahrzeugId } = req.params; + await incidentService.removeVehicle(id, fahrzeugId); + + res.status(200).json({ success: true, message: 'Fahrzeug entfernt' }); + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + res.status(404).json({ success: false, message: 'Zuweisung nicht gefunden' }); + return; + } + logger.error('Remove vehicle error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Fehler beim Entfernen des Fahrzeugs' }); + } + } + + // ------------------------------------------------------------------------- + // POST /api/incidents/refresh-stats (admin utility) + // ------------------------------------------------------------------------- + async refreshStats(_req: Request, res: Response): Promise { + try { + await incidentService.refreshStatistikView(); + res.status(200).json({ success: true, message: 'Statistik-View aktualisiert' }); + } catch (error) { + logger.error('Refresh stats view error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Statistik' }); + } + } +} + +export default new IncidentController(); diff --git a/backend/src/controllers/member.controller.ts b/backend/src/controllers/member.controller.ts new file mode 100644 index 0000000..7326923 --- /dev/null +++ b/backend/src/controllers/member.controller.ts @@ -0,0 +1,234 @@ +import { Request, Response } from 'express'; +import memberService from '../services/member.service'; +import logger from '../utils/logger'; +import { AppError } from '../middleware/error.middleware'; +import { + CreateMemberProfileSchema, + UpdateMemberProfileSchema, + SelfUpdateMemberProfileSchema, +} from '../models/member.model'; + +// ---------------------------------------------------------------- +// Role helpers +// These helpers inspect req.user.role which is populated by the +// requireRole / requirePermission middleware (see member.routes.ts). +// ---------------------------------------------------------------- + +type AppRole = 'admin' | 'kommandant' | 'mitglied'; + +function getRole(req: Request): AppRole { + return (req.user as any)?.role ?? 'mitglied'; +} + +function canWrite(req: Request): boolean { + const role = getRole(req); + return role === 'admin' || role === 'kommandant'; +} + +function isOwnProfile(req: Request, userId: string): boolean { + return req.user?.id === userId; +} + +// ---------------------------------------------------------------- +// Controller +// ---------------------------------------------------------------- +class MemberController { + /** + * GET /api/members + * Returns a paginated list of all active members. + * Supports ?search=, ?status[]=, ?dienstgrad[]=, ?page=, ?pageSize= + */ + async getMembers(req: Request, res: Response): Promise { + try { + const { + search, + page, + pageSize, + } = req.query as Record; + + // Arrays can be sent as ?status[]=aktiv&status[]=passiv or CSV + const statusParam = req.query['status'] as string | string[] | undefined; + const dienstgradParam = req.query['dienstgrad'] as string | string[] | undefined; + + const normalizeArray = (v?: string | string[]): string[] | undefined => { + if (!v) return undefined; + return Array.isArray(v) ? v : v.split(',').map((s) => s.trim()).filter(Boolean); + }; + + const { items, total } = await memberService.getAllMembers({ + search, + status: normalizeArray(statusParam) as any, + dienstgrad: normalizeArray(dienstgradParam) as any, + page: page ? parseInt(page, 10) : 1, + pageSize: pageSize ? Math.min(parseInt(pageSize, 10), 100) : 25, + }); + + res.status(200).json({ + success: true, + data: items, + meta: { total, page: page ? parseInt(page, 10) : 1 }, + }); + } catch (error) { + logger.error('getMembers error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Mitglieder.' }); + } + } + + /** + * GET /api/members/stats + * Returns aggregate member counts for each status. + * Must be registered BEFORE /:userId to avoid route collision. + */ + async getMemberStats(req: Request, res: Response): Promise { + try { + const stats = await memberService.getMemberStats(); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + logger.error('getMemberStats error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken.' }); + } + } + + /** + * GET /api/members/:userId + * Returns full member detail including profile and rank history. + * + * Role rules: + * - Kommandant/Admin: all fields + * - Mitglied reading own profile: all fields + * - Mitglied reading another member: geburtsdatum and emergency contact redacted + */ + async getMemberById(req: Request, res: Response): Promise { + try { + const { userId } = req.params; + const requestorId = req.user!.id; + const requestorRole = getRole(req); + const ownProfile = isOwnProfile(req, userId); + + const member = await memberService.getMemberById(userId); + + if (!member) { + res.status(404).json({ success: false, message: 'Mitglied nicht gefunden.' }); + return; + } + + // Sensitive field masking for non-privileged reads of other members + const canReadSensitive = canWrite(req) || ownProfile; + + if (!canReadSensitive && member.profile) { + // Replace geburtsdatum with only the age (year of birth omitted for DSGVO) + const ageMasked: any = { ...member.profile }; + + if (ageMasked.geburtsdatum) { + const birthYear = new Date(ageMasked.geburtsdatum).getFullYear(); + const age = new Date().getFullYear() - birthYear; + ageMasked.geburtsdatum = null; + ageMasked._age = age; // synthesised non-DB field + } + + // Redact emergency contact entirely + ageMasked.notfallkontakt_name = null; + ageMasked.notfallkontakt_telefon = null; + + res.status(200).json({ + success: true, + data: { ...member, profile: ageMasked }, + }); + return; + } + + res.status(200).json({ success: true, data: member }); + } catch (error) { + logger.error('getMemberById error', { error, userId: req.params.userId }); + res.status(500).json({ success: false, message: 'Fehler beim Laden des Mitglieds.' }); + } + } + + /** + * POST /api/members/:userId/profile + * Creates the mitglieder_profile row for an existing auth user. + * Restricted to Kommandant/Admin. + */ + async createMemberProfile(req: Request, res: Response): Promise { + try { + const { userId } = req.params; + + const parseResult = CreateMemberProfileSchema.safeParse(req.body); + if (!parseResult.success) { + res.status(400).json({ + success: false, + message: 'Ungültige Eingabedaten.', + errors: parseResult.error.flatten().fieldErrors, + }); + return; + } + + const profile = await memberService.createMemberProfile(userId, parseResult.data); + logger.info('createMemberProfile', { userId, createdBy: req.user!.id }); + + res.status(201).json({ success: true, data: profile }); + } catch (error: any) { + if (error?.message?.includes('existiert bereits')) { + res.status(409).json({ success: false, message: error.message }); + return; + } + logger.error('createMemberProfile error', { error, userId: req.params.userId }); + res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Profils.' }); + } + } + + /** + * PATCH /api/members/:userId + * Updates the mitglieder_profile row. + * + * Role rules: + * - Kommandant/Admin: full update (all fields) + * - Mitglied (own profile only): restricted to SelfUpdateMemberProfileSchema fields + */ + async updateMember(req: Request, res: Response): Promise { + try { + const { userId } = req.params; + const updaterId = req.user!.id; + const ownProfile = isOwnProfile(req, userId); + + if (!canWrite(req) && !ownProfile) { + res.status(403).json({ + success: false, + message: 'Keine Berechtigung, dieses Profil zu bearbeiten.', + }); + return; + } + + // Choose validation schema based on role + const schema = canWrite(req) ? UpdateMemberProfileSchema : SelfUpdateMemberProfileSchema; + const parseResult = schema.safeParse(req.body); + + if (!parseResult.success) { + res.status(400).json({ + success: false, + message: 'Ungültige Eingabedaten.', + errors: parseResult.error.flatten().fieldErrors, + }); + return; + } + + const profile = await memberService.updateMemberProfile( + userId, + parseResult.data as any, + updaterId + ); + + logger.info('updateMember', { userId, updatedBy: updaterId }); + res.status(200).json({ success: true, data: profile }); + } catch (error: any) { + if (error?.message === 'Mitgliedsprofil nicht gefunden.') { + res.status(404).json({ success: false, message: error.message }); + return; + } + logger.error('updateMember error', { error, userId: req.params.userId }); + res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Profils.' }); + } + } +} + +export default new MemberController(); diff --git a/backend/src/controllers/training.controller.ts b/backend/src/controllers/training.controller.ts new file mode 100644 index 0000000..d6f9260 --- /dev/null +++ b/backend/src/controllers/training.controller.ts @@ -0,0 +1,346 @@ +import { Request, Response } from 'express'; +import trainingService from '../services/training.service'; +import { + CreateUebungSchema, + UpdateUebungSchema, + UpdateRsvpSchema, + MarkAttendanceSchema, + CancelEventSchema, +} from '../models/training.model'; +import logger from '../utils/logger'; +import environment from '../config/environment'; + +class TrainingController { + // ------------------------------------------------------------------------- + // GET /api/training + // ------------------------------------------------------------------------- + getUpcoming = async (req: Request, res: Response): Promise => { + try { + const limit = Math.min(Number(req.query.limit ?? 10), 50); + const userId = req.user?.id; + const events = await trainingService.getUpcomingEvents(limit, userId); + + res.json({ success: true, data: events }); + } catch (error) { + logger.error('getUpcoming error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltungen' }); + } + }; + + // ------------------------------------------------------------------------- + // GET /api/training/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 userId = req.user?.id; + const events = await trainingService.getEventsByDateRange(from, to, userId); + res.json({ success: true, data: events }); + } catch (error) { + logger.error('getCalendarRange error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalenders' }); + } + }; + + // ------------------------------------------------------------------------- + // GET /api/training/calendar.ics?token= + // + // Auth design: We use a per-user opaque token stored in `calendar_tokens`. + // This allows calendar clients (Apple Calendar, Google Calendar) to subscribe + // without sending a Bearer token on every refresh. The token is long-lived + // and can be rotated by calling POST /api/training/calendar-token/rotate. + // + // TRADEOFF DISCUSSION: + // Option A (used here) — Personal calendar token: + // + Works natively in all calendar apps (no auth headers needed) + // + Rotating the token invalidates stale subscriptions + // - Token embedded in URL is visible in server logs / browser history + // → Mitigation: tokens are 256-bit random, never contain PII + // + // Option B — Bearer auth only: + // + No token in URL + // - Most native calendar apps cannot send custom headers; + // requires a proxy or special client + // → Rejected for this use case (firefighter mobile phones) + // + // Option C — Publicly readable feed (no auth): + // + Simplest + // - Leaks member names and attendance status to anyone with the URL + // → Rejected on privacy grounds + // ------------------------------------------------------------------------- + getIcalExport = async (req: Request, res: Response): Promise => { + try { + const token = req.query.token as string | undefined; + + let userId: string | undefined; + + if (token) { + const resolved = await trainingService.resolveCalendarToken(token); + if (!resolved) { + res.status(401).json({ success: false, message: 'Ungültiger Kalender-Token' }); + return; + } + userId = resolved; + } else if (req.user) { + // Also accept a normal Bearer token for direct browser access + userId = req.user.id; + } + // userId may be undefined → returns all events without personal RSVP info + + const ics = await trainingService.getCalendarExport(userId); + + res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename="feuerwehr-rems.ics"'); + // 30-minute cache — calendar clients typically re-fetch at this interval + res.setHeader('Cache-Control', 'max-age=1800, public'); + res.send(ics); + } catch (error) { + logger.error('getIcalExport error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' }); + } + }; + + // ------------------------------------------------------------------------- + // GET /api/training/calendar-token (authenticated — get or create token) + // ------------------------------------------------------------------------- + getCalendarToken = async (req: Request, res: Response): Promise => { + try { + if (!req.user) { + res.status(401).json({ success: false, message: 'Nicht authentifiziert' }); + return; + } + + const token = await trainingService.getOrCreateCalendarToken(req.user.id); + const baseUrl = environment.cors.origin.replace(/\/$/, ''); + const subscribeUrl = `${baseUrl.replace('localhost:3001', 'localhost:3000')}/api/training/calendar.ics?token=${token}`; + + res.json({ + success: true, + data: { + token, + subscribeUrl, + instructions: 'Kopiere diese URL und füge sie in "Kalender abonnieren" ein (Apple Kalender, Google Calendar, Thunderbird, etc.).', + }, + }); + } catch (error) { + logger.error('getCalendarToken error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalender-Tokens' }); + } + }; + + // ------------------------------------------------------------------------- + // GET /api/training/stats?year=2026 + // ------------------------------------------------------------------------- + getStats = async (req: Request, res: Response): Promise => { + try { + const year = Number(req.query.year ?? new Date().getFullYear()); + + if (isNaN(year) || year < 2000 || year > 2100) { + res.status(400).json({ success: false, message: 'Ungültiges Jahr' }); + return; + } + + const stats = await trainingService.getMemberParticipationStats(year); + res.json({ success: true, data: stats, meta: { year } }); + } catch (error) { + logger.error('getStats error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken' }); + } + }; + + // ------------------------------------------------------------------------- + // GET /api/training/:id + // ------------------------------------------------------------------------- + getById = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const userId = req.user?.id; + + // Determine if the requester may see the full attendee list + // In v1 we use a simple check: if the user has training:write permission + // (Kommandant / Gruppenführer), populate teilnahmen. + // The permission flag is set on req by requirePermission middleware, + // but we check it here by convention. + const canSeeTeilnahmen = (req as any).canSeeTeilnahmen === true; + + const event = await trainingService.getEventById(id, userId, canSeeTeilnahmen); + + 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/training + // ------------------------------------------------------------------------- + createEvent = async (req: Request, res: Response): Promise => { + try { + const parsed = CreateUebungSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + + const event = await trainingService.createEvent(parsed.data, req.user!.id); + res.status(201).json({ success: true, data: event }); + } catch (error) { + logger.error('createEvent error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Veranstaltung' }); + } + }; + + // ------------------------------------------------------------------------- + // PATCH /api/training/:id + // ------------------------------------------------------------------------- + updateEvent = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const parsed = UpdateUebungSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + + const event = await trainingService.updateEvent(id, parsed.data, req.user!.id); + res.json({ success: true, data: event }); + } catch (error: any) { + if (error.message === 'Event not found') { + res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' }); + return; + } + logger.error('updateEvent error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Veranstaltung' }); + } + }; + + // ------------------------------------------------------------------------- + // DELETE /api/training/:id (soft cancel) + // ------------------------------------------------------------------------- + cancelEvent = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const parsed = CancelEventSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + + await trainingService.cancelEvent(id, parsed.data.absage_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' }); + } + }; + + // ------------------------------------------------------------------------- + // PATCH /api/training/:id/attendance — own RSVP + // ------------------------------------------------------------------------- + updateRsvp = async (req: Request, res: Response): Promise => { + try { + const { id: uebungId } = req.params; + const userId = req.user!.id; + const parsed = UpdateRsvpSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + + await trainingService.updateAttendanceRSVP( + uebungId, + userId, + parsed.data.status, + parsed.data.bemerkung + ); + + res.json({ success: true, message: 'Rückmeldung gespeichert' }); + } catch (error) { + logger.error('updateRsvp error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Speichern der Rückmeldung' }); + } + }; + + // ------------------------------------------------------------------------- + // POST /api/training/:id/attendance/mark — bulk mark as erschienen + // ------------------------------------------------------------------------- + markAttendance = async (req: Request, res: Response): Promise => { + try { + const { id: uebungId } = req.params; + const parsed = MarkAttendanceSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + + await trainingService.markAttendance(uebungId, parsed.data.userIds, req.user!.id); + res.json({ + success: true, + message: `${parsed.data.userIds.length} Personen als erschienen markiert`, + }); + } catch (error) { + logger.error('markAttendance error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Erfassen der Anwesenheit' }); + } + }; +} + +export default new TrainingController(); diff --git a/backend/src/controllers/vehicle.controller.ts b/backend/src/controllers/vehicle.controller.ts new file mode 100644 index 0000000..90ac9b5 --- /dev/null +++ b/backend/src/controllers/vehicle.controller.ts @@ -0,0 +1,344 @@ +import { Request, Response } from 'express'; +import { z } from 'zod'; +import vehicleService from '../services/vehicle.service'; +import { FahrzeugStatus, PruefungArt } from '../models/vehicle.model'; +import logger from '../utils/logger'; + +// ── Zod Validation Schemas ──────────────────────────────────────────────────── + +const FahrzeugStatusEnum = z.enum([ + FahrzeugStatus.Einsatzbereit, + FahrzeugStatus.AusserDienstWartung, + FahrzeugStatus.AusserDienstSchaden, + FahrzeugStatus.InLehrgang, +]); + +const PruefungArtEnum = z.enum([ + PruefungArt.HU, + PruefungArt.AU, + PruefungArt.UVV, + PruefungArt.Leiter, + PruefungArt.Kran, + PruefungArt.Seilwinde, + PruefungArt.Sonstiges, +]); + +const isoDate = z.string().regex( + /^\d{4}-\d{2}-\d{2}$/, + 'Expected ISO date format YYYY-MM-DD' +); + +const CreateFahrzeugSchema = z.object({ + bezeichnung: z.string().min(1).max(100), + kurzname: z.string().max(20).optional(), + amtliches_kennzeichen: z.string().max(20).optional(), + fahrgestellnummer: z.string().max(50).optional(), + baujahr: z.number().int().min(1950).max(2100).optional(), + hersteller: z.string().max(100).optional(), + typ_schluessel: z.string().max(30).optional(), + besatzung_soll: z.string().max(10).optional(), + status: FahrzeugStatusEnum.optional(), + status_bemerkung: z.string().max(500).optional(), + standort: z.string().max(100).optional(), + bild_url: z.string().url().max(500).optional(), +}); + +const UpdateFahrzeugSchema = CreateFahrzeugSchema.partial(); + +const UpdateStatusSchema = z.object({ + status: FahrzeugStatusEnum, + bemerkung: z.string().max(500).optional().default(''), +}); + +const CreatePruefungSchema = z.object({ + pruefung_art: PruefungArtEnum, + faellig_am: isoDate, + durchgefuehrt_am: isoDate.optional(), + ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden', 'ausstehend']).optional(), + pruefende_stelle: z.string().max(150).optional(), + kosten: z.number().min(0).optional(), + dokument_url: z.string().url().max(500).optional(), + bemerkung: z.string().max(1000).optional(), +}); + +const CreateWartungslogSchema = z.object({ + datum: isoDate, + art: z.enum(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges']).optional(), + beschreibung: z.string().min(1).max(2000), + km_stand: z.number().int().min(0).optional(), + kraftstoff_liter: z.number().min(0).optional(), + kosten: z.number().min(0).optional(), + externe_werkstatt: z.string().max(150).optional(), +}); + +// ── Helper ──────────────────────────────────────────────────────────────────── + +function getUserId(req: Request): string { + // req.user is guaranteed by the authenticate middleware + return req.user!.id; +} + +// ── Controller ──────────────────────────────────────────────────────────────── + +class VehicleController { + /** + * GET /api/vehicles + * Fleet overview list with per-vehicle inspection badge data. + */ + async listVehicles(req: Request, res: Response): Promise { + try { + const vehicles = await vehicleService.getAllVehicles(); + res.status(200).json({ success: true, data: vehicles }); + } catch (error) { + logger.error('listVehicles error', { error }); + res.status(500).json({ success: false, message: 'Fahrzeuge konnten nicht geladen werden' }); + } + } + + /** + * GET /api/vehicles/stats + * Aggregated KPI counts for the dashboard strip. + */ + async getStats(req: Request, res: Response): Promise { + try { + const stats = await vehicleService.getVehicleStats(); + res.status(200).json({ success: true, data: stats }); + } catch (error) { + logger.error('getStats error', { error }); + res.status(500).json({ success: false, message: 'Statistiken konnten nicht geladen werden' }); + } + } + + /** + * GET /api/vehicles/alerts?daysAhead=30 + * Upcoming and overdue inspections — used by the InspectionAlerts dashboard panel. + * Returns alerts sorted by urgency (most overdue / soonest due first). + */ + async getAlerts(req: Request, res: Response): Promise { + try { + const daysAhead = Math.min( + parseInt((req.query.daysAhead as string) || '30', 10), + 365 // hard cap — never expose more than 1 year of lookahead + ); + + if (isNaN(daysAhead) || daysAhead < 0) { + res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' }); + return; + } + + const alerts = await vehicleService.getUpcomingInspections(daysAhead); + res.status(200).json({ success: true, data: alerts }); + } catch (error) { + logger.error('getAlerts error', { error }); + res.status(500).json({ success: false, message: 'Prüfungshinweise konnten nicht geladen werden' }); + } + } + + /** + * GET /api/vehicles/:id + * Full vehicle detail with pruefstatus, inspection history, and wartungslog. + */ + async getVehicle(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const vehicle = await vehicleService.getVehicleById(id); + + if (!vehicle) { + res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); + return; + } + + res.status(200).json({ success: true, data: vehicle }); + } catch (error) { + logger.error('getVehicle error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht geladen werden' }); + } + } + + /** + * POST /api/vehicles + * Create a new vehicle. Requires vehicles:write permission. + */ + async createVehicle(req: Request, res: Response): Promise { + try { + const parsed = CreateFahrzeugSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + + const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req)); + res.status(201).json({ success: true, data: vehicle }); + } catch (error) { + logger.error('createVehicle error', { error }); + res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht erstellt werden' }); + } + } + + /** + * PATCH /api/vehicles/:id + * Update vehicle fields. Requires vehicles:write permission. + */ + async updateVehicle(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const parsed = UpdateFahrzeugSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + + const vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req)); + res.status(200).json({ success: true, data: vehicle }); + } catch (error: any) { + if (error?.message === 'Vehicle not found') { + res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); + return; + } + logger.error('updateVehicle error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht aktualisiert werden' }); + } + } + + /** + * PATCH /api/vehicles/:id/status + * Live status change — the Socket.IO hook point for Tier 3. + * Requires vehicles:write permission. + * + * The `io` instance is attached to req.app in server.ts (Tier 3): + * app.set('io', io); + * and retrieved here via req.app.get('io'). + */ + async updateVehicleStatus(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const parsed = UpdateStatusSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + + // Tier 3: io will be available via req.app.get('io') once Socket.IO is wired up. + // Passing undefined here is safe — the service handles it gracefully. + const io = req.app.get('io') ?? undefined; + + await vehicleService.updateVehicleStatus( + id, + parsed.data.status, + parsed.data.bemerkung, + getUserId(req), + io + ); + + res.status(200).json({ success: true, message: 'Status aktualisiert' }); + } catch (error: any) { + if (error?.message === 'Vehicle not found') { + res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' }); + return; + } + logger.error('updateVehicleStatus error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' }); + } + } + + // ── Inspections ───────────────────────────────────────────────────────────── + + /** + * POST /api/vehicles/:id/pruefungen + * Record an inspection (scheduled or completed). Requires vehicles:write. + */ + async addPruefung(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const parsed = CreatePruefungSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + + const pruefung = await vehicleService.addPruefung(id, parsed.data, getUserId(req)); + res.status(201).json({ success: true, data: pruefung }); + } catch (error) { + logger.error('addPruefung error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Prüfung konnte nicht eingetragen werden' }); + } + } + + /** + * GET /api/vehicles/:id/pruefungen + * Full inspection history for a vehicle. + */ + async getPruefungen(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const pruefungen = await vehicleService.getPruefungenForVehicle(id); + res.status(200).json({ success: true, data: pruefungen }); + } catch (error) { + logger.error('getPruefungen error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Prüfungshistorie konnte nicht geladen werden' }); + } + } + + // ── Maintenance Log ────────────────────────────────────────────────────────── + + /** + * POST /api/vehicles/:id/wartung + * Add a maintenance log entry. Requires vehicles:write. + */ + async addWartung(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const parsed = CreateWartungslogSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ + success: false, + message: 'Validierungsfehler', + errors: parsed.error.flatten().fieldErrors, + }); + return; + } + + const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req)); + res.status(201).json({ success: true, data: entry }); + } catch (error) { + logger.error('addWartung error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' }); + } + } + + /** + * GET /api/vehicles/:id/wartung + * Maintenance log for a vehicle. + */ + async getWartung(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const entries = await vehicleService.getWartungslogForVehicle(id); + res.status(200).json({ success: true, data: entries }); + } catch (error) { + logger.error('getWartung error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Wartungslog konnte nicht geladen werden' }); + } + } +} + +export default new VehicleController(); diff --git a/backend/src/database/migrations/002_create_audit_log.sql b/backend/src/database/migrations/002_create_audit_log.sql new file mode 100644 index 0000000..bd2db5c --- /dev/null +++ b/backend/src/database/migrations/002_create_audit_log.sql @@ -0,0 +1,146 @@ +-- ============================================================================= +-- Migration 002: Audit Log Table +-- GDPR Art. 5(2) Accountability + Art. 30 Records of Processing Activities +-- +-- Design decisions: +-- - UUID primary key (consistent with users table) +-- - user_id is nullable: pre-auth events (LOGIN failures) have no user yet +-- - old_value / new_value are JSONB: flexible, queryable, indexable +-- - ip_address stored as TEXT (supports IPv4 + IPv6); anonymised after 90 days +-- - Table is immutable: a PostgreSQL RULE blocks UPDATE and DELETE at the +-- SQL level, which is simpler than triggers and cannot be bypassed by the +-- application role +-- - Partial index on ip_address covers only recent rows (90-day window) to +-- support efficient anonymisation queries without a full-table scan +-- ============================================================================= + +-- ------------------------------------------------------- +-- 1. Enums — define before the table +-- ------------------------------------------------------- + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN + CREATE TYPE audit_action AS ENUM ( + 'CREATE', + 'UPDATE', + 'DELETE', + 'LOGIN', + 'LOGOUT', + 'EXPORT', + 'PERMISSION_DENIED', + 'PASSWORD_CHANGE', + 'ROLE_CHANGE' + ); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_resource_type') THEN + CREATE TYPE audit_resource_type AS ENUM ( + 'MEMBER', + 'INCIDENT', + 'VEHICLE', + 'EQUIPMENT', + 'QUALIFICATION', + 'USER', + 'SYSTEM' + ); + END IF; +END +$$; + +-- ------------------------------------------------------- +-- 2. Core audit_log table +-- ------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + user_email VARCHAR(255), -- denormalised snapshot; users can be deleted + action audit_action NOT NULL, + resource_type audit_resource_type NOT NULL, + resource_id VARCHAR(255), -- may be UUID, numeric ID, or 'system' + old_value JSONB, -- state before the operation + new_value JSONB, -- state after the operation + ip_address TEXT, -- anonymised to '[anonymized]' after 90 days + user_agent TEXT, + metadata JSONB DEFAULT '{}', -- any extra context (e.g. export format, reason) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Prevent modification of audit records at the database level. +-- Using RULE rather than a trigger because rules are evaluated before +-- the operation and cannot be disabled without superuser access. +CREATE OR REPLACE RULE audit_log_no_update AS + ON UPDATE TO audit_log DO INSTEAD NOTHING; + +CREATE OR REPLACE RULE audit_log_no_delete AS + ON DELETE TO audit_log DO INSTEAD NOTHING; + +-- ------------------------------------------------------- +-- 3. Indexes +-- ------------------------------------------------------- + +-- Lookup by actor (admin "what did this user do?") +CREATE INDEX IF NOT EXISTS idx_audit_log_user_id + ON audit_log (user_id) + WHERE user_id IS NOT NULL; + +-- Lookup by subject resource (admin "what happened to member X?") +CREATE INDEX IF NOT EXISTS idx_audit_log_resource + ON audit_log (resource_type, resource_id); + +-- Time-range queries and retention scans (most common filter) +CREATE INDEX IF NOT EXISTS idx_audit_log_created_at + ON audit_log (created_at DESC); + +-- Action-type filter +CREATE INDEX IF NOT EXISTS idx_audit_log_action + ON audit_log (action); + +-- Partial index: only rows where IP address is not yet anonymised. +-- The anonymisation job does: WHERE created_at < NOW() - INTERVAL '90 days' +-- AND ip_address != '[anonymized]' +-- This index makes that query O(matched rows) instead of O(table size). +CREATE INDEX IF NOT EXISTS idx_audit_log_ip_retention + ON audit_log (created_at) + WHERE ip_address IS NOT NULL + AND ip_address != '[anonymized]'; + +-- ------------------------------------------------------- +-- 4. OPTIONAL — Range partitioning by month (recommended +-- for high-volume deployments; skip in small setups) +-- +-- To use partitioning, replace the CREATE TABLE above with +-- the following DDL *before* the first row is inserted. +-- Partitioning cannot be added to an existing unpartitioned +-- table without a full rewrite (pg_partman can automate this). +-- +-- CREATE TABLE audit_log ( +-- id UUID NOT NULL DEFAULT uuid_generate_v4(), +-- user_id UUID REFERENCES users(id) ON DELETE SET NULL, +-- user_email VARCHAR(255), +-- action audit_action NOT NULL, +-- resource_type audit_resource_type NOT NULL, +-- resource_id VARCHAR(255), +-- old_value JSONB, +-- new_value JSONB, +-- ip_address TEXT, +-- user_agent TEXT, +-- metadata JSONB DEFAULT '{}', +-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +-- ) PARTITION BY RANGE (created_at); +-- +-- -- Create monthly partitions with pg_partman or manually: +-- CREATE TABLE audit_log_2026_01 PARTITION OF audit_log +-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'); +-- CREATE TABLE audit_log_2026_02 PARTITION OF audit_log +-- FOR VALUES FROM ('2026-02-01') TO ('2026-03-01'); +-- -- ... and so on. pg_partman automates partition creation. +-- +-- With partitioning, old partitions can be DROPped for +-- efficient bulk retention deletion without a full-table scan. +-- ------------------------------------------------------- diff --git a/backend/src/database/migrations/003_create_mitglieder_profile.sql b/backend/src/database/migrations/003_create_mitglieder_profile.sql new file mode 100644 index 0000000..6c89394 --- /dev/null +++ b/backend/src/database/migrations/003_create_mitglieder_profile.sql @@ -0,0 +1,138 @@ +-- Migration: 003_create_mitglieder_profile +-- Creates the mitglieder_profile and dienstgrad_verlauf tables for fire department member data. +-- Rollback: +-- DROP TABLE IF EXISTS dienstgrad_verlauf; +-- DROP TABLE IF EXISTS mitglieder_profile; + +-- ============================================================ +-- mitglieder_profile +-- One-to-one extension of the users table. +-- A user can exist without a profile (profile is created later +-- by a Kommandant). The user_id is both FK and UNIQUE. +-- ============================================================ +CREATE TABLE IF NOT EXISTS mitglieder_profile ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Internal member identifier assigned by the Kommandant + mitglieds_nr VARCHAR(32) UNIQUE, + + -- Rank (Dienstgrad) with allowed values enforced by CHECK + dienstgrad VARCHAR(64) + CHECK (dienstgrad IS NULL OR dienstgrad IN ( + 'Feuerwehranwärter', + 'Feuerwehrmann', + 'Feuerwehrfrau', + 'Oberfeuerwehrmann', + 'Oberfeuerwehrfrau', + 'Hauptfeuerwehrmann', + 'Hauptfeuerwehrfrau', + 'Löschmeister', + 'Oberlöschmeister', + 'Hauptlöschmeister', + 'Brandmeister', + 'Oberbrandmeister', + 'Hauptbrandmeister', + 'Brandinspektor', + 'Oberbrandinspektor', + 'Brandoberinspektor', + 'Brandamtmann' + )), + dienstgrad_seit DATE, + + -- Funktion(en) — a member can hold multiple roles simultaneously + -- Stored as a PostgreSQL TEXT array + funktion TEXT[] DEFAULT '{}', + + -- Membership status + status VARCHAR(32) NOT NULL DEFAULT 'aktiv' + CHECK (status IN ( + 'aktiv', + 'passiv', + 'ehrenmitglied', + 'jugendfeuerwehr', + 'anwärter', + 'ausgetreten' + )), + + -- Important dates + eintrittsdatum DATE, + austrittsdatum DATE, + geburtsdatum DATE, -- sensitive: only shown to Kommandant/Admin + + -- Contact information (stored raw, formatted on display) + telefon_mobil VARCHAR(32), + telefon_privat VARCHAR(32), + + -- Emergency contact (sensitive: only own record visible to Mitglied) + notfallkontakt_name VARCHAR(255), + notfallkontakt_telefon VARCHAR(32), + + -- Driving licenses (e.g. ['B', 'C', 'CE']) + fuehrerscheinklassen TEXT[] DEFAULT '{}', + + -- Uniform sizing + tshirt_groesse VARCHAR(8) + CHECK (tshirt_groesse IS NULL OR tshirt_groesse IN ( + 'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL' + )), + schuhgroesse VARCHAR(8), + + -- Free-text notes (Kommandant only) + bemerkungen TEXT, + + -- Profile photo URL (separate from the Authentik profile_picture_url) + bild_url TEXT, + + -- Audit timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- Enforce one-to-one relationship with users + CONSTRAINT uq_mitglieder_profile_user_id UNIQUE (user_id) +); + +-- ============================================================ +-- Indexes for the most common query patterns +-- ============================================================ +CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_user_id + ON mitglieder_profile(user_id); + +CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_status + ON mitglieder_profile(status); + +CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_dienstgrad + ON mitglieder_profile(dienstgrad); + +CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_mitglieds_nr + ON mitglieder_profile(mitglieds_nr); + +-- ============================================================ +-- Auto-update trigger for updated_at +-- Reuses the function already created by migration 001. +-- ============================================================ +CREATE TRIGGER update_mitglieder_profile_updated_at + BEFORE UPDATE ON mitglieder_profile + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================================ +-- dienstgrad_verlauf +-- Append-only audit log of every rank change. +-- Never deleted; soft history only. +-- ============================================================ +CREATE TABLE IF NOT EXISTS dienstgrad_verlauf ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dienstgrad_neu VARCHAR(64) NOT NULL, + dienstgrad_alt VARCHAR(64), -- NULL on first assignment + datum DATE NOT NULL DEFAULT CURRENT_DATE, + durch_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + bemerkung TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_user_id + ON dienstgrad_verlauf(user_id); + +CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_datum + ON dienstgrad_verlauf(datum DESC); diff --git a/backend/src/database/migrations/004_create_einsaetze.sql b/backend/src/database/migrations/004_create_einsaetze.sql new file mode 100644 index 0000000..29c40e4 --- /dev/null +++ b/backend/src/database/migrations/004_create_einsaetze.sql @@ -0,0 +1,270 @@ +-- Migration: 004_create_einsaetze.sql +-- Feature: Einsatzprotokoll (Incident Management) +-- Depends on: 001_create_users_table.sql +-- Rollback instructions at bottom of file + +-- --------------------------------------------------------------------------- +-- ENUM-LIKE CHECK CONSTRAINTS (as VARCHAR + CHECK for easy extension) +-- --------------------------------------------------------------------------- + +-- --------------------------------------------------------------------------- +-- SEQUENCE SUPPORT TABLE for year-based Einsatz-Nr (YYYY-NNN) +-- One row per year; nextval equivalent done via UPDATE...RETURNING +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS einsatz_nr_sequence ( + year INTEGER PRIMARY KEY, + last_nr INTEGER NOT NULL DEFAULT 0 +); + +-- --------------------------------------------------------------------------- +-- FAHRZEUGE TABLE (vehicles — stub so junction table has a valid FK target) +-- A full Fahrzeuge feature is covered by 005_create_fahrzeuge.sql. +-- We do NOT create the table here; migration 005 owns it. +-- The FK in einsatz_fahrzeuge will resolve correctly when 005 runs first +-- in the standard sort order (004 runs before 005, so we use DEFERRABLE or +-- we simply add the FK as a separate ALTER TABLE at the end of this file, +-- after we know 005 may not have run yet). +-- +-- Resolution strategy: einsatz_fahrzeuge.fahrzeug_id FK is defined as +-- DEFERRABLE INITIALLY DEFERRED so it is checked at transaction commit time +-- rather than at statement time, allowing this migration to run without +-- requiring fahrzeuge to exist yet. +-- --------------------------------------------------------------------------- + +-- --------------------------------------------------------------------------- +-- MAIN EINSAETZE TABLE +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS einsaetze ( + -- Identity + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + einsatz_nr VARCHAR(12) NOT NULL, -- Format: 2026-001 + + -- Core timestamps (all TIMESTAMPTZ — never null for alarm_time) + alarm_time TIMESTAMPTZ NOT NULL, + ausrueck_time TIMESTAMPTZ, + ankunft_time TIMESTAMPTZ, + einrueck_time TIMESTAMPTZ, + + -- Classification + einsatz_art VARCHAR(30) NOT NULL + CONSTRAINT chk_einsatz_art CHECK (einsatz_art IN ( + 'Brand', + 'THL', + 'ABC', + 'BMA', + 'Hilfeleistung', + 'Fehlalarm', + 'Brandsicherheitswache' + )), + einsatz_stichwort VARCHAR(30), -- B1/B2/B3/B4, THL 1/2/3, etc. + + -- Location + strasse VARCHAR(150), + hausnummer VARCHAR(20), + ort VARCHAR(100), + koordinaten POINT, -- (longitude, latitude) + + -- Narrative + bericht_kurz VARCHAR(255), -- short description, visible to all + bericht_text TEXT, -- full narrative, restricted to Kommandant+ + + -- References + einsatzleiter_id UUID + CONSTRAINT fk_einsaetze_einsatzleiter REFERENCES users(id) ON DELETE SET NULL, + + -- Operational metadata + alarmierung_art VARCHAR(30) NOT NULL DEFAULT 'ILS' + CONSTRAINT chk_alarmierung_art CHECK (alarmierung_art IN ( + 'ILS', + 'DME', + 'Telefon', + 'Vor_Ort', + 'Sonstiges' + )), + status VARCHAR(20) NOT NULL DEFAULT 'aktiv' + CONSTRAINT chk_einsatz_status CHECK (status IN ( + 'aktiv', + 'abgeschlossen', + 'archiviert' + )), + + -- Audit + created_by UUID + CONSTRAINT fk_einsaetze_created_by REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Uniqueness: einsatz_nr is globally unique (year+seq guarantees it) + CONSTRAINT uq_einsatz_nr UNIQUE (einsatz_nr) +); + +-- Performance indexes +CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_time ON einsaetze(alarm_time DESC); +CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatz_art ON einsaetze(einsatz_art); +CREATE INDEX IF NOT EXISTS idx_einsaetze_status ON einsaetze(status); +CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatzleiter ON einsaetze(einsatzleiter_id); +CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_year ON einsaetze(EXTRACT(YEAR FROM alarm_time)); +CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_art_year ON einsaetze(einsatz_art, EXTRACT(YEAR FROM alarm_time)); + +-- Auto-update updated_at +CREATE TRIGGER update_einsaetze_updated_at + BEFORE UPDATE ON einsaetze + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- --------------------------------------------------------------------------- +-- JUNCTION TABLE: einsatz_fahrzeuge +-- NOTE: fahrzeug_id FK references fahrzeuge which is created in 005. +-- We define the FK as DEFERRABLE INITIALLY DEFERRED so the constraint is +-- validated at transaction commit, not statement time. This means the +-- migration can run before 005 as long as both run in the same session. +-- In production where 005 was already run, the FK resolves immediately. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS einsatz_fahrzeuge ( + einsatz_id UUID NOT NULL + CONSTRAINT fk_ef_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE, + fahrzeug_id UUID NOT NULL, + -- FK added separately below via ALTER TABLE to handle cross-migration dependency + + -- Vehicle-level timestamps (may differ from main einsatz times) + ausrueck_time TIMESTAMPTZ, + einrueck_time TIMESTAMPTZ, + + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT pk_einsatz_fahrzeuge PRIMARY KEY (einsatz_id, fahrzeug_id) +); + +-- Add the FK to fahrzeuge only if the fahrzeuge table already exists +-- (handles the case where 004 runs after 005 in a fresh deployment). +-- If fahrzeuge does not exist yet, the FK will be added by migration 005 +-- via an ALTER TABLE at the end of that migration. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'fahrzeuge' + ) THEN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'einsatz_fahrzeuge' + AND constraint_name = 'fk_ef_fahrzeug' + ) THEN + ALTER TABLE einsatz_fahrzeuge + ADD CONSTRAINT fk_ef_fahrzeug + FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE; + END IF; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_ef_fahrzeug_id ON einsatz_fahrzeuge(fahrzeug_id); + +-- --------------------------------------------------------------------------- +-- JUNCTION TABLE: einsatz_personal +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS einsatz_personal ( + einsatz_id UUID NOT NULL + CONSTRAINT fk_ep_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE, + user_id UUID NOT NULL + CONSTRAINT fk_ep_user REFERENCES users(id) ON DELETE CASCADE, + + funktion VARCHAR(50) NOT NULL DEFAULT 'Mannschaft' + CONSTRAINT chk_ep_funktion CHECK (funktion IN ( + 'Einsatzleiter', + 'Gruppenführer', + 'Maschinist', + 'Atemschutz', + 'Sicherheitstrupp', + 'Melder', + 'Wassertrupp', + 'Angriffstrupp', + 'Mannschaft', + 'Sonstiges' + )), + + -- Personal-level timestamps + alarm_time TIMESTAMPTZ, + ankunft_time TIMESTAMPTZ, + + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT pk_einsatz_personal PRIMARY KEY (einsatz_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_ep_user_id ON einsatz_personal(user_id); +CREATE INDEX IF NOT EXISTS idx_ep_funktion ON einsatz_personal(funktion); + +-- --------------------------------------------------------------------------- +-- FUNCTION: generate_einsatz_nr(alarm_time) +-- Atomically generates the next Einsatz-Nr for a given year. +-- Uses an advisory lock to serialise concurrent inserts for the same year. +-- Returns format: '2026-001' +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION generate_einsatz_nr(p_alarm_time TIMESTAMPTZ) +RETURNS VARCHAR AS $$ +DECLARE + v_year INTEGER; + v_nr INTEGER; +BEGIN + v_year := EXTRACT(YEAR FROM p_alarm_time)::INTEGER; + + -- Upsert the sequence row, then atomically increment and return + INSERT INTO einsatz_nr_sequence (year, last_nr) + VALUES (v_year, 1) + ON CONFLICT (year) DO UPDATE + SET last_nr = einsatz_nr_sequence.last_nr + 1 + RETURNING last_nr INTO v_nr; + + RETURN v_year::TEXT || '-' || LPAD(v_nr::TEXT, 3, '0'); +END; +$$ LANGUAGE plpgsql; + +-- --------------------------------------------------------------------------- +-- MATERIALIZED VIEW: einsatz_statistik +-- Pre-aggregated stats used by the dashboard and annual KBI reports. +-- Refresh manually after inserts/updates via: REFRESH MATERIALIZED VIEW einsatz_statistik; +-- --------------------------------------------------------------------------- +CREATE MATERIALIZED VIEW IF NOT EXISTS einsatz_statistik AS +SELECT + EXTRACT(YEAR FROM alarm_time)::INTEGER AS jahr, + EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat, + einsatz_art, + COUNT(*) AS anzahl, + -- Hilfsfrist: median minutes from alarm to arrival (only where ankunft_time is set) + ROUND( + AVG( + EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0 + ) FILTER (WHERE ankunft_time IS NOT NULL) + )::INTEGER AS avg_hilfsfrist_min, + -- Total duration (alarm to einrueck) + ROUND( + AVG( + EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0 + ) FILTER (WHERE einrueck_time IS NOT NULL) + )::INTEGER AS avg_dauer_min, + COUNT(*) FILTER (WHERE status = 'abgeschlossen') AS anzahl_abgeschlossen, + COUNT(*) FILTER (WHERE status = 'aktiv') AS anzahl_aktiv +FROM einsaetze +WHERE status != 'archiviert' +GROUP BY + EXTRACT(YEAR FROM alarm_time), + EXTRACT(MONTH FROM alarm_time), + einsatz_art +WITH DATA; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_einsatz_statistik_pk + ON einsatz_statistik(jahr, monat, einsatz_art); + +CREATE INDEX IF NOT EXISTS idx_einsatz_statistik_jahr + ON einsatz_statistik(jahr); + +-- --------------------------------------------------------------------------- +-- ROLLBACK INSTRUCTIONS (run in reverse order to undo): +-- +-- DROP MATERIALIZED VIEW IF EXISTS einsatz_statistik; +-- DROP FUNCTION IF EXISTS generate_einsatz_nr(TIMESTAMPTZ); +-- DROP TABLE IF EXISTS einsatz_personal; +-- DROP TABLE IF EXISTS einsatz_fahrzeuge; +-- DROP TABLE IF EXISTS einsaetze; +-- DROP TABLE IF EXISTS fahrzeuge; +-- DROP TABLE IF EXISTS einsatz_nr_sequence; +-- --------------------------------------------------------------------------- diff --git a/backend/src/database/migrations/005_create_fahrzeuge.sql b/backend/src/database/migrations/005_create_fahrzeuge.sql new file mode 100644 index 0000000..d96ed1d --- /dev/null +++ b/backend/src/database/migrations/005_create_fahrzeuge.sql @@ -0,0 +1,331 @@ +-- Migration 005: Fahrzeugverwaltung (Vehicle Fleet Management) +-- Depends on: 001_create_users_table.sql (uuid-ossp extension, users table) + +-- ============================================================ +-- TABLE: fahrzeuge +-- ============================================================ +CREATE TABLE IF NOT EXISTS fahrzeuge ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + bezeichnung VARCHAR(100) NOT NULL, -- e.g. "LF 20/16", "HLF 10" + kurzname VARCHAR(20), -- e.g. "LF1", "HLF2" + amtliches_kennzeichen VARCHAR(20) UNIQUE, -- e.g. "WN-FW 1" + fahrgestellnummer VARCHAR(50), -- VIN + baujahr INTEGER CHECK (baujahr >= 1950 AND baujahr <= 2100), + hersteller VARCHAR(100), -- e.g. "MAN", "Mercedes-Benz", "Rosenbauer" + typ_schluessel VARCHAR(30), -- DIN 14502 code, e.g. "LF 20/16" + besatzung_soll VARCHAR(10), -- crew config e.g. "1/8", "1/5" + status VARCHAR(40) NOT NULL DEFAULT 'einsatzbereit' + CHECK (status IN ( + 'einsatzbereit', + 'ausser_dienst_wartung', + 'ausser_dienst_schaden', + 'in_lehrgang' + )), + status_bemerkung TEXT, + standort VARCHAR(100) NOT NULL DEFAULT 'Feuerwehrhaus', + bild_url VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_fahrzeuge_status ON fahrzeuge(status); +CREATE INDEX IF NOT EXISTS idx_fahrzeuge_kennzeichen ON fahrzeuge(amtliches_kennzeichen); + +-- Auto-update updated_at (reuses function from migration 001) +CREATE TRIGGER update_fahrzeuge_updated_at + BEFORE UPDATE ON fahrzeuge + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + + +-- ============================================================ +-- TABLE: fahrzeug_pruefungen +-- Stores both upcoming scheduled inspections AND completed ones. +-- A row with durchgefuehrt_am = NULL is an open/scheduled inspection. +-- ============================================================ +CREATE TABLE IF NOT EXISTS fahrzeug_pruefungen ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE, + pruefung_art VARCHAR(30) NOT NULL + CHECK (pruefung_art IN ( + 'HU', -- Hauptuntersuchung (TÜV), 24-month interval + 'AU', -- Abgasuntersuchung, 12-month interval + 'UVV', -- Unfallverhütungsvorschrift BGV D29, 12-month + 'Leiter', -- Leiternprüfung (DLK), 12-month + 'Kran', -- Kranprüfung, 12-month + 'Seilwinde', -- Seilwindenprüfung, 12-month + 'Sonstiges' + )), + -- faellig_am: the deadline by which this inspection must be completed + faellig_am DATE NOT NULL, + -- durchgefuehrt_am: NULL = not yet done; set when inspection is completed + durchgefuehrt_am DATE, + ergebnis VARCHAR(30) + CHECK (ergebnis IS NULL OR ergebnis IN ( + 'bestanden', + 'bestanden_mit_maengeln', + 'nicht_bestanden', + 'ausstehend' + )), + -- naechste_faelligkeit: auto-calculated next due date after completion + naechste_faelligkeit DATE, + pruefende_stelle VARCHAR(150), -- e.g. "TÜV Süd Stuttgart", "DEKRA" + kosten DECIMAL(8,2), + dokument_url VARCHAR(500), + bemerkung TEXT, + erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_id ON fahrzeug_pruefungen(fahrzeug_id); +CREATE INDEX IF NOT EXISTS idx_pruefungen_faellig_am ON fahrzeug_pruefungen(faellig_am); +CREATE INDEX IF NOT EXISTS idx_pruefungen_art ON fahrzeug_pruefungen(pruefung_art); +-- Composite index for the "latest per type" query pattern +CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_art_faellig + ON fahrzeug_pruefungen(fahrzeug_id, pruefung_art, faellig_am DESC); + + +-- ============================================================ +-- TABLE: fahrzeug_wartungslog +-- Service/maintenance log entries (fuel, repairs, tyres, etc.) +-- ============================================================ +CREATE TABLE IF NOT EXISTS fahrzeug_wartungslog ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE, + datum DATE NOT NULL, + art VARCHAR(30) + CHECK (art IS NULL OR art IN ( + 'Inspektion', + 'Reparatur', + 'Kraftstoff', + 'Reifenwechsel', + 'Hauptuntersuchung', + 'Reinigung', + 'Sonstiges' + )), + beschreibung TEXT NOT NULL, + km_stand INTEGER CHECK (km_stand >= 0), + kraftstoff_liter DECIMAL(6,2) CHECK (kraftstoff_liter >= 0), + kosten DECIMAL(8,2) CHECK (kosten >= 0), + externe_werkstatt VARCHAR(150), + erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_wartungslog_fahrzeug_id ON fahrzeug_wartungslog(fahrzeug_id); +CREATE INDEX IF NOT EXISTS idx_wartungslog_datum ON fahrzeug_wartungslog(datum DESC); + + +-- ============================================================ +-- VIEW: fahrzeuge_mit_pruefstatus +-- For each vehicle, joins its LATEST scheduled pruefung per art +-- and computes tage_bis_faelligkeit (negative = overdue). +-- The dashboard alert panel and fleet overview query this view. +-- ============================================================ +CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS +WITH latest_pruefungen AS ( + -- For each (fahrzeug, pruefung_art) pair, pick the row with the + -- latest faellig_am that is NOT yet completed (durchgefuehrt_am IS NULL), + -- OR if all are completed, the one with the highest naechste_faelligkeit. + SELECT DISTINCT ON (fahrzeug_id, pruefung_art) + fahrzeug_id, + pruefung_art, + id AS pruefung_id, + faellig_am, + durchgefuehrt_am, + ergebnis, + naechste_faelligkeit, + pruefende_stelle, + CURRENT_DATE - faellig_am::date AS tage_ueberfaellig, + faellig_am::date - CURRENT_DATE AS tage_bis_faelligkeit + FROM fahrzeug_pruefungen + ORDER BY + fahrzeug_id, + pruefung_art, + -- Open inspections (nicht durchgeführt) first, then most recent + (durchgefuehrt_am IS NULL) DESC, + faellig_am DESC +) +SELECT + f.id, + f.bezeichnung, + f.kurzname, + f.amtliches_kennzeichen, + f.fahrgestellnummer, + f.baujahr, + f.hersteller, + f.typ_schluessel, + f.besatzung_soll, + f.status, + f.status_bemerkung, + f.standort, + f.bild_url, + f.created_at, + f.updated_at, + -- HU + hu.pruefung_id AS hu_pruefung_id, + hu.faellig_am AS hu_faellig_am, + hu.tage_bis_faelligkeit AS hu_tage_bis_faelligkeit, + hu.ergebnis AS hu_ergebnis, + -- AU + au.pruefung_id AS au_pruefung_id, + au.faellig_am AS au_faellig_am, + au.tage_bis_faelligkeit AS au_tage_bis_faelligkeit, + au.ergebnis AS au_ergebnis, + -- UVV + uvv.pruefung_id AS uvv_pruefung_id, + uvv.faellig_am AS uvv_faellig_am, + uvv.tage_bis_faelligkeit AS uvv_tage_bis_faelligkeit, + uvv.ergebnis AS uvv_ergebnis, + -- Leiter (DLK only) + leiter.pruefung_id AS leiter_pruefung_id, + leiter.faellig_am AS leiter_faellig_am, + leiter.tage_bis_faelligkeit AS leiter_tage_bis_faelligkeit, + leiter.ergebnis AS leiter_ergebnis, + -- Overall worst tage_bis_faelligkeit across all active inspections + LEAST( + hu.tage_bis_faelligkeit, + au.tage_bis_faelligkeit, + uvv.tage_bis_faelligkeit, + leiter.tage_bis_faelligkeit + ) AS naechste_pruefung_tage +FROM + fahrzeuge f + LEFT JOIN latest_pruefungen hu ON hu.fahrzeug_id = f.id AND hu.pruefung_art = 'HU' + LEFT JOIN latest_pruefungen au ON au.fahrzeug_id = f.id AND au.pruefung_art = 'AU' + LEFT JOIN latest_pruefungen uvv ON uvv.fahrzeug_id = f.id AND uvv.pruefung_art = 'UVV' + LEFT JOIN latest_pruefungen leiter ON leiter.fahrzeug_id = f.id AND leiter.pruefung_art = 'Leiter'; + + +-- ============================================================ +-- SEED DATA: 3 typical German Feuerwehr vehicles +-- ============================================================ +DO $$ +DECLARE + v_lf10_id UUID := uuid_generate_v4(); + v_hlf20_id UUID := uuid_generate_v4(); + v_mtf_id UUID := uuid_generate_v4(); +BEGIN + -- Only insert if no vehicles exist yet (idempotent seed) + IF (SELECT COUNT(*) FROM fahrzeuge) = 0 THEN + + -- 1) LF 10 – Standard Löschgruppenfahrzeug + INSERT INTO fahrzeuge ( + id, bezeichnung, kurzname, amtliches_kennzeichen, + fahrgestellnummer, baujahr, hersteller, typ_schluessel, + besatzung_soll, status, standort + ) VALUES ( + v_lf10_id, + 'LF 10', + 'LF 1', + 'WN-FW 1', + 'WDB9634031L123456', + 2018, + 'Mercedes-Benz Atego', + 'LF 10', + '1/8', + 'einsatzbereit', + 'Feuerwehrhaus' + ); + + -- LF 10 inspections + INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES + (v_lf10_id, 'HU', '2026-03-15', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'), + (v_lf10_id, 'AU', '2026-04-01', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'), + (v_lf10_id, 'UVV', '2025-11-30', '2025-11-28', 'bestanden', '2026-11-28', 'DGUV Prüfer Rems'); + + -- LF 10 maintenance log + INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES + (v_lf10_id, '2025-11-28', 'Inspektion', 'Jahresinspektion nach Herstellervorgabe, Öl- und Filterwechsel, Bremsenprüfung', 48320, NULL, 420.00), + (v_lf10_id, '2025-10-15', 'Kraftstoff', 'Betankung nach Einsatz Feuerwehr Rems', 48150, 85.4, 145.18), + (v_lf10_id, '2025-09-01', 'Reifenwechsel','Sommerreifen auf Winterreifen gewechselt, alle 4 Reifen erneuert (Continental)', 47800, NULL, 980.00); + + -- 2) HLF 20/16 – Hilfeleistungslöschgruppenfahrzeug (flagship) + INSERT INTO fahrzeuge ( + id, bezeichnung, kurzname, amtliches_kennzeichen, + fahrgestellnummer, baujahr, hersteller, typ_schluessel, + besatzung_soll, status, standort + ) VALUES ( + v_hlf20_id, + 'HLF 20/16', + 'HLF 1', + 'WN-FW 2', + 'WMAN29ZZ3LM654321', + 2020, + 'MAN TGM / Rosenbauer', + 'HLF 20/16', + '1/8', + 'einsatzbereit', + 'Feuerwehrhaus' + ); + + -- HLF 20 inspections — all current + INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES + (v_hlf20_id, 'HU', '2026-08-20', NULL, 'ausstehend', NULL, 'DEKRA Esslingen'), + (v_hlf20_id, 'AU', '2026-02-01', '2026-01-28', 'bestanden', '2027-01-28', 'DEKRA Esslingen'), + (v_hlf20_id, 'UVV', '2026-01-15', '2026-01-14', 'bestanden', '2027-01-14', 'DGUV Prüfer Rems'); + + -- HLF 20 maintenance log + INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES + (v_hlf20_id, '2026-01-28', 'Hauptuntersuchung', 'AU bestanden ohne Mängel bei DEKRA Esslingen', 22450, NULL, 185.00), + (v_hlf20_id, '2026-01-14', 'Inspektion', 'UVV-Prüfung bestanden, Licht und Bremsen geprüft', 22430, NULL, 0.00), + (v_hlf20_id, '2025-12-10', 'Kraftstoff', 'Betankung nach Übung', 22300, 120.0, 204.00), + (v_hlf20_id, '2025-10-05', 'Reparatur', 'Hydraulikpumpe für Rettungssatz getauscht', 21980, NULL, 2340.00); + + -- 3) MTF – Mannschaftstransportfahrzeug + INSERT INTO fahrzeuge ( + id, bezeichnung, kurzname, amtliches_kennzeichen, + fahrgestellnummer, baujahr, hersteller, typ_schluessel, + besatzung_soll, status, status_bemerkung, standort + ) VALUES ( + v_mtf_id, + 'MTF', + 'MTF 1', + 'WN-FW 5', + 'WDB9066371S789012', + 2015, + 'Mercedes-Benz Sprinter', + 'MTF', + '1/8', + 'ausser_dienst_wartung', + 'Geplante Inspektion: Zahnriemenwechsel 60.000 km fällig', + 'Feuerwehrhaus' + ); + + -- MTF inspections — HU overdue (safety-critical test data) + INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES + (v_mtf_id, 'HU', '2026-01-31', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'), + (v_mtf_id, 'AU', '2025-06-15', '2025-06-12', 'bestanden', '2026-06-12', 'TÜV Süd Stuttgart'), + (v_mtf_id, 'UVV', '2025-12-01', '2025-11-30', 'bestanden_mit_maengeln', '2026-11-30', 'DGUV Prüfer Rems'); + + -- MTF maintenance log + INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES + (v_mtf_id, '2025-11-30', 'Inspektion', 'UVV-Prüfung: kleiner Mangel Innenbeleuchtung, nachgebessert', 58920, NULL, 0.00), + (v_mtf_id, '2025-11-01', 'Kraftstoff', 'Betankung regulär', 58700, 65.0, 110.50), + (v_mtf_id, '2025-09-20', 'Reparatur', 'Heckleuchte links defekt, Glühbirne getauscht', 58400, NULL, 12.50); + + END IF; +END +$$; + +-- --------------------------------------------------------------------------- +-- Cross-migration FK: add fahrzeug_id FK to einsatz_fahrzeuge (created in 004) +-- This runs only if einsatz_fahrzeuge exists but the FK is not yet present. +-- Handles the case where 004 ran before 005 and deferred the FK creation. +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'einsatz_fahrzeuge' + ) + AND NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'einsatz_fahrzeuge' + AND constraint_name = 'fk_ef_fahrzeug' + ) THEN + ALTER TABLE einsatz_fahrzeuge + ADD CONSTRAINT fk_ef_fahrzeug + FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE; + RAISE NOTICE 'Added fk_ef_fahrzeug FK on einsatz_fahrzeuge'; + END IF; +END $$; diff --git a/backend/src/database/migrations/006_create_uebungen.sql b/backend/src/database/migrations/006_create_uebungen.sql new file mode 100644 index 0000000..f7a75b9 --- /dev/null +++ b/backend/src/database/migrations/006_create_uebungen.sql @@ -0,0 +1,201 @@ +-- ============================================================================= +-- Migration 006: Übungsplanung & Dienstkalender +-- Training Schedule & Service Calendar for Feuerwehr Rems Dashboard +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 1. Calendar token table for iCal subscribe URLs (no auth required per-request) +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS calendar_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(64) UNIQUE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ +); + +CREATE INDEX idx_calendar_tokens_token ON calendar_tokens(token); +CREATE INDEX idx_calendar_tokens_user_id ON calendar_tokens(user_id); + +-- ----------------------------------------------------------------------------- +-- 2. Main events table +-- ----------------------------------------------------------------------------- +CREATE TYPE uebung_typ AS ENUM ( + 'Übungsabend', + 'Lehrgang', + 'Sonderdienst', + 'Versammlung', + 'Gemeinschaftsübung', + 'Sonstiges' +); + +CREATE TABLE uebungen ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + titel VARCHAR(255) NOT NULL, + beschreibung TEXT, + typ uebung_typ NOT NULL, + datum_von TIMESTAMPTZ NOT NULL, + datum_bis TIMESTAMPTZ NOT NULL, + ort VARCHAR(255), + treffpunkt VARCHAR(255), + pflichtveranstaltung BOOLEAN NOT NULL DEFAULT FALSE, + mindest_teilnehmer INT CHECK (mindest_teilnehmer > 0), + max_teilnehmer INT CHECK (max_teilnehmer > 0), + angelegt_von UUID REFERENCES users(id) ON DELETE SET NULL, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + abgesagt BOOLEAN NOT NULL DEFAULT FALSE, + absage_grund TEXT, + + CONSTRAINT datum_reihenfolge CHECK (datum_bis >= datum_von), + CONSTRAINT max_groesser_min CHECK ( + max_teilnehmer IS NULL OR + mindest_teilnehmer IS NULL OR + max_teilnehmer >= mindest_teilnehmer + ) +); + +CREATE INDEX idx_uebungen_datum_von ON uebungen(datum_von); +CREATE INDEX idx_uebungen_typ ON uebungen(typ); +CREATE INDEX idx_uebungen_pflichtveranstaltung ON uebungen(pflichtveranstaltung) WHERE pflichtveranstaltung = TRUE; +CREATE INDEX idx_uebungen_abgesagt ON uebungen(abgesagt) WHERE abgesagt = FALSE; +-- Compound index for the most common calendar-range query +CREATE INDEX idx_uebungen_datum_von_bis ON uebungen(datum_von, datum_bis); + +-- Keep aktualisiert_am in sync via trigger (reuse function from migration 001) +CREATE TRIGGER update_uebungen_aktualisiert_am + BEFORE UPDATE ON uebungen + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ----------------------------------------------------------------------------- +-- 3. Attendance / RSVP table +-- ----------------------------------------------------------------------------- +CREATE TYPE teilnahme_status AS ENUM ( + 'zugesagt', + 'abgesagt', + 'erschienen', + 'entschuldigt', + 'unbekannt' +); + +CREATE TABLE uebung_teilnahmen ( + uebung_id UUID NOT NULL REFERENCES uebungen(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status teilnahme_status NOT NULL DEFAULT 'unbekannt', + antwort_am TIMESTAMPTZ, + erschienen_erfasst_am TIMESTAMPTZ, + erschienen_erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL, + bemerkung VARCHAR(500), + + PRIMARY KEY (uebung_id, user_id) +); + +CREATE INDEX idx_teilnahmen_uebung_id ON uebung_teilnahmen(uebung_id); +CREATE INDEX idx_teilnahmen_user_id ON uebung_teilnahmen(user_id); +CREATE INDEX idx_teilnahmen_status ON uebung_teilnahmen(status); + +-- ----------------------------------------------------------------------------- +-- 4. Trigger: auto-create 'unbekannt' rows for all active members on new event +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION fn_create_teilnahmen_for_all_active_members() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO uebung_teilnahmen (uebung_id, user_id, status) + SELECT NEW.id, u.id, 'unbekannt' + FROM users u + WHERE u.is_active = TRUE + ON CONFLICT (uebung_id, user_id) DO NOTHING; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_auto_teilnahmen_after_insert + AFTER INSERT ON uebungen + FOR EACH ROW + EXECUTE FUNCTION fn_create_teilnahmen_for_all_active_members(); + +-- ----------------------------------------------------------------------------- +-- 5. Trigger: when a new member becomes active, add them to all future events +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION fn_add_member_to_future_events() +RETURNS TRIGGER AS $$ +BEGIN + -- Only run when is_active transitions FALSE -> TRUE + IF (OLD.is_active = FALSE OR OLD.is_active IS NULL) AND NEW.is_active = TRUE THEN + INSERT INTO uebung_teilnahmen (uebung_id, user_id, status) + SELECT u.id, NEW.id, 'unbekannt' + FROM uebungen u + WHERE u.datum_von > NOW() + AND u.abgesagt = FALSE + ON CONFLICT (uebung_id, user_id) DO NOTHING; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_add_member_to_future_events + AFTER UPDATE OF is_active ON users + FOR EACH ROW + EXECUTE FUNCTION fn_add_member_to_future_events(); + +-- ----------------------------------------------------------------------------- +-- 6. Convenience view: event overview with attendance counts +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE VIEW uebung_uebersicht AS +SELECT + u.id, + u.titel, + u.typ, + u.datum_von, + u.datum_bis, + u.ort, + u.treffpunkt, + u.pflichtveranstaltung, + u.mindest_teilnehmer, + u.max_teilnehmer, + u.abgesagt, + u.absage_grund, + u.angelegt_von, + u.erstellt_am, + u.aktualisiert_am, + -- Attendance aggregates + COUNT(t.user_id) AS gesamt_eingeladen, + COUNT(t.user_id) FILTER (WHERE t.status = 'zugesagt') AS anzahl_zugesagt, + COUNT(t.user_id) FILTER (WHERE t.status = 'abgesagt') AS anzahl_abgesagt, + COUNT(t.user_id) FILTER (WHERE t.status = 'erschienen') AS anzahl_erschienen, + COUNT(t.user_id) FILTER (WHERE t.status = 'entschuldigt') AS anzahl_entschuldigt, + COUNT(t.user_id) FILTER (WHERE t.status = 'unbekannt') AS anzahl_unbekannt +FROM uebungen u +LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id +GROUP BY u.id; + +-- ----------------------------------------------------------------------------- +-- 7. View: per-member participation statistics (feeds Tier 3 reporting) +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE VIEW member_participation_stats AS +SELECT + usr.id AS user_id, + COALESCE(usr.name, usr.preferred_username, usr.email) AS name, + COUNT(t.uebung_id) AS total_eingeladen, + COUNT(t.uebung_id) FILTER (WHERE t.status = 'erschienen') AS total_erschienen, + COUNT(t.uebung_id) FILTER ( + WHERE u.pflichtveranstaltung = TRUE AND t.status = 'erschienen' + ) AS pflicht_erschienen, + COUNT(t.uebung_id) FILTER (WHERE u.pflichtveranstaltung = TRUE) AS pflicht_gesamt, + ROUND( + CASE + WHEN COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') = 0 THEN 0 + ELSE + COUNT(t.uebung_id) FILTER ( + WHERE u.typ = 'Übungsabend' AND t.status = 'erschienen' + )::NUMERIC / + COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') * 100 + END, 1 + ) AS uebungsabend_quote_pct +FROM users usr +JOIN uebung_teilnahmen t ON t.user_id = usr.id +JOIN uebungen u ON u.id = t.uebung_id +WHERE usr.is_active = TRUE + AND u.abgesagt = FALSE +GROUP BY usr.id, usr.name, usr.preferred_username, usr.email; diff --git a/backend/src/database/seeds/einsaetze_test_data.sql b/backend/src/database/seeds/einsaetze_test_data.sql new file mode 100644 index 0000000..fe0a7e9 --- /dev/null +++ b/backend/src/database/seeds/einsaetze_test_data.sql @@ -0,0 +1,197 @@ +-- ============================================================================= +-- TEST DATA: 5 sample incidents for development and manual testing +-- Run AFTER all migrations have been applied. +-- Assumes at least one user exists in the users table. +-- Replace the user UUIDs with real values from your dev database. +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- Step 1: Insert two sample vehicles (if not already present) +-- Uses column names from 005_create_fahrzeuge.sql +-- --------------------------------------------------------------------------- +INSERT INTO fahrzeuge (id, bezeichnung, kurzname, amtliches_kennzeichen, baujahr, hersteller, typ_schluessel, besatzung_soll, status) +VALUES + ('a1000000-0000-0000-0000-000000000001', 'HLF 20 - Hilfeleistungslöschgruppenfahrzeug', 'HLF TEST', 'WN-FW 1234', 2020, 'MAN / Rosenbauer', 'HLF 20/16', '1/8', 'einsatzbereit'), + ('a1000000-0000-0000-0000-000000000002', 'TLF 3000 - Tanklöschfahrzeug', 'TLF TEST', 'WN-FW 5678', 2018, 'Mercedes-Benz Atego', 'TLF 3000', '1/5', 'einsatzbereit') +ON CONFLICT (id) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- Step 2: Helper — get the first active user ID (replace with real UUID in prod) +-- We use a DO block to make the script re-runnable and dev-environment agnostic. +-- --------------------------------------------------------------------------- +DO $$ +DECLARE + v_user_id UUID; + v_einsatz_id1 UUID := 'b1000000-0000-0000-0000-000000000001'; + v_einsatz_id2 UUID := 'b1000000-0000-0000-0000-000000000002'; + v_einsatz_id3 UUID := 'b1000000-0000-0000-0000-000000000003'; + v_einsatz_id4 UUID := 'b1000000-0000-0000-0000-000000000004'; + v_einsatz_id5 UUID := 'b1000000-0000-0000-0000-000000000005'; + v_fahrzeug1 UUID := 'a1000000-0000-0000-0000-000000000001'; + v_fahrzeug2 UUID := 'a1000000-0000-0000-0000-000000000002'; +BEGIN + SELECT id INTO v_user_id FROM users WHERE is_active = TRUE LIMIT 1; + IF v_user_id IS NULL THEN + RAISE NOTICE 'No active user found — skipping test data insert.'; + RETURN; + END IF; + + -- ------------------------------------------------------------------------- + -- EINSATZ 1: Wohnungsbrand (Brand B3) — January — abgeschlossen + -- ------------------------------------------------------------------------- + INSERT INTO einsaetze ( + id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time, + einsatz_art, einsatz_stichwort, strasse, hausnummer, ort, + bericht_kurz, bericht_text, + einsatzleiter_id, alarmierung_art, status, created_by + ) VALUES ( + v_einsatz_id1, + generate_einsatz_nr('2026-01-14 14:32:00+01'::TIMESTAMPTZ), + '2026-01-14 14:32:00+01', + '2026-01-14 14:35:00+01', + '2026-01-14 14:41:00+01', + '2026-01-14 17:15:00+01', + 'Brand', 'B3', + 'Hauptstraße', '42', 'Berglen', + 'Wohnungsbrand im 2. OG, 1 Person gerettet', + 'Alarm um 14:32 Uhr durch ILS. Bei Ankunft stand das zweite Obergeschoss in Flammen. ' || + 'Eine Person wurde über die Drehleiter gerettet und dem Rettungsdienst übergeben. ' || + 'Brandbekämpfung mit zwei C-Rohren. Nachlöscharbeiten bis 17:00 Uhr.', + v_user_id, 'ILS', 'abgeschlossen', v_user_id + ) ON CONFLICT (id) DO NOTHING; + + -- ------------------------------------------------------------------------- + -- EINSATZ 2: Verkehrsunfall (THL 2) — Februar — abgeschlossen + -- ------------------------------------------------------------------------- + INSERT INTO einsaetze ( + id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time, + einsatz_art, einsatz_stichwort, strasse, hausnummer, ort, + bericht_kurz, bericht_text, + einsatzleiter_id, alarmierung_art, status, created_by + ) VALUES ( + v_einsatz_id2, + generate_einsatz_nr('2026-02-03 08:17:00+01'::TIMESTAMPTZ), + '2026-02-03 08:17:00+01', + '2026-02-03 08:20:00+01', + '2026-02-03 08:26:00+01', + '2026-02-03 10:45:00+01', + 'THL', 'THL 2', + 'Landesstraße 1115', NULL, 'Rudersberg', + 'Verkehrsunfall mit eingeklemmter Person, PKW gegen Baum', + 'PKW frontal gegen Baum. Fahrer eingeklemmt, Beifahrerin konnte selbstständig aussteigen. ' || + 'Technische Rettung mit Spreizer und Schere. Person nach ca. 25 Minuten befreit. ' || + 'Übergabe an den Rettungsdienst und Hubschrauber.', + v_user_id, 'ILS', 'abgeschlossen', v_user_id + ) ON CONFLICT (id) DO NOTHING; + + -- ------------------------------------------------------------------------- + -- EINSATZ 3: Brandmeldeanlage (BMA) — März — abgeschlossen (Fehlalarm) + -- ------------------------------------------------------------------------- + INSERT INTO einsaetze ( + id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time, + einsatz_art, einsatz_stichwort, strasse, hausnummer, ort, + bericht_kurz, + einsatzleiter_id, alarmierung_art, status, created_by + ) VALUES ( + v_einsatz_id3, + generate_einsatz_nr('2026-03-21 11:05:00+01'::TIMESTAMPTZ), + '2026-03-21 11:05:00+01', + '2026-03-21 11:08:00+01', + '2026-03-21 11:12:00+01', + '2026-03-21 11:35:00+01', + 'BMA', NULL, + 'Gewerbepark Rems', '7', 'Weinstadt', + 'BMA ausgelöst durch Staubentwicklung bei Bauarbeiten, kein Feuer', + v_user_id, 'ILS', 'abgeschlossen', v_user_id + ) ON CONFLICT (id) DO NOTHING; + + -- ------------------------------------------------------------------------- + -- EINSATZ 4: Ölspur (Hilfeleistung) — März — abgeschlossen + -- ------------------------------------------------------------------------- + INSERT INTO einsaetze ( + id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, einrueck_time, + einsatz_art, einsatz_stichwort, strasse, hausnummer, ort, + bericht_kurz, + einsatzleiter_id, alarmierung_art, status, created_by + ) VALUES ( + v_einsatz_id4, + generate_einsatz_nr('2026-03-28 16:45:00+01'::TIMESTAMPTZ), + '2026-03-28 16:45:00+01', + '2026-03-28 16:49:00+01', + '2026-03-28 16:54:00+01', + '2026-03-28 18:20:00+01', + 'Hilfeleistung', NULL, + 'Ortsdurchfahrt B29', NULL, 'Schorndorf', + 'Ölspur ca. 600m, LKW verlor Hydraulikflüssigkeit, Reinigung mit Ölbindemittel', + v_user_id, 'ILS', 'abgeschlossen', v_user_id + ) ON CONFLICT (id) DO NOTHING; + + -- ------------------------------------------------------------------------- + -- EINSATZ 5: Flächenbrand (Brand B2) — aktiv (noch laufend heute) + -- ------------------------------------------------------------------------- + INSERT INTO einsaetze ( + id, einsatz_nr, alarm_time, ausrueck_time, ankunft_time, + einsatz_art, einsatz_stichwort, strasse, hausnummer, ort, + bericht_kurz, + einsatzleiter_id, alarmierung_art, status, created_by + ) VALUES ( + v_einsatz_id5, + generate_einsatz_nr(NOW()), + NOW() - INTERVAL '45 minutes', + NOW() - INTERVAL '42 minutes', + NOW() - INTERVAL '37 minutes', + 'Brand', 'B2', + 'Waldweg', NULL, 'Alfdorf', + 'Flächenbrand ca. 0,5 ha Unterholz, Wasserversorgung über Pendelverkehr', + v_user_id, 'ILS', 'aktiv', v_user_id + ) ON CONFLICT (id) DO NOTHING; + + -- ------------------------------------------------------------------------- + -- VEHICLES — assign to incidents + -- ------------------------------------------------------------------------- + INSERT INTO einsatz_fahrzeuge (einsatz_id, fahrzeug_id, ausrueck_time, einrueck_time) + VALUES + (v_einsatz_id1, v_fahrzeug1, '2026-01-14 14:35:00+01', '2026-01-14 17:15:00+01'), + (v_einsatz_id1, v_fahrzeug2, '2026-01-14 14:36:00+01', '2026-01-14 17:10:00+01'), + (v_einsatz_id2, v_fahrzeug1, '2026-02-03 08:20:00+01', '2026-02-03 10:45:00+01'), + (v_einsatz_id4, v_fahrzeug1, '2026-03-28 16:49:00+01', '2026-03-28 18:20:00+01'), + (v_einsatz_id5, v_fahrzeug1, NOW() - INTERVAL '42 minutes', NULL), + (v_einsatz_id5, v_fahrzeug2, NOW() - INTERVAL '40 minutes', NULL) + ON CONFLICT (einsatz_id, fahrzeug_id) DO NOTHING; + + -- ------------------------------------------------------------------------- + -- PERSONNEL — assign the single test user to each incident + -- ------------------------------------------------------------------------- + INSERT INTO einsatz_personal (einsatz_id, user_id, funktion, alarm_time, ankunft_time) + VALUES + (v_einsatz_id1, v_user_id, 'Einsatzleiter', '2026-01-14 14:32:00+01', '2026-01-14 14:41:00+01'), + (v_einsatz_id2, v_user_id, 'Gruppenführer', '2026-02-03 08:17:00+01', '2026-02-03 08:26:00+01'), + (v_einsatz_id3, v_user_id, 'Einsatzleiter', '2026-03-21 11:05:00+01', '2026-03-21 11:12:00+01'), + (v_einsatz_id4, v_user_id, 'Maschinist', '2026-03-28 16:45:00+01', '2026-03-28 16:54:00+01'), + (v_einsatz_id5, v_user_id, 'Einsatzleiter', NOW() - INTERVAL '45 minutes', NOW() - INTERVAL '37 minutes') + ON CONFLICT (einsatz_id, user_id) DO NOTHING; + + RAISE NOTICE 'Test data inserted successfully for user %', v_user_id; +END $$; + +-- --------------------------------------------------------------------------- +-- Refresh the materialized view after data load +-- --------------------------------------------------------------------------- +REFRESH MATERIALIZED VIEW einsatz_statistik; + +-- --------------------------------------------------------------------------- +-- Verify: quick sanity check +-- --------------------------------------------------------------------------- +SELECT + e.einsatz_nr, + e.einsatz_art, + TO_CHAR(e.alarm_time AT TIME ZONE 'Europe/Berlin', 'DD.MM.YYYY HH24:MI') AS alarm_de, + e.ort, + e.status, + COUNT(ep.user_id) AS kräfte, + COUNT(ef.fahrzeug_id) AS fahrzeuge +FROM einsaetze e +LEFT JOIN einsatz_personal ep ON ep.einsatz_id = e.id +LEFT JOIN einsatz_fahrzeuge ef ON ef.einsatz_id = e.id +GROUP BY e.id, e.einsatz_nr, e.einsatz_art, e.alarm_time, e.ort, e.status +ORDER BY e.alarm_time DESC; diff --git a/backend/src/jobs/audit-cleanup.job.ts b/backend/src/jobs/audit-cleanup.job.ts new file mode 100644 index 0000000..5ea3e2d --- /dev/null +++ b/backend/src/jobs/audit-cleanup.job.ts @@ -0,0 +1,114 @@ +/** + * Audit Cleanup Job + * + * Runs the GDPR IP anonymisation task on a schedule. + * Must be started once from server.ts during application boot. + * + * Two scheduling options are shown: + * 1. node-cron (recommended) — cron expression, survives DST changes + * 2. setInterval (fallback) — simpler, no extra dependency + * + * Install node-cron if using option 1: + * npm install node-cron + * npm install --save-dev @types/node-cron + */ + +import auditService from '../services/audit.service'; +import logger from '../utils/logger'; + +// --------------------------------------------------------------------------- +// Option 1 (RECOMMENDED): node-cron +// +// Runs every day at 02:00 local server time. +// The cron expression format is: second(optional) minute hour dom month dow +// +// Uncomment this block and comment out the setInterval block below. +// --------------------------------------------------------------------------- + +/* +import cron from 'node-cron'; + +let cronJob: cron.ScheduledTask | null = null; + +export function startAuditCleanupJob(): void { + if (cronJob) { + logger.warn('Audit cleanup job already running — skipping duplicate start'); + return; + } + + // '0 2 * * *' = every day at 02:00 + cronJob = cron.schedule('0 2 * * *', async () => { + logger.info('Audit cleanup job: starting IP anonymisation'); + await auditService.anonymizeOldIpAddresses(); + logger.info('Audit cleanup job: IP anonymisation complete'); + }, { + scheduled: true, + timezone: 'Europe/Vienna', // Feuerwehr Rems is in Austria + }); + + logger.info('Audit cleanup job scheduled (node-cron, daily at 02:00 Europe/Vienna)'); +} + +export function stopAuditCleanupJob(): void { + if (cronJob) { + cronJob.stop(); + cronJob = null; + logger.info('Audit cleanup job stopped'); + } +} +*/ + +// --------------------------------------------------------------------------- +// Option 2 (FALLBACK): setInterval +// +// Runs every 24 hours from the moment the server starts. +// Simpler — no extra dependency — but does not align to wall-clock time +// and will drift if the server restarts frequently. +// --------------------------------------------------------------------------- + +const INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours + +let cleanupInterval: ReturnType | null = null; + +export function startAuditCleanupJob(): void { + if (cleanupInterval !== null) { + logger.warn('Audit cleanup job already running — skipping duplicate start'); + return; + } + + // Run once on startup to catch any backlog, then repeat on schedule. + // We intentionally do not await — startup must not be blocked. + runAuditCleanup(); + + cleanupInterval = setInterval(() => { + runAuditCleanup(); + }, INTERVAL_MS); + + logger.info('Audit cleanup job scheduled (setInterval, 24h interval)'); +} + +export function stopAuditCleanupJob(): void { + if (cleanupInterval !== null) { + clearInterval(cleanupInterval); + cleanupInterval = null; + logger.info('Audit cleanup job stopped'); + } +} + +// --------------------------------------------------------------------------- +// Core cleanup function — called by both scheduler options +// --------------------------------------------------------------------------- + +export async function runAuditCleanup(): Promise { + try { + logger.debug('Audit cleanup: running IP anonymisation'); + await auditService.anonymizeOldIpAddresses(); + } catch (error) { + // anonymizeOldIpAddresses() swallows its own errors, but we add a second + // layer of protection here so that a scheduler implementation mistake + // cannot crash the process. + logger.error('Audit cleanup job: unexpected error', { + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/backend/src/middleware/audit.middleware.ts b/backend/src/middleware/audit.middleware.ts new file mode 100644 index 0000000..f97c991 --- /dev/null +++ b/backend/src/middleware/audit.middleware.ts @@ -0,0 +1,235 @@ +/** + * Audit Middleware + * + * Factory function that returns an Express middleware for a given + * (resourceType, action) pair. Designed to sit after `authenticate` + * in the middleware chain. + * + * Usage examples: + * + * // Simple: no old_value needed + * router.post('/', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.CREATE), createMember); + * + * // UPDATE: controller must populate res.locals.auditOldValue before responding + * router.put('/:id', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.UPDATE), updateMember); + * + * // DELETE: controller must populate res.locals.auditOldValue (the entity before deletion) + * router.delete('/:id', authenticate, auditMiddleware(AuditResourceType.MEMBER, AuditAction.DELETE), deleteMember); + * + * Pattern for capturing old_value in controllers: + * + * async function updateMember(req: Request, res: Response): Promise { + * const existing = await memberService.findById(req.params.id); + * res.locals.auditOldValue = existing; // <-- set BEFORE modifying + * + * const updated = await memberService.update(req.params.id, req.body); + * res.locals.auditResourceId = req.params.id; + * + * res.status(200).json({ success: true, data: updated }); + * // The middleware intercepts res.json() and logs AFTER the response is sent. + * } + * + * For PERMISSION_DENIED events, use auditPermissionDenied() directly in the + * requirePermission middleware — see auth.middleware.ts. + */ + +import { Request, Response, NextFunction } from 'express'; +import auditService, { AuditAction, AuditResourceType } from '../services/audit.service'; +import logger from '../utils/logger'; + +// --------------------------------------------------------------------------- +// IP extraction helper +// --------------------------------------------------------------------------- + +/** + * Extracts the real client IP address, respecting X-Forwarded-For when set + * by a trusted reverse proxy (Nginx / Traefik in the Docker stack). + * + * Trust only the leftmost IP in X-Forwarded-For to avoid spoofing. + * If you run without a reverse proxy, req.ip is sufficient. + */ +export function extractIp(req: Request): string | null { + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) { + const ips = (typeof forwarded === 'string' ? forwarded : forwarded[0]).split(','); + const ip = ips[0].trim(); + return ip || req.ip || null; + } + return req.ip || null; +} + +/** + * Extracts User-Agent, truncated to 500 chars to avoid bloating the DB. + */ +export function extractUserAgent(req: Request): string | null { + const ua = req.headers['user-agent']; + if (!ua) return null; + return ua.substring(0, 500); +} + +// --------------------------------------------------------------------------- +// res.locals contract (populated by controllers before res.json()) +// --------------------------------------------------------------------------- + +/** + * Extend Express locals so TypeScript knows what the audit middleware reads. + * + * Controllers set these before calling res.json() / res.status().json(): + * res.locals.auditOldValue — entity state before the operation (UPDATE, DELETE) + * res.locals.auditNewValue — override for new value (optional; defaults to response body) + * res.locals.auditResourceId — override for resource ID (optional; defaults to req.params.id) + * res.locals.auditMetadata — any extra context object + * res.locals.auditSkip — set to true to suppress logging for this request + */ +declare global { + namespace Express { + interface Locals { + auditOldValue?: Record | null; + auditNewValue?: Record | null; + auditResourceId?: string; + auditMetadata?: Record; + auditSkip?: boolean; + } + } +} + +// --------------------------------------------------------------------------- +// Middleware factory +// --------------------------------------------------------------------------- + +/** + * auditMiddleware — returns Express middleware that fires an audit log entry + * after the response has been sent (fire-and-forget; never delays the response). + * + * @param resourceType The type of entity being operated on. + * @param action The action being performed. + */ +export function auditMiddleware( + resourceType: AuditResourceType, + action: AuditAction +) { + return function auditHandler( + req: Request, + res: Response, + next: NextFunction + ): void { + // Intercept res.json() so we can capture the response body after it is + // set, without delaying the response to the client. + const originalJson = res.json.bind(res); + + res.json = function auditInterceptedJson(body: unknown): Response { + // Call the original json() — this sends the response immediately. + const result = originalJson(body); + + // Only log on successful responses (2xx). Failed requests are not + // meaningful audit events for data mutations (the data did not change). + if (res.statusCode >= 200 && res.statusCode < 300 && !res.locals.auditSkip) { + const userId = req.user?.id ?? null; + const userEmail = req.user?.email ?? null; + const ipAddress = extractIp(req); + const userAgent = extractUserAgent(req); + + // Resource ID: prefer controller override, then route param, then body id. + const resourceId = + res.locals.auditResourceId ?? + req.params.id ?? + (body !== null && typeof body === 'object' + ? (body as Record)?.data?.id as string | undefined + : undefined) ?? + null; + + // old_value: always from res.locals (controller must set it for UPDATE/DELETE). + const oldValue = res.locals.auditOldValue ?? null; + + // new_value: prefer controller override, else use the response body data. + // We extract the `data` property if it exists (matches our { success, data } envelope). + let newValue: Record | null = res.locals.auditNewValue ?? null; + if (newValue === null && action !== AuditAction.DELETE) { + const bodyObj = body as Record | null; + newValue = + (bodyObj?.data as Record | undefined) ?? + (typeof bodyObj === 'object' && bodyObj !== null ? bodyObj : null); + } + + // Fire-and-forget — never await in a middleware. + auditService.logAudit({ + user_id: userId, + user_email: userEmail, + action, + resource_type: resourceType, + resource_id: resourceId ? String(resourceId) : null, + old_value: oldValue, + new_value: newValue, + ip_address: ipAddress, + user_agent: userAgent, + metadata: res.locals.auditMetadata ?? {}, + }).catch((err) => { + // Belt-and-suspenders: logAudit() never rejects, but just in case. + logger.error('auditMiddleware: unexpected logAudit rejection', { err }); + }); + } + + return result; + }; + + next(); + }; +} + +// --------------------------------------------------------------------------- +// Standalone helpers for use outside of route middleware chains +// --------------------------------------------------------------------------- + +/** + * auditPermissionDenied + * + * Call this inside requirePermission() when access is denied. + * Does NOT depend on res.json interception — fires directly. + */ +export function auditPermissionDenied( + req: Request, + resourceType: AuditResourceType, + resourceId?: string, + metadata?: Record +): void { + auditService.logAudit({ + user_id: req.user?.id ?? null, + user_email: req.user?.email ?? null, + action: AuditAction.PERMISSION_DENIED, + resource_type: resourceType, + resource_id: resourceId ?? null, + old_value: null, + new_value: null, + ip_address: extractIp(req), + user_agent: extractUserAgent(req), + metadata: { + attempted_path: req.path, + attempted_method: req.method, + ...metadata, + }, + }).catch(() => {/* swallowed — audit must not affect the 403 response */}); +} + +/** + * auditExport + * + * Call this immediately before streaming an export response. + */ +export function auditExport( + req: Request, + resourceType: AuditResourceType, + metadata: Record = {} +): void { + auditService.logAudit({ + user_id: req.user?.id ?? null, + user_email: req.user?.email ?? null, + action: AuditAction.EXPORT, + resource_type: resourceType, + resource_id: null, + old_value: null, + new_value: null, + ip_address: extractIp(req), + user_agent: extractUserAgent(req), + metadata, + }).catch(() => {}); +} diff --git a/backend/src/middleware/rbac.middleware.ts b/backend/src/middleware/rbac.middleware.ts new file mode 100644 index 0000000..211d875 --- /dev/null +++ b/backend/src/middleware/rbac.middleware.ts @@ -0,0 +1,136 @@ +import { Request, Response, NextFunction } from 'express'; +import pool from '../config/database'; +import logger from '../utils/logger'; +import { auditPermissionDenied } from './audit.middleware'; +import { AuditResourceType } from '../services/audit.service'; + +// --------------------------------------------------------------------------- +// AppRole — mirrors the roles defined in the project spec. +// Tier 1 (RBAC) is assumed complete and adds a `role` column to users. +// This middleware reads that column to enforce permissions. +// --------------------------------------------------------------------------- +export type AppRole = + | 'admin' + | 'kommandant' + | 'gruppenfuehrer' + | 'mitglied' + | 'bewerber'; + +/** + * Role hierarchy: higher index = more permissions. + * Used to implement "at least X role" checks. + */ +const ROLE_HIERARCHY: AppRole[] = [ + 'bewerber', + 'mitglied', + 'gruppenfuehrer', + 'kommandant', + 'admin', +]; + +/** + * Permission map: defines which roles hold a given permission string. + * All roles at or above the listed minimum also hold the permission. + */ +const PERMISSION_ROLE_MIN: Record = { + 'incidents:read': 'mitglied', + 'incidents:write': 'gruppenfuehrer', + 'incidents:delete': 'kommandant', + 'incidents:read_bericht_text': 'kommandant', + 'incidents:manage_personnel': 'gruppenfuehrer', + // Training / Calendar + 'training:read': 'mitglied', + 'training:write': 'gruppenfuehrer', + 'training:cancel': 'kommandant', + 'training:mark_attendance': 'gruppenfuehrer', + 'reports:read': 'kommandant', + // Audit log and admin panel — restricted to admin role only + 'admin:access': 'admin', + 'audit:read': 'admin', + 'audit:export': 'admin', +}; + +function hasPermission(role: AppRole, permission: string): boolean { + const minRole = PERMISSION_ROLE_MIN[permission]; + if (!minRole) { + logger.warn('Unknown permission checked', { permission }); + return false; + } + const userLevel = ROLE_HIERARCHY.indexOf(role); + const minLevel = ROLE_HIERARCHY.indexOf(minRole); + return userLevel >= minLevel; +} + +/** + * Retrieves the role for a given user ID from the database. + * Falls back to 'mitglied' if the users table does not yet have a role column + * (graceful degradation while Tier 1 migration is pending). + */ +async function getUserRole(userId: string): Promise { + try { + const result = await pool.query( + `SELECT role FROM users WHERE id = $1`, + [userId] + ); + if (result.rows.length === 0) return 'mitglied'; + return (result.rows[0].role as AppRole) ?? 'mitglied'; + } catch (error) { + // If the column doesn't exist yet (Tier 1 not deployed), degrade gracefully + const errMsg = error instanceof Error ? error.message : String(error); + if (errMsg.includes('column "role" does not exist')) { + logger.warn('users.role column not found — Tier 1 RBAC migration pending. Defaulting to mitglied.'); + return 'mitglied'; + } + logger.error('Error fetching user role', { error, userId }); + return 'mitglied'; + } +} + +/** + * Middleware factory: requires the authenticated user to hold the given + * permission (or a role with sufficient hierarchy level). + * + * Usage: + * router.post('/api/incidents', authenticate, requirePermission('incidents:write'), handler) + */ +export function requirePermission(permission: string) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (!req.user) { + res.status(401).json({ + success: false, + message: 'Authentication required', + }); + return; + } + + const role = await getUserRole(req.user.id); + + // Attach role to request for downstream use (e.g., bericht_text redaction) + (req as Request & { userRole?: AppRole }).userRole = role; + + if (!hasPermission(role, permission)) { + logger.warn('Permission denied', { + userId: req.user.id, + role, + permission, + path: req.path, + }); + + // GDPR audit trail — fire-and-forget, never throws + auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, { + required_permission: permission, + user_role: role, + }); + + res.status(403).json({ + success: false, + message: `Keine Berechtigung: ${permission}`, + }); + return; + } + + next(); + }; +} + +export { getUserRole, hasPermission }; diff --git a/backend/src/models/incident.model.ts b/backend/src/models/incident.model.ts new file mode 100644 index 0000000..629e7af --- /dev/null +++ b/backend/src/models/incident.model.ts @@ -0,0 +1,261 @@ +import { z } from 'zod'; + +// --------------------------------------------------------------------------- +// ENUMS (as const arrays for runtime use + TypeScript types) +// --------------------------------------------------------------------------- + +export const EINSATZ_ARTEN = [ + 'Brand', + 'THL', + 'ABC', + 'BMA', + 'Hilfeleistung', + 'Fehlalarm', + 'Brandsicherheitswache', +] as const; + +export type EinsatzArt = (typeof EINSATZ_ARTEN)[number]; + +export const EINSATZ_ART_LABELS: Record = { + Brand: 'Brand', + THL: 'Technische Hilfeleistung', + ABC: 'ABC-Einsatz / Gefahrgut', + BMA: 'Brandmeldeanlage', + Hilfeleistung: 'Hilfeleistung', + Fehlalarm: 'Fehlalarm', + Brandsicherheitswache: 'Brandsicherheitswache', +}; + +export const EINSATZ_STATUS = ['aktiv', 'abgeschlossen', 'archiviert'] as const; + +export type EinsatzStatus = (typeof EINSATZ_STATUS)[number]; + +export const EINSATZ_STATUS_LABELS: Record = { + aktiv: 'Aktiv', + abgeschlossen: 'Abgeschlossen', + archiviert: 'Archiviert', +}; + +export const EINSATZ_FUNKTIONEN = [ + 'Einsatzleiter', + 'Gruppenführer', + 'Maschinist', + 'Atemschutz', + 'Sicherheitstrupp', + 'Melder', + 'Wassertrupp', + 'Angriffstrupp', + 'Mannschaft', + 'Sonstiges', +] as const; + +export type EinsatzFunktion = (typeof EINSATZ_FUNKTIONEN)[number]; + +export const ALARMIERUNG_ARTEN = [ + 'ILS', + 'DME', + 'Telefon', + 'Vor_Ort', + 'Sonstiges', +] as const; + +export type AlarmierungArt = (typeof ALARMIERUNG_ARTEN)[number]; + +export const ALARMIERUNG_ART_LABELS: Record = { + ILS: 'ILS (Integrierte Leitstelle)', + DME: 'Digitaler Meldeempfänger', + Telefon: 'Telefon', + Vor_Ort: 'Vor Ort', + Sonstiges: 'Sonstiges', +}; + +// --------------------------------------------------------------------------- +// DATABASE ROW INTERFACES +// --------------------------------------------------------------------------- + +/** Raw database row for einsaetze table */ +export interface Einsatz { + id: string; + einsatz_nr: string; + + alarm_time: Date; + ausrueck_time: Date | null; + ankunft_time: Date | null; + einrueck_time: Date | null; + + einsatz_art: EinsatzArt; + einsatz_stichwort: string | null; + + strasse: string | null; + hausnummer: string | null; + ort: string | null; + koordinaten: { x: number; y: number } | null; // pg returns POINT as {x,y} + + bericht_kurz: string | null; + bericht_text: string | null; + + einsatzleiter_id: string | null; + alarmierung_art: AlarmierungArt; + status: EinsatzStatus; + + created_by: string | null; + created_at: Date; + updated_at: Date; +} + +/** Assigned vehicle row */ +export interface EinsatzFahrzeug { + einsatz_id: string; + fahrzeug_id: string; + ausrueck_time: Date | null; + einrueck_time: Date | null; + assigned_at: Date; + // Joined fields — aliased to 'kennzeichen' and 'fahrzeug_typ' in SQL query + // to match the aliased SELECT in incident.service.ts + kennzeichen?: string | null; // aliased from amtliches_kennzeichen + bezeichnung?: string; + fahrzeug_typ?: string | null; // aliased from typ_schluessel +} + +/** Assigned personnel row */ +export interface EinsatzPersonal { + einsatz_id: string; + user_id: string; + funktion: EinsatzFunktion; + alarm_time: Date | null; + ankunft_time: Date | null; + assigned_at: Date; + // Joined fields + name?: string | null; + email?: string; + given_name?: string | null; + family_name?: string | null; +} + +// --------------------------------------------------------------------------- +// EXTENDED TYPES (joins / computed) +// --------------------------------------------------------------------------- + +/** Full incident with all related data — used by detail endpoint */ +export interface EinsatzWithDetails extends Einsatz { + einsatzleiter_name: string | null; + fahrzeuge: EinsatzFahrzeug[]; + personal: EinsatzPersonal[]; + /** Hilfsfrist in minutes (alarm → arrival), null if ankunft_time not set */ + hilfsfrist_min: number | null; + /** Total duration in minutes (alarm → return), null if einrueck_time not set */ + dauer_min: number | null; +} + +/** Lightweight row for the list view DataGrid */ +export interface EinsatzListItem { + id: string; + einsatz_nr: string; + alarm_time: Date; + einsatz_art: EinsatzArt; + einsatz_stichwort: string | null; + ort: string | null; + strasse: string | null; + status: EinsatzStatus; + einsatzleiter_name: string | null; + /** Hilfsfrist in minutes, null if ankunft_time not set */ + hilfsfrist_min: number | null; + /** Total duration in minutes, null if einrueck_time not set */ + dauer_min: number | null; + personal_count: number; +} + +// --------------------------------------------------------------------------- +// STATISTICS TYPES +// --------------------------------------------------------------------------- + +export interface MonthlyStatRow { + monat: number; // 1–12 + anzahl: number; + avg_hilfsfrist_min: number | null; + avg_dauer_min: number | null; +} + +export interface EinsatzArtStatRow { + einsatz_art: EinsatzArt; + anzahl: number; + avg_hilfsfrist_min: number | null; +} + +export interface EinsatzStats { + jahr: number; + gesamt: number; + abgeschlossen: number; + aktiv: number; + avg_hilfsfrist_min: number | null; + /** Einsatzart with the highest count */ + haeufigste_art: EinsatzArt | null; + monthly: MonthlyStatRow[]; + by_art: EinsatzArtStatRow[]; + /** Previous year monthly for chart overlay */ + prev_year_monthly: MonthlyStatRow[]; +} + +// --------------------------------------------------------------------------- +// ZOD VALIDATION SCHEMAS (Zod v4) +// --------------------------------------------------------------------------- + +export const CreateEinsatzSchema = z.object({ + alarm_time: z.string().datetime({ offset: true }), + ausrueck_time: z.string().datetime({ offset: true }).optional().nullable(), + ankunft_time: z.string().datetime({ offset: true }).optional().nullable(), + einrueck_time: z.string().datetime({ offset: true }).optional().nullable(), + + einsatz_art: z.enum(EINSATZ_ARTEN), + einsatz_stichwort: z.string().max(30).optional().nullable(), + + strasse: z.string().max(150).optional().nullable(), + hausnummer: z.string().max(20).optional().nullable(), + ort: z.string().max(100).optional().nullable(), + + bericht_kurz: z.string().max(255).optional().nullable(), + bericht_text: z.string().optional().nullable(), + + einsatzleiter_id: z.string().uuid().optional().nullable(), + alarmierung_art: z.enum(ALARMIERUNG_ARTEN).optional().default('ILS'), + status: z.enum(EINSATZ_STATUS).optional().default('aktiv'), +}); + +export type CreateEinsatzData = z.infer; + +export const UpdateEinsatzSchema = CreateEinsatzSchema.partial().omit({ + alarm_time: true, // alarm_time can be updated but is handled explicitly +}).extend({ + // alarm_time is allowed to be updated but must remain valid if provided + alarm_time: z.string().datetime({ offset: true }).optional(), +}); + +export type UpdateEinsatzData = z.infer; + +export const AssignPersonnelSchema = z.object({ + user_id: z.string().uuid(), + funktion: z.enum(EINSATZ_FUNKTIONEN).optional().default('Mannschaft'), + alarm_time: z.string().datetime({ offset: true }).optional().nullable(), + ankunft_time: z.string().datetime({ offset: true }).optional().nullable(), +}); + +export type AssignPersonnelData = z.infer; + +export const AssignVehicleSchema = z.object({ + fahrzeug_id: z.string().uuid(), + ausrueck_time: z.string().datetime({ offset: true }).optional().nullable(), + einrueck_time: z.string().datetime({ offset: true }).optional().nullable(), +}); + +export type AssignVehicleData = z.infer; + +export const IncidentFiltersSchema = z.object({ + dateFrom: z.string().datetime({ offset: true }).optional(), + dateTo: z.string().datetime({ offset: true }).optional(), + einsatzArt: z.enum(EINSATZ_ARTEN).optional(), + status: z.enum(EINSATZ_STATUS).optional(), + limit: z.coerce.number().int().min(1).max(200).optional().default(50), + offset: z.coerce.number().int().min(0).optional().default(0), +}); + +export type IncidentFilters = z.infer; diff --git a/backend/src/models/member.model.ts b/backend/src/models/member.model.ts new file mode 100644 index 0000000..656e02a --- /dev/null +++ b/backend/src/models/member.model.ts @@ -0,0 +1,230 @@ +import { z } from 'zod'; + +// ============================================================ +// Domain enumerations — used both as Zod schemas and runtime +// arrays (for building setNewStatus(e.target.value as FahrzeugStatus)} + > + {Object.values(FahrzeugStatus).map((s) => ( + + {FahrzeugStatusLabel[s]} + + ))} + + + setBemerkung(e.target.value)} + placeholder="z.B. Fahrzeug in Werkstatt, voraussichtlich ab 01.03. wieder einsatzbereit" + /> + + + + + + + + ); +}; + +// ── Prüfungen Tab ───────────────────────────────────────────────────────────── + +interface PruefungenTabProps { + fahrzeugId: string; + pruefungen: FahrzeugPruefung[]; + onAdded: () => void; +} + +const ERGEBNIS_LABELS: Record = { + bestanden: 'Bestanden', + bestanden_mit_maengeln: 'Bestanden mit Mängeln', + nicht_bestanden: 'Nicht bestanden', + ausstehend: 'Ausstehend', +}; + +const ERGEBNIS_COLORS: Record = { + bestanden: 'success', + bestanden_mit_maengeln: 'warning', + nicht_bestanden: 'error', + ausstehend: 'default', +}; + +const PruefungenTab: React.FC = ({ fahrzeugId, pruefungen, onAdded }) => { + const [dialogOpen, setDialogOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const emptyForm: CreatePruefungPayload = { + pruefung_art: PruefungArt.HU, + faellig_am: '', + durchgefuehrt_am: '', + ergebnis: 'ausstehend', + pruefende_stelle: '', + kosten: undefined, + bemerkung: '', + }; + + const [form, setForm] = useState(emptyForm); + + const handleSubmit = async () => { + if (!form.faellig_am) { + setSaveError('Fälligkeitsdatum ist erforderlich.'); + return; + } + try { + setSaving(true); + setSaveError(null); + const payload: CreatePruefungPayload = { + ...form, + durchgefuehrt_am: form.durchgefuehrt_am || undefined, + kosten: form.kosten !== undefined && form.kosten !== null ? Number(form.kosten) : undefined, + }; + await vehiclesApi.addPruefung(fahrzeugId, payload); + setDialogOpen(false); + setForm(emptyForm); + onAdded(); + } catch { + setSaveError('Prüfung konnte nicht gespeichert werden.'); + } finally { + setSaving(false); + } + }; + + return ( + + {pruefungen.length === 0 ? ( + Noch keine Prüfungen erfasst. + ) : ( + } spacing={0}> + {pruefungen.map((p) => { + const ergebnis = (p.ergebnis ?? 'ausstehend') as PruefungErgebnis; + const isFaellig = !p.durchgefuehrt_am && new Date(p.faellig_am) < new Date(); + return ( + + + + {PruefungArtLabel[p.pruefung_art] ?? p.pruefung_art} + + + Fällig: {fmtDate(p.faellig_am)} + + {isFaellig && !p.durchgefuehrt_am && ( + + )} + + + + + {p.durchgefuehrt_am && ( + + )} + {p.naechste_faelligkeit && ( + + )} + + {p.pruefende_stelle && ( + + {p.pruefende_stelle} + {p.kosten != null && ` · ${p.kosten.toFixed(2)} €`} + + )} + {p.bemerkung && ( + + {p.bemerkung} + + )} + + + ); + })} + + )} + + {/* FAB */} + { setForm(emptyForm); setDialogOpen(true); }} + > + + + + {/* Add inspection dialog */} + setDialogOpen(false)} maxWidth="sm" fullWidth> + Prüfung erfassen + + {saveError && {saveError}} + + + + + Prüfungsart + + + + + + Ergebnis + + + + + setForm((f) => ({ ...f, faellig_am: e.target.value }))} + InputLabelProps={{ shrink: true }} + /> + + + setForm((f) => ({ ...f, durchgefuehrt_am: e.target.value }))} + InputLabelProps={{ shrink: true }} + /> + + + setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))} + placeholder="z.B. TÜV Süd Stuttgart" + /> + + + setForm((f) => ({ ...f, kosten: e.target.value ? Number(e.target.value) : undefined }))} + inputProps={{ min: 0, step: 0.01 }} + /> + + + setForm((f) => ({ ...f, bemerkung: e.target.value }))} + /> + + + + + + + + + + ); +}; + +// ── Wartung Tab ─────────────────────────────────────────────────────────────── + +interface WartungTabProps { + fahrzeugId: string; + wartungslog: FahrzeugWartungslog[]; + onAdded: () => void; +} + +const WARTUNG_ART_ICONS: Record = { + Kraftstoff: , + Reparatur: , + Inspektion: , + Hauptuntersuchung:, + default: , +}; + +const WartungTab: React.FC = ({ fahrzeugId, wartungslog, onAdded }) => { + const [dialogOpen, setDialogOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const emptyForm: CreateWartungslogPayload = { + datum: '', + art: undefined, + beschreibung: '', + km_stand: undefined, + kraftstoff_liter: undefined, + kosten: undefined, + externe_werkstatt: '', + }; + + const [form, setForm] = useState(emptyForm); + + const handleSubmit = async () => { + if (!form.datum || !form.beschreibung.trim()) { + setSaveError('Datum und Beschreibung sind erforderlich.'); + return; + } + try { + setSaving(true); + setSaveError(null); + await vehiclesApi.addWartungslog(fahrzeugId, { + ...form, + externe_werkstatt: form.externe_werkstatt || undefined, + }); + setDialogOpen(false); + setForm(emptyForm); + onAdded(); + } catch { + setSaveError('Wartungseintrag konnte nicht gespeichert werden.'); + } finally { + setSaving(false); + } + }; + + return ( + + {wartungslog.length === 0 ? ( + Noch keine Wartungseinträge erfasst. + ) : ( + // MUI Timeline is available via @mui/lab — using Paper list as fallback + // since @mui/lab is not in current package.json + } spacing={0}> + {wartungslog.map((entry) => { + const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default; + return ( + + {artIcon} + + + {fmtDate(entry.datum)} + {entry.art && ( + + )} + + {entry.beschreibung} + + {[ + entry.km_stand != null && `${entry.km_stand.toLocaleString('de-DE')} km`, + entry.kraftstoff_liter != null && `${entry.kraftstoff_liter.toFixed(1)} L`, + entry.kosten != null && `${entry.kosten.toFixed(2)} €`, + entry.externe_werkstatt && entry.externe_werkstatt, + ].filter(Boolean).join(' · ')} + + + + ); + })} + + )} + + { setForm(emptyForm); setDialogOpen(true); }} + > + + + + setDialogOpen(false)} maxWidth="sm" fullWidth> + Wartung / Service eintragen + + {saveError && {saveError}} + + + setForm((f) => ({ ...f, datum: e.target.value }))} + InputLabelProps={{ shrink: true }} + /> + + + + Art + + + + + setForm((f) => ({ ...f, beschreibung: e.target.value }))} + /> + + + setForm((f) => ({ ...f, km_stand: e.target.value ? Number(e.target.value) : undefined }))} + inputProps={{ min: 0 }} + /> + + + setForm((f) => ({ ...f, kraftstoff_liter: e.target.value ? Number(e.target.value) : undefined }))} + inputProps={{ min: 0, step: 0.1 }} + /> + + + setForm((f) => ({ ...f, kosten: e.target.value ? Number(e.target.value) : undefined }))} + inputProps={{ min: 0, step: 0.01 }} + /> + + + setForm((f) => ({ ...f, externe_werkstatt: e.target.value }))} + placeholder="Name der Werkstatt (wenn extern)" + /> + + + + + + + + + + ); +}; + +// ── Main Page ───────────────────────────────────────────────────────────────── + +function FahrzeugDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [vehicle, setVehicle] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState(0); + + const fetchVehicle = useCallback(async () => { + if (!id) return; + try { + setLoading(true); + setError(null); + const data = await vehiclesApi.getById(id); + setVehicle(data); + } catch { + setError('Fahrzeug konnte nicht geladen werden.'); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { fetchVehicle(); }, [fetchVehicle]); + + if (loading) { + return ( + + + + + + ); + } + + if (error || !vehicle) { + return ( + + + {error ?? 'Fahrzeug nicht gefunden.'} + + + + ); + } + + return ( + + + {/* Breadcrumb / back */} + + + {/* Page title */} + + + + + {vehicle.bezeichnung} + {vehicle.kurzname && ( + + {vehicle.kurzname} + + )} + + {vehicle.amtliches_kennzeichen && ( + + {vehicle.amtliches_kennzeichen} + {vehicle.hersteller && ` · ${vehicle.hersteller}`} + + )} + + + + + + + {/* Tabs */} + + setActiveTab(v)} + aria-label="Fahrzeug Detailansicht" + > + + + Prüfungen + + : 'Prüfungen' + } + /> + + + + + + {/* Tab content */} + + + + + + + + + + + + + + + + + Einsatzhistorie + + + Die Einsatzverknüpfung wird in Tier 2 (Einsatzverwaltung) implementiert. + + + + + + ); +} + +export default FahrzeugDetail; diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx new file mode 100644 index 0000000..ac38f34 --- /dev/null +++ b/frontend/src/pages/Kalender.tsx @@ -0,0 +1,804 @@ +import { useState, useMemo, useCallback } from 'react'; +import { + Box, + Typography, + IconButton, + ButtonGroup, + Button, + Popover, + List, + ListItem, + ListItemText, + Chip, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Snackbar, + Alert, + Skeleton, + Divider, + useTheme, + useMediaQuery, +} from '@mui/material'; +import { + ChevronLeft, + ChevronRight, + Today as TodayIcon, + CalendarViewMonth as CalendarIcon, + ViewList as ListViewIcon, + Star as StarIcon, + ContentCopy as CopyIcon, + CheckCircle as CheckIcon, + Cancel as CancelIcon, + HelpOutline as UnknownIcon, +} from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { trainingApi } from '../services/training'; +import type { UebungListItem, UebungTyp, TeilnahmeStatus } from '../types/training.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', +]; + +const TYP_DOT_COLOR: Record = { + 'Übungsabend': '#1976d2', // blue + 'Lehrgang': '#7b1fa2', // purple + 'Sonderdienst': '#e65100', // orange + 'Versammlung': '#616161', // gray + 'Gemeinschaftsübung': '#00796b', // teal + 'Sonstiges': '#9e9e9e', // light gray +}; + +const TYP_CHIP_COLOR: Record< + UebungTyp, + 'primary' | 'secondary' | 'warning' | 'default' | 'error' | 'info' | 'success' +> = { + 'Übungsabend': 'primary', + 'Lehrgang': 'secondary', + 'Sonderdienst': 'warning', + 'Versammlung': 'default', + 'Gemeinschaftsübung': 'info', + 'Sonstiges': 'default', +}; + +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) */ +function buildMonthGrid(year: number, month: number): Date[] { + // month is 0-indexed + 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(isoString: string): string { + const d = new Date(isoString); + const days = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; + return `${days[d.getDay()]}, ${d.getDate()}. ${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`; +} + +// --------------------------------------------------------------------------- +// RSVP indicator +// --------------------------------------------------------------------------- + +function RsvpDot({ status }: { status: TeilnahmeStatus | undefined }) { + if (!status || status === 'unbekannt') return ; + if (status === 'zugesagt' || status === 'erschienen') return ; + return ; +} + +// --------------------------------------------------------------------------- +// 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 trainingApi.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 Dienste 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: UebungListItem[]; + onDayClick: (day: Date, anchor: Element) => void; +} + +function MonthCalendar({ year, month, events, 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 + const eventsByDay = useMemo(() => { + const map = new Map(); + for (const ev of events) { + const d = startOfDay(new Date(ev.datum_von)); + const key = d.toISOString().slice(0, 10); + const arr = map.get(key) ?? []; + arr.push(ev); + map.set(key, arr); + } + return map; + }, [events]); + + return ( + + {/* Weekday headers */} + + {WEEKDAY_LABELS.map((wd) => ( + + {wd} + + ))} + + + {/* Day cells — 6 rows × 7 cols */} + + {cells.map((cell, idx) => { + const isCurrentMonth = cell.getMonth() === month; + const isToday = sameDay(cell, today); + const key = cell.toISOString().slice(0, 10); + const dayEvents = eventsByDay.get(key) ?? []; + const hasEvents = dayEvents.length > 0; + + return ( + hasEvents && onDayClick(cell, e.currentTarget)} + sx={{ + minHeight: isMobile ? 44 : 72, + borderRadius: 1, + p: '4px', + cursor: hasEvents ? 'pointer' : 'default', + bgcolor: isToday + ? 'primary.main' + : isCurrentMonth + ? 'background.paper' + : 'action.disabledBackground', + border: '1px solid', + borderColor: isToday ? 'primary.dark' : 'divider', + transition: 'background 0.1s', + '&:hover': hasEvents + ? { bgcolor: isToday ? 'primary.dark' : 'action.hover' } + : {}, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + overflow: 'hidden', + }} + > + + {cell.getDate()} + + + {/* Event dots — max 3 visible on mobile */} + {hasEvents && ( + + {dayEvents.slice(0, isMobile ? 3 : 5).map((ev, i) => ( + + ))} + {dayEvents.length > (isMobile ? 3 : 5) && ( + + +{dayEvents.length - (isMobile ? 3 : 5)} + + )} + + )} + + {/* On desktop: show short event titles */} + {!isMobile && hasEvents && ( + + {dayEvents.slice(0, 2).map((ev, i) => ( + + {ev.pflichtveranstaltung && '* '}{ev.titel} + + ))} + + )} + + ); + })} + + + {/* Legend */} + + {Object.entries(TYP_DOT_COLOR).map(([typ, color]) => ( + + + {typ} + + ))} + + + Pflichtveranstaltung + + + + ); +} + +// --------------------------------------------------------------------------- +// List View +// --------------------------------------------------------------------------- + +function ListView({ + events, + onEventClick, +}: { + events: UebungListItem[]; + onEventClick: (id: string) => void; +}) { + return ( + + {events.map((ev, idx) => ( + + {idx > 0 && } + onEventClick(ev.id)} + sx={{ + cursor: 'pointer', + px: 1, + py: 1, + borderRadius: 1, + opacity: ev.abgesagt ? 0.55 : 1, + '&:hover': { bgcolor: 'action.hover' }, + }} + > + {/* Date badge */} + + + {new Date(ev.datum_von).getDate()}. + {new Date(ev.datum_von).getMonth() + 1}. + + + {formatTime(ev.datum_von)} + + + + + {ev.pflichtveranstaltung && ( + + )} + + {ev.titel} + + {ev.abgesagt && ( + + )} + + } + secondary={ + + + {ev.ort && ( + + {ev.ort} + + )} + + } + sx={{ my: 0 }} + /> + + {/* RSVP badge */} + + + + + + ))} + {events.length === 0 && ( + + Keine Veranstaltungen in diesem Monat. + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Day Popover +// --------------------------------------------------------------------------- + +interface DayPopoverProps { + anchorEl: Element | null; + day: Date | null; + events: UebungListItem[]; + onClose: () => void; + onEventClick: (id: string) => void; +} + +function DayPopover({ anchorEl, day, events, onClose, onEventClick }: DayPopoverProps) { + if (!day) return null; + + return ( + + + {formatDateLong(day.toISOString())} + + + {events.map((ev) => ( + { onEventClick(ev.id); onClose(); }} + sx={{ + cursor: 'pointer', + borderRadius: 1, + px: 0.75, + '&:hover': { bgcolor: 'action.hover' }, + opacity: ev.abgesagt ? 0.6 : 1, + }} + > + + + {ev.pflichtveranstaltung && } + {ev.titel} + + } + secondary={`${formatTime(ev.datum_von)} – ${formatTime(ev.datum_bis)} Uhr`} + /> + + + ))} + + + ); +} + +// --------------------------------------------------------------------------- +// Main Page +// --------------------------------------------------------------------------- + +export default function Kalender() { + const navigate = useNavigate(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + const today = new Date(); + const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() }); + const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar'); + const [icalOpen, setIcalOpen] = useState(false); + + // Popover state + const [popoverAnchor, setPopoverAnchor] = useState(null); + const [popoverDay, setPopoverDay] = useState(null); + const [popoverEvents, setPopoverEvents] = useState([]); + + // Compute fetch range: whole month ± 1 week buffer for grid + const { from, to } = useMemo(() => { + const firstCell = new Date(viewMonth.year, viewMonth.month, 1); + const dayOfWeek = (firstCell.getDay() + 6) % 7; + const f = new Date(firstCell); + f.setDate(f.getDate() - dayOfWeek); + + const lastCell = new Date(f); + lastCell.setDate(lastCell.getDate() + 41); + + return { from: f, to: lastCell }; + }, [viewMonth]); + + const { data, isLoading } = useQuery({ + queryKey: ['training', 'calendar', from.toISOString(), to.toISOString()], + queryFn: () => trainingApi.getCalendarRange(from, to), + staleTime: 5 * 60 * 1000, + }); + + const events = useMemo(() => data ?? [], [data]); + + 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() }); + }; + + const handleDayClick = useCallback((day: Date, anchor: Element) => { + const key = day.toISOString().slice(0, 10); + const dayEvs = events.filter( + (ev) => startOfDay(new Date(ev.datum_von)).toISOString().slice(0, 10) === key + ); + setPopoverDay(day); + setPopoverAnchor(anchor); + setPopoverEvents(dayEvs); + }, [events]); + + return ( + + + {/* Page header */} + + + + Dienstkalender + + + {/* View toggle */} + + + + + + + + + + + + + {/* Month navigation */} + + + + + + + {MONTH_LABELS[viewMonth.month]} {viewMonth.year} + + + + + + + + + + {/* Calendar / List body */} + {isLoading ? ( + + ) : viewMode === 'calendar' ? ( + + ) : ( + { + const d = new Date(ev.datum_von); + return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year; + })} + onEventClick={(id) => navigate(`/training/${id}`)} + /> + )} + + + {/* Day Popover */} + setPopoverAnchor(null)} + onEventClick={(id) => navigate(`/training/${id}`)} + /> + + {/* iCal Subscribe Dialog */} + setIcalOpen(false)} /> + + ); +} diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx new file mode 100644 index 0000000..3265567 --- /dev/null +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -0,0 +1,792 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Container, + Box, + Typography, + Card, + CardContent, + CardHeader, + Avatar, + Button, + Chip, + Tabs, + Tab, + Grid, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + CircularProgress, + Alert, + Divider, + Tooltip, + IconButton, + Stack, +} from '@mui/material'; +import { + Edit as EditIcon, + Save as SaveIcon, + Cancel as CancelIcon, + Person as PersonIcon, + Phone as PhoneIcon, + Badge as BadgeIcon, + Security as SecurityIcon, + History as HistoryIcon, + DriveEta as DriveEtaIcon, +} from '@mui/icons-material'; +import { useParams, useNavigate } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useAuth } from '../contexts/AuthContext'; +import { membersService } from '../services/members'; +import { + MemberWithProfile, + StatusEnum, + DienstgradEnum, + FunktionEnum, + TshirtGroesseEnum, + DIENSTGRAD_VALUES, + STATUS_VALUES, + FUNKTION_VALUES, + TSHIRT_GROESSE_VALUES, + STATUS_LABELS, + STATUS_COLORS, + getMemberDisplayName, + formatPhone, + UpdateMemberProfileData, +} from '../types/member.types'; + +// ---------------------------------------------------------------- +// Role helpers +// ---------------------------------------------------------------- +function useCanWrite(): boolean { + const { user } = useAuth(); + const groups: string[] = (user as any)?.groups ?? []; + return groups.includes('feuerwehr-admin') || groups.includes('feuerwehr-kommandant'); +} + +function useCurrentUserId(): string | undefined { + const { user } = useAuth(); + return (user as any)?.id; +} + +// ---------------------------------------------------------------- +// Tab panel helper +// ---------------------------------------------------------------- +interface TabPanelProps { + children?: React.ReactNode; + value: number; + index: number; +} + +function TabPanel({ children, value, index }: TabPanelProps) { + return ( + + ); +} + +// ---------------------------------------------------------------- +// Rank history timeline component +// ---------------------------------------------------------------- +interface RankTimelineProps { + entries: NonNullable; +} + +function RankTimeline({ entries }: RankTimelineProps) { + if (entries.length === 0) { + return ( + + Keine Dienstgradänderungen eingetragen. + + ); + } + + return ( + + {entries.map((entry, idx) => ( + + {/* Timeline dot */} + + + + + {/* Content */} + + + {entry.dienstgrad_neu} + + {entry.dienstgrad_alt && ( + + vorher: {entry.dienstgrad_alt} + + )} + + + {new Date(entry.datum).toLocaleDateString('de-AT')} + + {entry.durch_user_name && ( + + · durch {entry.durch_user_name} + + )} + + {entry.bemerkung && ( + + {entry.bemerkung} + + )} + + + ))} + + ); +} + +// ---------------------------------------------------------------- +// Read-only field row +// ---------------------------------------------------------------- +function FieldRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + + {label} + + + {value ?? '—'} + + + ); +} + +// ---------------------------------------------------------------- +// Main component +// ---------------------------------------------------------------- +function MitgliedDetail() { + const { userId } = useParams<{ userId: string }>(); + const navigate = useNavigate(); + const canWrite = useCanWrite(); + const currentUserId = useCurrentUserId(); + const isOwnProfile = currentUserId === userId; + const canEdit = canWrite || isOwnProfile; + + // --- state --- + const [member, setMember] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [editMode, setEditMode] = useState(false); + const [activeTab, setActiveTab] = useState(0); + + // Edit form state — only the fields the user is allowed to change + const [formData, setFormData] = useState({}); + + // ---------------------------------------------------------------- + // Data loading + // ---------------------------------------------------------------- + const loadMember = useCallback(async () => { + if (!userId) return; + setLoading(true); + setError(null); + try { + const data = await membersService.getMember(userId); + setMember(data); + } catch { + setError('Mitglied konnte nicht geladen werden.'); + } finally { + setLoading(false); + } + }, [userId]); + + useEffect(() => { + loadMember(); + }, [loadMember]); + + // Populate form from current profile + useEffect(() => { + if (member?.profile) { + setFormData({ + mitglieds_nr: member.profile.mitglieds_nr ?? undefined, + dienstgrad: member.profile.dienstgrad ?? undefined, + funktion: member.profile.funktion, + status: member.profile.status, + eintrittsdatum: member.profile.eintrittsdatum ?? undefined, + geburtsdatum: member.profile.geburtsdatum ?? undefined, + telefon_mobil: member.profile.telefon_mobil ?? undefined, + telefon_privat: member.profile.telefon_privat ?? undefined, + notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined, + notfallkontakt_telefon: member.profile.notfallkontakt_telefon ?? undefined, + fuehrerscheinklassen: member.profile.fuehrerscheinklassen, + tshirt_groesse: member.profile.tshirt_groesse ?? undefined, + schuhgroesse: member.profile.schuhgroesse ?? undefined, + bemerkungen: member.profile.bemerkungen ?? undefined, + }); + } + }, [member]); + + // ---------------------------------------------------------------- + // Save + // ---------------------------------------------------------------- + const handleSave = async () => { + if (!userId) return; + setSaving(true); + setSaveError(null); + try { + const updated = await membersService.updateMember(userId, formData); + setMember(updated); + setEditMode(false); + } catch { + setSaveError('Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.'); + } finally { + setSaving(false); + } + }; + + const handleCancelEdit = () => { + setEditMode(false); + setSaveError(null); + // Reset form to current profile values + if (member?.profile) { + setFormData({ + telefon_mobil: member.profile.telefon_mobil ?? undefined, + telefon_privat: member.profile.telefon_privat ?? undefined, + notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined, + notfallkontakt_telefon: member.profile.notfallkontakt_telefon ?? undefined, + tshirt_groesse: member.profile.tshirt_groesse ?? undefined, + schuhgroesse: member.profile.schuhgroesse ?? undefined, + }); + } + }; + + const handleFieldChange = (field: keyof UpdateMemberProfileData, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + // ---------------------------------------------------------------- + // Render helpers + // ---------------------------------------------------------------- + if (loading) { + return ( + + + + + + ); + } + + if (error || !member) { + return ( + + + + {error ?? 'Mitglied nicht gefunden.'} + + + + + ); + } + + const displayName = getMemberDisplayName(member); + const profile = member.profile; + const initials = [member.given_name?.[0], member.family_name?.[0]] + .filter(Boolean) + .join('') + .toUpperCase() || member.email[0].toUpperCase(); + + return ( + + + {/* Back button */} + + + {/* Header card */} + + + + + {initials} + + + + + + {displayName} + + {profile?.mitglieds_nr && ( + } + label={`Nr. ${profile.mitglieds_nr}`} + size="small" + variant="outlined" + /> + )} + {profile?.status && ( + + )} + + + + {member.email} + + + {profile?.dienstgrad && ( + + Dienstgrad: {profile.dienstgrad} + {profile.dienstgrad_seit + ? ` (seit ${new Date(profile.dienstgrad_seit).toLocaleDateString('de-AT')})` + : ''} + + )} + + {profile && profile.funktion.length > 0 && ( + + {profile.funktion.map((f) => ( + + ))} + + )} + + + {/* Edit controls */} + {canEdit && ( + + {editMode ? ( + + + + + {saving ? : } + + + + + + + + + + ) : ( + + setEditMode(true)} aria-label="Bearbeiten"> + + + + )} + + )} + + + {!profile && ( + + Für dieses Mitglied wurde noch kein Profil angelegt. + {canWrite && ' Ein Kommandant kann das Profil unter "Stammdaten" erstellen.'} + + )} + + {saveError && ( + setSaveError(null)}> + {saveError} + + )} + + + + {/* Tabs */} + + setActiveTab(v)} + aria-label="Mitglied Details" + > + + + + + + + {/* ---- Tab 0: Stammdaten ---- */} + + + {/* Personal data */} + + + } + title="Persönliche Daten" + /> + + {editMode && canWrite ? ( + + handleFieldChange('dienstgrad', e.target.value as DienstgradEnum || undefined)} + > + + {DIENSTGRAD_VALUES.map((dg) => ( + {dg} + ))} + + + handleFieldChange('dienstgrad_seit', e.target.value || undefined)} + InputLabelProps={{ shrink: true }} + /> + + handleFieldChange('status', e.target.value as StatusEnum)} + > + {STATUS_VALUES.map((s) => ( + {STATUS_LABELS[s]} + ))} + + + handleFieldChange('mitglieds_nr', e.target.value || undefined)} + /> + + handleFieldChange('eintrittsdatum', e.target.value || undefined)} + InputLabelProps={{ shrink: true }} + /> + + handleFieldChange('geburtsdatum', e.target.value || undefined)} + InputLabelProps={{ shrink: true }} + /> + + ) : ( + <> + + + + : null + } /> + + + + + )} + + + + + {/* Contact */} + + + } + title="Kontaktdaten" + /> + + {editMode ? ( + + handleFieldChange('telefon_mobil', e.target.value || undefined)} + placeholder="+436641234567" + /> + handleFieldChange('telefon_privat', e.target.value || undefined)} + placeholder="+4371234567" + /> + + + Notfallkontakt + + handleFieldChange('notfallkontakt_name', e.target.value || undefined)} + /> + handleFieldChange('notfallkontakt_telefon', e.target.value || undefined)} + placeholder="+436641234567" + /> + + ) : ( + <> + + + + {member.email} + + } + /> + + + Notfallkontakt + + + + + )} + + + + + {/* Uniform sizing */} + + + } + title="Ausrüstung & Uniform" + /> + + {editMode ? ( + + handleFieldChange('tshirt_groesse', e.target.value as TshirtGroesseEnum || undefined)} + > + + {TSHIRT_GROESSE_VALUES.map((g) => ( + {g} + ))} + + handleFieldChange('schuhgroesse', e.target.value || undefined)} + placeholder="z.B. 43" + /> + + ) : ( + <> + + + + )} + + + + + {/* Driving licenses */} + + + } + title="Führerscheinklassen" + /> + + {profile?.fuehrerscheinklassen && profile.fuehrerscheinklassen.length > 0 ? ( + + {profile.fuehrerscheinklassen.map((k) => ( + + ))} + + ) : ( + + )} + + + + + {/* Rank history */} + + + } + title="Dienstgrad-Verlauf" + /> + + + + + + + {/* Remarks — Kommandant/Admin only */} + {canWrite && ( + + + + + {editMode ? ( + handleFieldChange('bemerkungen', e.target.value || undefined)} + /> + ) : ( + + {profile?.bemerkungen ?? 'Keine Bemerkungen eingetragen.'} + + )} + + + + )} + + + + {/* ---- Tab 1: Qualifikationen (placeholder) ---- */} + + + + + + + Qualifikationen & Lehrgänge + + + Diese Funktion wird in einer zukünftigen Version verfügbar sein. + Geplant: Atemschutz, G26-Untersuchungen, Absolvierte Kurse, Gültigkeitsdaten. + + + + + + + {/* ---- Tab 2: Einsätze (placeholder) ---- */} + + + + + + + Einsätze dieses Mitglieds + + + Diese Funktion wird verfügbar sobald das Einsatz-Modul implementiert ist. + + + + + + + + ); +} + +export default MitgliedDetail; diff --git a/frontend/src/pages/UebungDetail.tsx b/frontend/src/pages/UebungDetail.tsx new file mode 100644 index 0000000..eacab9e --- /dev/null +++ b/frontend/src/pages/UebungDetail.tsx @@ -0,0 +1,551 @@ +import { useState, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Box, + Typography, + Chip, + Button, + Divider, + Accordion, + AccordionSummary, + AccordionDetails, + List, + ListItem, + ListItemText, + ListItemIcon, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Checkbox, + Skeleton, + Alert, + Paper, + Stack, + Tooltip, + CircularProgress, + useTheme, + useMediaQuery, +} from '@mui/material'; +import { + ExpandMore as ExpandMoreIcon, + CheckCircle as CheckIcon, + Cancel as CancelIcon, + HelpOutline as UnknownIcon, + Star as StarIcon, + LocationOn as LocationIcon, + AccessTime as TimeIcon, + Group as GroupIcon, + ArrowBack as BackIcon, + Edit as EditIcon, + Info as InfoIcon, + HowToReg as AttendanceIcon, +} from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useAuth } from '../contexts/AuthContext'; +import { trainingApi } from '../services/training'; +import type { TeilnahmeStatus, UebungTyp, Teilnahme } from '../types/training.types'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const TYP_CHIP_COLOR: Record< + UebungTyp, + 'primary' | 'secondary' | 'warning' | 'default' | 'error' | 'info' | 'success' +> = { + 'Übungsabend': 'primary', + 'Lehrgang': 'secondary', + 'Sonderdienst': 'warning', + 'Versammlung': 'default', + 'Gemeinschaftsübung': 'info', + 'Sonstiges': 'default', +}; + +const WEEKDAY = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; +const MONTH = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; + +function formatDateFull(iso: string): string { + const d = new Date(iso); + return `${WEEKDAY[d.getDay()]}, ${d.getDate()}. ${MONTH[d.getMonth()]} ${d.getFullYear()}`; +} + +function formatTime(iso: string): string { + const d = new Date(iso); + return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')} Uhr`; +} + +// --------------------------------------------------------------------------- +// Role helper — reads `role` from the user object (added by Tier 1) +// --------------------------------------------------------------------------- + +const ROLE_ORDER: Record = { + mitglied: 0, gruppenfuehrer: 1, kommandant: 2, admin: 3, +}; + +function hasRole(userRole: string | undefined, minRole: string): boolean { + return (ROLE_ORDER[userRole ?? 'mitglied'] ?? 0) >= (ROLE_ORDER[minRole] ?? 0); +} + +// --------------------------------------------------------------------------- +// RSVP Status icon +// --------------------------------------------------------------------------- + +function StatusIcon({ status }: { status: TeilnahmeStatus | undefined }) { + if (!status || status === 'unbekannt') return ; + if (status === 'zugesagt') return ; + if (status === 'erschienen') return ; + if (status === 'entschuldigt') return ; + return ; +} + +const STATUS_LABEL: Record = { + zugesagt: 'Zugesagt', + abgesagt: 'Abgesagt', + erschienen: 'Erschienen', + entschuldigt:'Entschuldigt', + unbekannt: 'Ausstehend', +}; + +// --------------------------------------------------------------------------- +// Mark Attendance Modal +// --------------------------------------------------------------------------- + +interface MarkAttendanceDialogProps { + open: boolean; + onClose: () => void; + uebungId: string; + teilnahmen: Teilnahme[]; +} + +function MarkAttendanceDialog({ + open, onClose, uebungId, teilnahmen, +}: MarkAttendanceDialogProps) { + const queryClient = useQueryClient(); + const [selected, setSelected] = useState>( + // Pre-select anyone already marked zugesagt + new Set(teilnahmen.filter((t) => t.status === 'zugesagt' || t.status === 'erschienen').map((t) => t.user_id)) + ); + + const mutation = useMutation({ + mutationFn: () => trainingApi.markAttendance(uebungId, Array.from(selected)), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['training', 'event', uebungId] }); + onClose(); + }, + }); + + const toggle = (userId: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(userId)) next.delete(userId); + else next.add(userId); + return next; + }); + }; + + return ( + + + + Anwesenheit erfassen + + + + {teilnahmen.map((t) => ( + toggle(t.user_id)} + sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} + > + + + + + + ))} + + + + + {selected.size} von {teilnahmen.length} ausgewählt + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Attendee Accordion +// --------------------------------------------------------------------------- + +function AttendeeAccordion({ + teilnahmen, + counts, + userRole, +}: { + teilnahmen?: Teilnahme[]; + counts: { + anzahl_zugesagt: number; + anzahl_abgesagt: number; + anzahl_unbekannt: number; + anzahl_entschuldigt: number; + anzahl_erschienen: number; + gesamt_eingeladen: number; + }; + userRole?: string; +}) { + const canSeeList = hasRole(userRole, 'gruppenfuehrer'); + + return ( + + }> + + + Rückmeldungen + + + + {counts.anzahl_erschienen > 0 && ( + + )} + + + + + {!canSeeList && ( + } sx={{ mb: 1 }}> + Nur Gruppenführer und Kommandanten sehen die individuelle Rückmeldungsliste. + + )} + + {canSeeList && teilnahmen && ( + + {teilnahmen.map((t) => ( + + + + + + {STATUS_LABEL[t.status]} + {t.bemerkung && ( + + — {t.bemerkung} + + )} + + } + primaryTypographyProps={{ variant: 'body2' }} + /> + + ))} + + )} + + {canSeeList && !teilnahmen && ( + + Keine Teilnehmer gefunden. + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Main Page +// --------------------------------------------------------------------------- + +export default function UebungDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + // We cast user to include `role` (added by Tier 1) + const userRole = (user as any)?.role as string | undefined; + const canWrite = hasRole(userRole, 'gruppenfuehrer'); + + const [markAttendanceOpen, setMarkAttendanceOpen] = useState(false); + const [rsvpLoading, setRsvpLoading] = useState<'zugesagt' | 'abgesagt' | null>(null); + + const { data: event, isLoading, isError } = useQuery({ + queryKey: ['training', 'event', id], + queryFn: () => trainingApi.getById(id!), + enabled: Boolean(id), + }); + + const rsvpMutation = useMutation({ + mutationFn: (status: 'zugesagt' | 'abgesagt') => + trainingApi.updateRsvp(id!, status), + onSuccess: (_data, status) => { + queryClient.invalidateQueries({ queryKey: ['training', 'event', id] }); + queryClient.invalidateQueries({ queryKey: ['training', 'upcoming'] }); + setRsvpLoading(null); + }, + onError: () => setRsvpLoading(null), + }); + + const handleRsvp = useCallback((status: 'zugesagt' | 'abgesagt') => { + setRsvpLoading(status); + rsvpMutation.mutate(status); + }, [rsvpMutation]); + + // ------------------------------------------------------------------------- + // Loading / error states + // ------------------------------------------------------------------------- + + if (isLoading) { + return ( + + + + + + + + + ); + } + + if (isError || !event) { + return ( + + + Veranstaltung konnte nicht geladen werden. + + + ); + } + + const isPast = new Date(event.datum_von) < new Date(); + const isAlreadyRsvp = event.eigener_status === 'zugesagt' || event.eigener_status === 'abgesagt'; + + return ( + + + {/* Back button */} + + + {/* Cancelled banner */} + {event.abgesagt && ( + + Abgesagt: {event.absage_grund ?? 'Kein Grund angegeben.'} + + )} + + {/* Header card */} + + + + {event.pflichtveranstaltung && ( + } + label="Pflichtveranstaltung" + size="small" + color="warning" + variant="outlined" + /> + )} + {canWrite && ( + + + + )} + + + + {event.titel} + + + {/* Meta info */} + + + + + {formatDateFull(event.datum_von)},{' '} + {formatTime(event.datum_von)} – {formatTime(event.datum_bis)} + + + {event.ort && ( + + + {event.ort} + + )} + {event.treffpunkt && event.treffpunkt !== event.ort && ( + + + + Treffpunkt: {event.treffpunkt} + + + )} + {event.angelegt_von_name && ( + + Erstellt von {event.angelegt_von_name} + + )} + + + + {/* Description */} + {event.beschreibung && ( + + Beschreibung + + {event.beschreibung} + + + )} + + {/* RSVP section */} + {!event.abgesagt && !isPast && ( + + + Meine Rückmeldung + + + {event.eigener_status && event.eigener_status !== 'unbekannt' && ( + + + + Aktuelle Rückmeldung: {STATUS_LABEL[event.eigener_status]} + + + )} + + + + + + + + )} + + {/* Attendee summary + list */} + + + Teilnehmer + {canWrite && !event.abgesagt && ( + + )} + + + + + + + {/* Mark Attendance Dialog */} + {event.teilnahmen && ( + setMarkAttendanceOpen(false)} + uebungId={id!} + teilnahmen={event.teilnahmen} + /> + )} + + ); +} diff --git a/frontend/src/pages/admin/AuditLog.tsx b/frontend/src/pages/admin/AuditLog.tsx new file mode 100644 index 0000000..827e33d --- /dev/null +++ b/frontend/src/pages/admin/AuditLog.tsx @@ -0,0 +1,733 @@ +/** + * AuditLog — Admin page + * + * Displays the immutable audit trail with filtering, pagination, and CSV export. + * Uses server-side pagination via the DataGrid's paginationMode="server" prop. + * + * Required packages (add to frontend/package.json dependencies): + * "@mui/x-data-grid": "^6.x || ^7.x" + * "@mui/x-date-pickers": "^6.x || ^7.x" + * "date-fns": "^3.x" + * "@date-io/date-fns": "^3.x" (adapter for MUI date pickers) + * + * Install: + * npm install @mui/x-data-grid @mui/x-date-pickers date-fns @date-io/date-fns + * + * Route registration in App.tsx: + * import AuditLog from './pages/admin/AuditLog'; + * // Inside : + * + * + * + * } + * /> + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, + Autocomplete, + Box, + Button, + Chip, + CircularProgress, + Container, + Dialog, + DialogContent, + DialogTitle, + Divider, + IconButton, + MenuItem, + Paper, + Select, + SelectChangeEvent, + Skeleton, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridRenderCellParams, + GridRowParams, +} from '@mui/x-data-grid'; +import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; +import { de } from 'date-fns/locale'; +import { format, parseISO } from 'date-fns'; +import CloseIcon from '@mui/icons-material/Close'; +import DownloadIcon from '@mui/icons-material/Download'; +import FilterAltIcon from '@mui/icons-material/FilterAlt'; +import DashboardLayout from '../../components/dashboard/DashboardLayout'; +import { api } from '../../services/api'; + +// --------------------------------------------------------------------------- +// Types — mirror the backend AuditLogEntry interface +// --------------------------------------------------------------------------- + +type AuditAction = + | 'CREATE' | 'UPDATE' | 'DELETE' + | 'LOGIN' | 'LOGOUT' | 'EXPORT' + | 'PERMISSION_DENIED' | 'PASSWORD_CHANGE' | 'ROLE_CHANGE'; + +type AuditResourceType = + | 'MEMBER' | 'INCIDENT' | 'VEHICLE' | 'EQUIPMENT' + | 'QUALIFICATION' | 'USER' | 'SYSTEM'; + +interface AuditLogEntry { + id: string; + user_id: string | null; + user_email: string | null; + action: AuditAction; + resource_type: AuditResourceType; + resource_id: string | null; + old_value: Record | null; + new_value: Record | null; + ip_address: string | null; + user_agent: string | null; + metadata: Record; + created_at: string; // ISO string from JSON +} + +interface AuditLogPage { + entries: AuditLogEntry[]; + total: number; + page: number; + pages: number; +} + +interface AuditFilters { + userId?: string; + action?: AuditAction[]; + resourceType?: AuditResourceType[]; + dateFrom?: Date | null; + dateTo?: Date | null; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const ALL_ACTIONS: AuditAction[] = [ + 'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT', + 'EXPORT', 'PERMISSION_DENIED', 'PASSWORD_CHANGE', 'ROLE_CHANGE', +]; + +const ALL_RESOURCE_TYPES: AuditResourceType[] = [ + 'MEMBER', 'INCIDENT', 'VEHICLE', 'EQUIPMENT', + 'QUALIFICATION', 'USER', 'SYSTEM', +]; + +// --------------------------------------------------------------------------- +// Action chip colour map +// --------------------------------------------------------------------------- + +const ACTION_COLORS: Record = { + CREATE: 'success', + UPDATE: 'primary', + DELETE: 'error', + LOGIN: 'info', + LOGOUT: 'default', + EXPORT: 'warning', + PERMISSION_DENIED:'error', + PASSWORD_CHANGE: 'warning', + ROLE_CHANGE: 'warning', +}; + +// --------------------------------------------------------------------------- +// Utility helpers +// --------------------------------------------------------------------------- + +function formatTimestamp(isoString: string): string { + try { + return format(parseISO(isoString), 'dd.MM.yyyy HH:mm:ss', { locale: de }); + } catch { + return isoString; + } +} + +function truncate(value: string | null | undefined, maxLength = 24): string { + if (!value) return '—'; + return value.length > maxLength ? value.substring(0, maxLength) + '…' : value; +} + +// --------------------------------------------------------------------------- +// JSON diff viewer (simple before / after display) +// --------------------------------------------------------------------------- + +interface JsonDiffViewerProps { + oldValue: Record | null; + newValue: Record | null; +} + +const JsonDiffViewer: React.FC = ({ oldValue, newValue }) => { + if (!oldValue && !newValue) { + return Keine Datenaenderung aufgezeichnet.; + } + + const allKeys = Array.from( + new Set([ + ...Object.keys(oldValue ?? {}), + ...Object.keys(newValue ?? {}), + ]) + ).sort(); + + return ( + + {oldValue && ( + + Vorher + +
+              {JSON.stringify(oldValue, null, 2)}
+            
+
+
+ )} + {newValue && ( + + Nachher + +
+              {JSON.stringify(newValue, null, 2)}
+            
+
+
+ )} + {/* Highlight changed fields */} + {oldValue && newValue && allKeys.length > 0 && ( + + Geaenderte Felder + + {allKeys.map((key) => { + const changed = + JSON.stringify((oldValue as Record)[key]) !== + JSON.stringify((newValue as Record)[key]); + if (!changed) return null; + return ( + + ); + })} + + + )} +
+ ); +}; + +// --------------------------------------------------------------------------- +// Entry detail dialog +// --------------------------------------------------------------------------- + +interface EntryDialogProps { + entry: AuditLogEntry | null; + onClose: () => void; + showIp: boolean; +} + +const EntryDialog: React.FC = ({ entry, onClose, showIp }) => { + if (!entry) return null; + + return ( + + + + + Audit-Eintrag — {entry.action} / {entry.resource_type} + + + + + + + + + + Metadaten + + {[ + ['Zeitpunkt', formatTimestamp(entry.created_at)], + ['Benutzer', entry.user_email ?? '—'], + ['Benutzer-ID', entry.user_id ?? '—'], + ['Aktion', entry.action], + ['Ressourcentyp', entry.resource_type], + ['Ressourcen-ID', entry.resource_id ?? '—'], + ...(showIp ? [['IP-Adresse', entry.ip_address ?? '—']] : []), + ].map(([label, value]) => ( + + + {label}: + + + {value} + + + ))} + + + + {Object.keys(entry.metadata ?? {}).length > 0 && ( + + Zusatzdaten + +
{JSON.stringify(entry.metadata, null, 2)}
+
+
+ )} + + + + + Datenaenderung + + + + +
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Filter panel +// --------------------------------------------------------------------------- + +interface FilterPanelProps { + filters: AuditFilters; + onChange: (f: AuditFilters) => void; + onReset: () => void; +} + +const FilterPanel: React.FC = ({ filters, onChange, onReset }) => { + return ( + + + + + Filter + + + + {/* Date range */} + onChange({ ...filters, dateFrom: date })} + slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }} + /> + onChange({ ...filters, dateTo: date })} + slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }} + /> + + {/* Action multi-select */} + onChange({ ...filters, action: value as AuditAction[] })} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + + {/* Resource type multi-select */} + onChange({ ...filters, resourceType: value as AuditResourceType[] })} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + /> + + + + + + ); +}; + +// --------------------------------------------------------------------------- +// Main page component +// --------------------------------------------------------------------------- + +const DEFAULT_FILTERS: AuditFilters = { + action: [], + resourceType: [], + dateFrom: null, + dateTo: null, +}; + +const AuditLog: React.FC = () => { + // Grid state + const [paginationModel, setPaginationModel] = useState({ + page: 0, // DataGrid is 0-based + pageSize: 25, + }); + const [rowCount, setRowCount] = useState(0); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Filters + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [appliedFilters, setApplied]= useState(DEFAULT_FILTERS); + + // Detail dialog + const [selectedEntry, setSelected]= useState(null); + + // Export state + const [exporting, setExporting] = useState(false); + + // The admin always sees IPs — toggle this based on role check if needed + const showIp = true; + + // ------------------------------------------------------------------------- + // Data fetching + // ------------------------------------------------------------------------- + + const fetchData = useCallback(async ( + pagination: GridPaginationModel, + f: AuditFilters, + ) => { + setLoading(true); + setError(null); + try { + const params: Record = { + page: String(pagination.page + 1), // convert 0-based to 1-based + pageSize: String(pagination.pageSize), + }; + + if (f.dateFrom) params.dateFrom = f.dateFrom.toISOString(); + if (f.dateTo) params.dateTo = f.dateTo.toISOString(); + if (f.action && f.action.length > 0) { + params.action = f.action.join(','); + } + if (f.resourceType && f.resourceType.length > 0) { + params.resourceType = f.resourceType.join(','); + } + if (f.userId) params.userId = f.userId; + + const queryString = new URLSearchParams(params).toString(); + const response = await api.get<{ success: boolean; data: AuditLogPage }>( + `/admin/audit-log?${queryString}` + ); + + setRows(response.data.data.entries); + setRowCount(response.data.data.total); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Unbekannter Fehler'; + setError(`Audit-Log konnte nicht geladen werden: ${msg}`); + } finally { + setLoading(false); + } + }, []); + + // Fetch when pagination or applied filters change + useEffect(() => { + fetchData(paginationModel, appliedFilters); + }, [paginationModel, appliedFilters, fetchData]); + + // ------------------------------------------------------------------------- + // Filter handlers + // ------------------------------------------------------------------------- + + const handleApplyFilters = () => { + setApplied(filters); + setPaginationModel((prev) => ({ ...prev, page: 0 })); + }; + + const handleResetFilters = () => { + setFilters(DEFAULT_FILTERS); + setApplied(DEFAULT_FILTERS); + setPaginationModel((prev) => ({ ...prev, page: 0 })); + }; + + // ------------------------------------------------------------------------- + // CSV export + // ------------------------------------------------------------------------- + + const handleExport = async () => { + setExporting(true); + try { + const params: Record = {}; + if (appliedFilters.dateFrom) params.dateFrom = appliedFilters.dateFrom.toISOString(); + if (appliedFilters.dateTo) params.dateTo = appliedFilters.dateTo.toISOString(); + if (appliedFilters.action && appliedFilters.action.length > 0) { + params.action = appliedFilters.action.join(','); + } + if (appliedFilters.resourceType && appliedFilters.resourceType.length > 0) { + params.resourceType = appliedFilters.resourceType.join(','); + } + + const queryString = new URLSearchParams(params).toString(); + const response = await api.get( + `/admin/audit-log/export?${queryString}`, + { responseType: 'blob' } + ); + + const url = URL.createObjectURL(response.data); + const filename = `audit_log_${format(new Date(), 'yyyy-MM-dd_HH-mm')}.csv`; + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch { + setError('CSV-Export fehlgeschlagen. Bitte versuchen Sie es erneut.'); + } finally { + setExporting(false); + } + }; + + // ------------------------------------------------------------------------- + // Column definitions + // ------------------------------------------------------------------------- + + const columns: GridColDef[] = useMemo(() => [ + { + field: 'created_at', + headerName: 'Zeitpunkt', + width: 160, + valueFormatter: (value: string) => formatTimestamp(value), + sortable: false, + }, + { + field: 'user_email', + headerName: 'Benutzer', + flex: 1, + minWidth: 160, + renderCell: (params: GridRenderCellParams) => + params.value ? ( + + {params.value} + + ) : ( + + ), + sortable: false, + }, + { + field: 'action', + headerName: 'Aktion', + width: 160, + renderCell: (params: GridRenderCellParams) => ( + + ), + sortable: false, + }, + { + field: 'resource_type', + headerName: 'Ressourcentyp', + width: 140, + sortable: false, + }, + { + field: 'resource_id', + headerName: 'Ressourcen-ID', + width: 130, + renderCell: (params: GridRenderCellParams) => ( + + + {truncate(params.value, 12)} + + + ), + sortable: false, + }, + ...(showIp ? [{ + field: 'ip_address', + headerName: 'IP-Adresse', + width: 140, + renderCell: (params: GridRenderCellParams) => ( + + {params.value ?? '—'} + + ), + sortable: false, + } as GridColDef] : []), + ], [showIp]); + + // ------------------------------------------------------------------------- + // Loading skeleton + // ------------------------------------------------------------------------- + + if (loading && rows.length === 0) { + return ( + + + + + + + + ); + } + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + + return ( + + + + {/* Header */} + + + Audit-Protokoll + + DSGVO Art. 5(2) — Unveraenderliches Protokoll aller Datenzugriffe + + + + + + {/* Error */} + {error && ( + setError(null)} sx={{ mb: 2 }}> + {error} + + )} + + {/* Filter panel */} + + + {/* Apply filters button */} + + + + + {/* Data grid */} + + + rows={rows} + columns={columns} + rowCount={rowCount} + loading={loading} + paginationMode="server" + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + pageSizeOptions={[25, 50, 100]} + disableRowSelectionOnClick={false} + onRowClick={(params: GridRowParams) => + setSelected(params.row) + } + sx={{ + border: 'none', + '& .MuiDataGrid-row': { cursor: 'pointer' }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'action.hover', + }, + }} + localeText={{ + noRowsLabel: 'Keine Eintraege gefunden', + MuiTablePagination: { + labelRowsPerPage: 'Eintraege pro Seite:', + labelDisplayedRows: ({ from, to, count }) => + `${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`, + }, + }} + autoHeight + /> + + + {/* Detail dialog */} + setSelected(null)} + showIp={showIp} + /> + + + + ); +}; + +export default AuditLog; diff --git a/frontend/src/services/incidents.ts b/frontend/src/services/incidents.ts new file mode 100644 index 0000000..b8a55e5 --- /dev/null +++ b/frontend/src/services/incidents.ts @@ -0,0 +1,299 @@ +import { api } from './api'; + +// --------------------------------------------------------------------------- +// SHARED TYPES (mirrors backend models, kept lean for frontend consumption) +// --------------------------------------------------------------------------- + +export const EINSATZ_ARTEN = [ + 'Brand', + 'THL', + 'ABC', + 'BMA', + 'Hilfeleistung', + 'Fehlalarm', + 'Brandsicherheitswache', +] as const; + +export type EinsatzArt = (typeof EINSATZ_ARTEN)[number]; + +export const EINSATZ_ART_LABELS: Record = { + Brand: 'Brand', + THL: 'Technische Hilfeleistung', + ABC: 'ABC / Gefahrgut', + BMA: 'Brandmeldeanlage', + Hilfeleistung: 'Hilfeleistung', + Fehlalarm: 'Fehlalarm', + Brandsicherheitswache: 'Brandsicherheitswache', +}; + +export const EINSATZ_STATUS = ['aktiv', 'abgeschlossen', 'archiviert'] as const; +export type EinsatzStatus = (typeof EINSATZ_STATUS)[number]; + +export const EINSATZ_STATUS_LABELS: Record = { + aktiv: 'Aktiv', + abgeschlossen: 'Abgeschlossen', + archiviert: 'Archiviert', +}; + +export const EINSATZ_FUNKTIONEN = [ + 'Einsatzleiter', + 'Gruppenführer', + 'Maschinist', + 'Atemschutz', + 'Sicherheitstrupp', + 'Melder', + 'Wassertrupp', + 'Angriffstrupp', + 'Mannschaft', + 'Sonstiges', +] as const; +export type EinsatzFunktion = (typeof EINSATZ_FUNKTIONEN)[number]; + +// --------------------------------------------------------------------------- +// API RESPONSE SHAPES +// --------------------------------------------------------------------------- + +export interface EinsatzListItem { + id: string; + einsatz_nr: string; + alarm_time: string; // ISO 8601 string from JSON + einsatz_art: EinsatzArt; + einsatz_stichwort: string | null; + ort: string | null; + strasse: string | null; + status: EinsatzStatus; + einsatzleiter_name: string | null; + hilfsfrist_min: number | null; + dauer_min: number | null; + personal_count: number; +} + +export interface EinsatzPersonal { + einsatz_id: string; + user_id: string; + funktion: EinsatzFunktion; + alarm_time: string | null; + ankunft_time: string | null; + assigned_at: string; + name: string | null; + email: string; + given_name: string | null; + family_name: string | null; +} + +export interface EinsatzFahrzeug { + einsatz_id: string; + fahrzeug_id: string; + ausrueck_time: string | null; + einrueck_time: string | null; + assigned_at: string; + kennzeichen: string; + bezeichnung: string; + fahrzeug_typ: string | null; +} + +export interface EinsatzDetail { + id: string; + einsatz_nr: string; + alarm_time: string; + ausrueck_time: string | null; + ankunft_time: string | null; + einrueck_time: string | null; + einsatz_art: EinsatzArt; + einsatz_stichwort: string | null; + strasse: string | null; + hausnummer: string | null; + ort: string | null; + bericht_kurz: string | null; + bericht_text: string | null; // undefined/null for roles below Kommandant + einsatzleiter_id: string | null; + einsatzleiter_name: string | null; + alarmierung_art: string; + status: EinsatzStatus; + created_by: string | null; + created_at: string; + updated_at: string; + hilfsfrist_min: number | null; + dauer_min: number | null; + fahrzeuge: EinsatzFahrzeug[]; + personal: EinsatzPersonal[]; +} + +export interface MonthlyStatRow { + monat: number; + anzahl: number; + avg_hilfsfrist_min: number | null; + avg_dauer_min: number | null; +} + +export interface EinsatzArtStatRow { + einsatz_art: EinsatzArt; + anzahl: number; + avg_hilfsfrist_min: number | null; +} + +export interface EinsatzStats { + jahr: number; + gesamt: number; + abgeschlossen: number; + aktiv: number; + avg_hilfsfrist_min: number | null; + haeufigste_art: EinsatzArt | null; + monthly: MonthlyStatRow[]; + by_art: EinsatzArtStatRow[]; + prev_year_monthly: MonthlyStatRow[]; +} + +export interface IncidentListResponse { + items: EinsatzListItem[]; + total: number; + limit: number; + offset: number; +} + +// --------------------------------------------------------------------------- +// REQUEST SHAPES +// --------------------------------------------------------------------------- + +export interface IncidentFilters { + dateFrom?: string; + dateTo?: string; + einsatzArt?: EinsatzArt; + status?: EinsatzStatus; + limit?: number; + offset?: number; +} + +export interface CreateEinsatzPayload { + alarm_time: string; + ausrueck_time?: string | null; + ankunft_time?: string | null; + einrueck_time?: string | null; + einsatz_art: EinsatzArt; + einsatz_stichwort?: string | null; + strasse?: string | null; + hausnummer?: string | null; + ort?: string | null; + bericht_kurz?: string | null; + bericht_text?: string | null; + einsatzleiter_id?: string | null; + alarmierung_art?: string; + status?: EinsatzStatus; +} + +export type UpdateEinsatzPayload = Partial; + +export interface AssignPersonnelPayload { + user_id: string; + funktion?: EinsatzFunktion; + alarm_time?: string | null; + ankunft_time?: string | null; +} + +export interface AssignVehiclePayload { + fahrzeug_id: string; + ausrueck_time?: string | null; + einrueck_time?: string | null; +} + +// --------------------------------------------------------------------------- +// API CALLS +// --------------------------------------------------------------------------- + +export const incidentsApi = { + /** + * Fetch paginated incident list with optional filters. + */ + async getAll(filters: IncidentFilters = {}): Promise { + const params = new URLSearchParams(); + if (filters.dateFrom) params.set('dateFrom', filters.dateFrom); + if (filters.dateTo) params.set('dateTo', filters.dateTo); + if (filters.einsatzArt) params.set('einsatzArt', filters.einsatzArt); + if (filters.status) params.set('status', filters.status); + if (filters.limit !== undefined) params.set('limit', String(filters.limit)); + if (filters.offset !== undefined) params.set('offset', String(filters.offset)); + + const response = await api.get<{ success: boolean; data: IncidentListResponse }>( + `/api/incidents?${params.toString()}` + ); + return response.data.data; + }, + + /** + * Fetch aggregated statistics for a given year. + */ + async getStats(year?: number): Promise { + const params = year ? `?year=${year}` : ''; + const response = await api.get<{ success: boolean; data: EinsatzStats }>( + `/api/incidents/stats${params}` + ); + return response.data.data; + }, + + /** + * Fetch a single incident with full detail. + */ + async getById(id: string): Promise { + const response = await api.get<{ success: boolean; data: EinsatzDetail }>( + `/api/incidents/${id}` + ); + return response.data.data; + }, + + /** + * Create a new incident. Returns the created Einsatz row. + */ + async create(payload: CreateEinsatzPayload): Promise { + const response = await api.post<{ success: boolean; data: EinsatzDetail }>( + '/api/incidents', + payload + ); + return response.data.data; + }, + + /** + * Partially update an incident. + */ + async update(id: string, payload: UpdateEinsatzPayload): Promise { + const response = await api.patch<{ success: boolean; data: EinsatzDetail }>( + `/api/incidents/${id}`, + payload + ); + return response.data.data; + }, + + /** + * Soft-delete (archive) an incident. + */ + async delete(id: string): Promise { + await api.delete(`/api/incidents/${id}`); + }, + + /** + * Assign a member to an incident. + */ + async assignPersonnel(einsatzId: string, payload: AssignPersonnelPayload): Promise { + await api.post(`/api/incidents/${einsatzId}/personnel`, payload); + }, + + /** + * Remove a member from an incident. + */ + async removePersonnel(einsatzId: string, userId: string): Promise { + await api.delete(`/api/incidents/${einsatzId}/personnel/${userId}`); + }, + + /** + * Assign a vehicle to an incident. + */ + async assignVehicle(einsatzId: string, payload: AssignVehiclePayload): Promise { + await api.post(`/api/incidents/${einsatzId}/vehicles`, payload); + }, + + /** + * Remove a vehicle from an incident. + */ + async removeVehicle(einsatzId: string, fahrzeugId: string): Promise { + await api.delete(`/api/incidents/${einsatzId}/vehicles/${fahrzeugId}`); + }, +}; diff --git a/frontend/src/services/members.ts b/frontend/src/services/members.ts new file mode 100644 index 0000000..0e60547 --- /dev/null +++ b/frontend/src/services/members.ts @@ -0,0 +1,113 @@ +import { api } from './api'; +import { + MemberListItem, + MemberWithProfile, + MemberFilters, + MemberStats, + CreateMemberProfileData, + UpdateMemberProfileData, +} from '../types/member.types'; + +// ---------------------------------------------------------------- +// Response envelope shapes +// ---------------------------------------------------------------- +interface ApiListResponse { + success: boolean; + data: T[]; + meta: { total: number; page: number }; +} + +interface ApiItemResponse { + success: boolean; + data: T; +} + +// ---------------------------------------------------------------- +// Service +// ---------------------------------------------------------------- + +/** + * Builds a URLSearchParams object from the filter object so query + * strings like ?status[]=aktiv&status[]=passiv are sent correctly. + */ +function buildParams(filters?: MemberFilters): URLSearchParams { + const params = new URLSearchParams(); + if (!filters) return params; + + if (filters.search) params.append('search', filters.search); + if (filters.page) params.append('page', String(filters.page)); + if (filters.pageSize) params.append('pageSize', String(filters.pageSize)); + + filters.status?.forEach((s) => params.append('status[]', s)); + filters.dienstgrad?.forEach((d) => params.append('dienstgrad[]', d)); + + return params; +} + +export const membersService = { + /** + * Fetches a paginated, optionally filtered list of members. + */ + async getMembers( + filters?: MemberFilters + ): Promise<{ items: MemberListItem[]; total: number; page: number }> { + const params = buildParams(filters); + const response = await api.get>( + `/api/members?${params.toString()}` + ); + return { + items: response.data.data, + total: response.data.meta.total, + page: response.data.meta.page, + }; + }, + + /** + * Fetches a single member with their full profile and rank history. + */ + async getMember(userId: string): Promise { + const response = await api.get>( + `/api/members/${userId}` + ); + return response.data.data; + }, + + /** + * Creates a new member profile for an existing auth user. + * Restricted to Kommandant/Admin (enforced server-side). + */ + async createMemberProfile( + userId: string, + data: CreateMemberProfileData + ): Promise { + const response = await api.post>( + `/api/members/${userId}/profile`, + data + ); + return response.data.data; + }, + + /** + * Partially updates a member profile. + * Kommandant/Admin: full update. + * Own profile: limited fields only (enforced server-side). + */ + async updateMember( + userId: string, + data: UpdateMemberProfileData + ): Promise { + const response = await api.patch>( + `/api/members/${userId}`, + data + ); + return response.data.data; + }, + + /** + * Fetches aggregate counts for the dashboard KPI widget. + */ + async getMemberStats(): Promise { + const response = await api.get>('/api/members/stats'); + return response.data.data; + }, +}; diff --git a/frontend/src/services/training.ts b/frontend/src/services/training.ts new file mode 100644 index 0000000..6023e57 --- /dev/null +++ b/frontend/src/services/training.ts @@ -0,0 +1,131 @@ +import { api } from './api'; +import { API_URL } from '../utils/config'; +import type { + Uebung, + UebungWithAttendance, + UebungListItem, + MemberParticipationStats, + CreateUebungData, + UpdateUebungData, +} from '../types/training.types'; + +// --------------------------------------------------------------------------- +// Response shapes from the backend +// --------------------------------------------------------------------------- +interface ApiResponse { + success: boolean; + data: T; + message?: string; +} + +// --------------------------------------------------------------------------- +// Helper: iCal subscribe URL +// --------------------------------------------------------------------------- +export function buildIcalUrl(token: string): string { + const base = API_URL.replace(/\/$/, ''); + return `${base}/api/training/calendar.ics?token=${token}`; +} + +// --------------------------------------------------------------------------- +// Training API service +// --------------------------------------------------------------------------- +export const trainingApi = { + // ------------------------------------------------------------------------- + // Event listing + // ------------------------------------------------------------------------- + + /** Upcoming events (dashboard widget, list view) */ + getUpcoming(limit = 10): Promise { + return api + .get>('/api/training', { params: { limit } }) + .then((r) => r.data.data); + }, + + /** Events in a date range for the month calendar view */ + getCalendarRange(from: Date, to: Date): Promise { + return api + .get>('/api/training/calendar', { + params: { + from: from.toISOString(), + to: to.toISOString(), + }, + }) + .then((r) => r.data.data); + }, + + /** Full event detail with attendance data */ + getById(id: string): Promise { + return api + .get>(`/api/training/${id}`) + .then((r) => r.data.data); + }, + + // ------------------------------------------------------------------------- + // CRUD + // ------------------------------------------------------------------------- + + createEvent(data: CreateUebungData): Promise { + return api + .post>('/api/training', data) + .then((r) => r.data.data); + }, + + updateEvent(id: string, data: Partial): Promise { + return api + .patch>(`/api/training/${id}`, data) + .then((r) => r.data.data); + }, + + cancelEvent(id: string, absage_grund: string): Promise { + return api + .delete(`/api/training/${id}`, { data: { absage_grund } }) + .then(() => undefined); + }, + + // ------------------------------------------------------------------------- + // Attendance / RSVP + // ------------------------------------------------------------------------- + + /** Member updates own RSVP */ + updateRsvp( + uebungId: string, + status: 'zugesagt' | 'abgesagt', + bemerkung?: string + ): Promise { + return api + .patch(`/api/training/${uebungId}/attendance`, { status, bemerkung }) + .then(() => undefined); + }, + + /** Gruppenführer bulk-marks attendance */ + markAttendance(uebungId: string, userIds: string[]): Promise { + return api + .post(`/api/training/${uebungId}/attendance/mark`, { userIds }) + .then(() => undefined); + }, + + // ------------------------------------------------------------------------- + // Stats + // ------------------------------------------------------------------------- + + getMemberStats(year?: number): Promise { + return api + .get>('/api/training/stats', { + params: { year: year ?? new Date().getFullYear() }, + }) + .then((r) => r.data.data); + }, + + // ------------------------------------------------------------------------- + // iCal + // ------------------------------------------------------------------------- + + /** Get the user's personal calendar subscribe URL */ + getCalendarToken(): Promise<{ token: string; subscribeUrl: string; instructions: string }> { + return api + .get>( + '/api/training/calendar-token' + ) + .then((r) => r.data.data); + }, +}; diff --git a/frontend/src/services/vehicles.ts b/frontend/src/services/vehicles.ts new file mode 100644 index 0000000..dced2d4 --- /dev/null +++ b/frontend/src/services/vehicles.ts @@ -0,0 +1,115 @@ +import { api } from './api'; +import type { + FahrzeugListItem, + FahrzeugDetail, + FahrzeugPruefung, + FahrzeugWartungslog, + VehicleStats, + InspectionAlert, + CreateFahrzeugPayload, + UpdateFahrzeugPayload, + UpdateStatusPayload, + CreatePruefungPayload, + CreateWartungslogPayload, +} from '../types/vehicle.types'; + +// --------------------------------------------------------------------------- +// Internal: unwrap the standard { success, data } envelope +// --------------------------------------------------------------------------- +async function unwrap(promise: ReturnType>): Promise { + const response = await promise; + return response.data.data; +} + +// --------------------------------------------------------------------------- +// Vehicle API Service +// --------------------------------------------------------------------------- + +export const vehiclesApi = { + + // ── Fleet overview ────────────────────────────────────────────────────────── + + /** Fetch all vehicles with their next inspection badge data */ + async getAll(): Promise { + return unwrap(api.get<{ success: boolean; data: FahrzeugListItem[] }>('/api/vehicles')); + }, + + /** Dashboard KPI stats */ + async getStats(): Promise { + return unwrap(api.get<{ success: boolean; data: VehicleStats }>('/api/vehicles/stats')); + }, + + /** + * Upcoming and overdue inspection alerts. + * @param daysAhead How many days to look ahead (default 30, max 365). + */ + async getAlerts(daysAhead = 30): Promise { + return unwrap( + api.get<{ success: boolean; data: InspectionAlert[] }>( + `/api/vehicles/alerts?daysAhead=${daysAhead}` + ) + ); + }, + + // ── Vehicle detail ────────────────────────────────────────────────────────── + + /** Full vehicle detail including inspection history and maintenance log */ + async getById(id: string): Promise { + return unwrap(api.get<{ success: boolean; data: FahrzeugDetail }>(`/api/vehicles/${id}`)); + }, + + // ── CRUD ──────────────────────────────────────────────────────────────────── + + async create(payload: CreateFahrzeugPayload): Promise { + const response = await api.post<{ success: boolean; data: FahrzeugDetail }>( + '/api/vehicles', + payload + ); + return response.data.data; + }, + + async update(id: string, payload: UpdateFahrzeugPayload): Promise { + const response = await api.patch<{ success: boolean; data: FahrzeugDetail }>( + `/api/vehicles/${id}`, + payload + ); + return response.data.data; + }, + + /** Live status change — Socket.IO event is emitted server-side in Tier 3 */ + async updateStatus(id: string, payload: UpdateStatusPayload): Promise { + await api.patch(`/api/vehicles/${id}/status`, payload); + }, + + // ── Inspections ───────────────────────────────────────────────────────────── + + async getPruefungen(id: string): Promise { + return unwrap( + api.get<{ success: boolean; data: FahrzeugPruefung[] }>(`/api/vehicles/${id}/pruefungen`) + ); + }, + + async addPruefung(id: string, payload: CreatePruefungPayload): Promise { + const response = await api.post<{ success: boolean; data: FahrzeugPruefung }>( + `/api/vehicles/${id}/pruefungen`, + payload + ); + return response.data.data; + }, + + // ── Maintenance log ───────────────────────────────────────────────────────── + + async getWartungslog(id: string): Promise { + return unwrap( + api.get<{ success: boolean; data: FahrzeugWartungslog[] }>(`/api/vehicles/${id}/wartung`) + ); + }, + + async addWartungslog(id: string, payload: CreateWartungslogPayload): Promise { + const response = await api.post<{ success: boolean; data: FahrzeugWartungslog }>( + `/api/vehicles/${id}/wartung`, + payload + ); + return response.data.data; + }, +}; diff --git a/frontend/src/types/member.types.ts b/frontend/src/types/member.types.ts new file mode 100644 index 0000000..e6bb85c --- /dev/null +++ b/frontend/src/types/member.types.ts @@ -0,0 +1,194 @@ +// ---------------------------------------------------------------- +// Frontend mirror of backend/src/models/member.model.ts +// Keep in sync when the model changes. +// ---------------------------------------------------------------- + +export const DIENSTGRAD_VALUES = [ + 'Feuerwehranwärter', + 'Feuerwehrmann', + 'Feuerwehrfrau', + 'Oberfeuerwehrmann', + 'Oberfeuerwehrfrau', + 'Hauptfeuerwehrmann', + 'Hauptfeuerwehrfrau', + 'Löschmeister', + 'Oberlöschmeister', + 'Hauptlöschmeister', + 'Brandmeister', + 'Oberbrandmeister', + 'Hauptbrandmeister', + 'Brandinspektor', + 'Oberbrandinspektor', + 'Brandoberinspektor', + 'Brandamtmann', +] as const; + +export const STATUS_VALUES = [ + 'aktiv', + 'passiv', + 'ehrenmitglied', + 'jugendfeuerwehr', + 'anwärter', + 'ausgetreten', +] as const; + +export const FUNKTION_VALUES = [ + 'Kommandant', + 'Stellv. Kommandant', + 'Gruppenführer', + 'Truppführer', + 'Gerätewart', + 'Kassier', + 'Schriftführer', + 'Atemschutzwart', + 'Ausbildungsbeauftragter', +] as const; + +export const TSHIRT_GROESSE_VALUES = [ + 'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL', +] as const; + +export type DienstgradEnum = typeof DIENSTGRAD_VALUES[number]; +export type StatusEnum = typeof STATUS_VALUES[number]; +export type FunktionEnum = typeof FUNKTION_VALUES[number]; +export type TshirtGroesseEnum = typeof TSHIRT_GROESSE_VALUES[number]; + +export interface MitgliederProfile { + id: string; + user_id: string; + mitglieds_nr: string | null; + dienstgrad: DienstgradEnum | null; + dienstgrad_seit: string | null; // ISO date string from API + funktion: FunktionEnum[]; + status: StatusEnum; + eintrittsdatum: string | null; + austrittsdatum: string | null; + geburtsdatum: string | null; // null when redacted by server + _age?: number; // synthesised when geburtsdatum is redacted + telefon_mobil: string | null; + telefon_privat: string | null; + notfallkontakt_name: string | null; + notfallkontakt_telefon: string | null; + fuehrerscheinklassen: string[]; + tshirt_groesse: TshirtGroesseEnum | null; + schuhgroesse: string | null; + bemerkungen: string | null; + bild_url: string | null; + created_at: string; + updated_at: string; +} + +export interface DienstgradVerlaufEntry { + id: string; + user_id: string; + dienstgrad_neu: string; + dienstgrad_alt: string | null; + datum: string; + durch_user_id: string | null; + durch_user_name?: string | null; + bemerkung: string | null; + created_at: string; +} + +export interface MemberWithProfile { + id: string; + email: string; + name: string | null; + given_name: string | null; + family_name: string | null; + preferred_username: string | null; + profile_picture_url: string | null; + is_active: boolean; + last_login_at: string | null; + created_at: string; + profile: MitgliederProfile | null; + dienstgrad_verlauf?: DienstgradVerlaufEntry[]; +} + +export interface MemberListItem { + id: string; + name: string | null; + given_name: string | null; + family_name: string | null; + email: string; + profile_picture_url: string | null; + is_active: boolean; + profile_id: string | null; + mitglieds_nr: string | null; + dienstgrad: DienstgradEnum | null; + funktion: FunktionEnum[]; + status: StatusEnum | null; + eintrittsdatum: string | null; + telefon_mobil: string | null; +} + +export interface MemberFilters { + search?: string; + status?: StatusEnum[]; + dienstgrad?: DienstgradEnum[]; + page?: number; + pageSize?: number; +} + +export type CreateMemberProfileData = Partial>; +export type UpdateMemberProfileData = CreateMemberProfileData; + +export interface MemberStats { + total: number; + aktiv: number; + passiv: number; + ehrenmitglied: number; + jugendfeuerwehr: number; + 'anwärter': number; + ausgetreten: number; +} + +// ---------------------------------------------------------------- +// Display helpers +// ---------------------------------------------------------------- + +/** Returns the display name built from given/family name or email fallback */ +export function getMemberDisplayName( + member: Pick +): string { + if (member.given_name || member.family_name) { + return [member.given_name, member.family_name].filter(Boolean).join(' '); + } + return member.name || member.email; +} + +/** Format a German phone number for display. + * Stored raw; displayed with spaces for readability. + * e.g. "+436641234567" → "+43 664 123 4567" + * Falls back to raw value for unrecognised formats. */ +export function formatPhone(raw: string | null | undefined): string { + if (!raw) return '—'; + const digits = raw.replace(/\s/g, ''); + // Austrian mobile: +43 6xx xxx xxxx + const atMobile = digits.match(/^(\+43)(6\d{2})(\d{3,4})(\d{4})$/); + if (atMobile) return `${atMobile[1]} ${atMobile[2]} ${atMobile[3]} ${atMobile[4]}`; + // German mobile: +49 1xx xxx xxxxx + const deMobile = digits.match(/^(\+49)(1\d{2})(\d{3,4})(\d{4,5})$/); + if (deMobile) return `${deMobile[1]} ${deMobile[2]} ${deMobile[3]} ${deMobile[4]}`; + return raw; +} + +/** Returns a human-readable status label */ +export const STATUS_LABELS: Record = { + aktiv: 'Aktiv', + passiv: 'Passiv', + ehrenmitglied: 'Ehrenmitglied', + jugendfeuerwehr: 'Jugendfeuerwehr', + anwärter: 'Anwärter', + ausgetreten: 'Ausgetreten', +}; + +/** MUI Chip color for each status */ +export const STATUS_COLORS: Record = { + aktiv: 'success', + passiv: 'warning', + ehrenmitglied: 'info', + jugendfeuerwehr: 'info', + anwärter: 'default', + ausgetreten: 'error', +}; diff --git a/frontend/src/types/training.types.ts b/frontend/src/types/training.types.ts new file mode 100644 index 0000000..7f759ac --- /dev/null +++ b/frontend/src/types/training.types.ts @@ -0,0 +1,115 @@ +// --------------------------------------------------------------------------- +// Frontend training types — mirrors backend/src/models/training.model.ts +// --------------------------------------------------------------------------- + +export const UEBUNG_TYPEN = [ + 'Übungsabend', + 'Lehrgang', + 'Sonderdienst', + 'Versammlung', + 'Gemeinschaftsübung', + 'Sonstiges', +] as const; + +export type UebungTyp = (typeof UEBUNG_TYPEN)[number]; + +export const TEILNAHME_STATUSES = [ + 'zugesagt', + 'abgesagt', + 'erschienen', + 'entschuldigt', + 'unbekannt', +] as const; + +export type TeilnahmeStatus = (typeof TEILNAHME_STATUSES)[number]; + +export interface Uebung { + id: string; + titel: string; + beschreibung?: string | null; + typ: UebungTyp; + datum_von: string; // ISO string from JSON + datum_bis: string; + ort?: string | null; + treffpunkt?: string | null; + pflichtveranstaltung: boolean; + mindest_teilnehmer?: number | null; + max_teilnehmer?: number | null; + angelegt_von?: string | null; + erstellt_am: string; + aktualisiert_am: string; + abgesagt: boolean; + absage_grund?: string | null; +} + +export interface Teilnahme { + uebung_id: string; + user_id: string; + status: TeilnahmeStatus; + antwort_am?: string | null; + erschienen_erfasst_am?: string | null; + erschienen_erfasst_von?: string | null; + bemerkung?: string | null; + user_name?: string | null; + user_email?: string | null; +} + +export interface AttendanceCounts { + gesamt_eingeladen: number; + anzahl_zugesagt: number; + anzahl_abgesagt: number; + anzahl_erschienen: number; + anzahl_entschuldigt: number; + anzahl_unbekannt: number; +} + +export interface UebungWithAttendance extends Uebung, AttendanceCounts { + teilnahmen?: Teilnahme[]; + eigener_status?: TeilnahmeStatus; + angelegt_von_name?: string | null; +} + +export interface UebungListItem { + id: string; + titel: string; + typ: UebungTyp; + datum_von: string; + datum_bis: string; + ort?: string | null; + pflichtveranstaltung: boolean; + abgesagt: boolean; + anzahl_zugesagt: number; + anzahl_erschienen: number; + gesamt_eingeladen: number; + eigener_status?: TeilnahmeStatus; +} + +export interface MemberParticipationStats { + userId: string; + name: string; + totalUebungen: number; + attended: number; + attendancePercent: number; + pflichtGesamt: number; + pflichtErschienen: number; + uebungsabendQuotePct: number; +} + +// --------------------------------------------------------------------------- +// Form data types (sent to API) +// --------------------------------------------------------------------------- + +export interface CreateUebungData { + titel: string; + beschreibung?: string | null; + typ: UebungTyp; + datum_von: string; // ISO-8601 with offset + datum_bis: string; + ort?: string | null; + treffpunkt?: string | null; + pflichtveranstaltung: boolean; + mindest_teilnehmer?: number | null; + max_teilnehmer?: number | null; +} + +export type UpdateUebungData = Partial; diff --git a/frontend/src/types/vehicle.types.ts b/frontend/src/types/vehicle.types.ts new file mode 100644 index 0000000..208ea61 --- /dev/null +++ b/frontend/src/types/vehicle.types.ts @@ -0,0 +1,205 @@ +// ============================================================================= +// Vehicle Fleet Management — Frontend Type Definitions +// Mirror of backend/src/models/vehicle.model.ts (transport layer shapes) +// ============================================================================= + +export enum FahrzeugStatus { + Einsatzbereit = 'einsatzbereit', + AusserDienstWartung = 'ausser_dienst_wartung', + AusserDienstSchaden = 'ausser_dienst_schaden', + InLehrgang = 'in_lehrgang', +} + +export const FahrzeugStatusLabel: Record = { + [FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit', + [FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)', + [FahrzeugStatus.AusserDienstSchaden]: 'Außer Dienst (Schaden)', + [FahrzeugStatus.InLehrgang]: 'In Lehrgang', +}; + +export enum PruefungArt { + HU = 'HU', + AU = 'AU', + UVV = 'UVV', + Leiter = 'Leiter', + Kran = 'Kran', + Seilwinde = 'Seilwinde', + Sonstiges = 'Sonstiges', +} + +export const PruefungArtLabel: Record = { + [PruefungArt.HU]: 'Hauptuntersuchung (TÜV)', + [PruefungArt.AU]: 'Abgasuntersuchung', + [PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)', + [PruefungArt.Leiter]: 'Leiternprüfung (DLK)', + [PruefungArt.Kran]: 'Kranprüfung', + [PruefungArt.Seilwinde]: 'Seilwindenprüfung', + [PruefungArt.Sonstiges]: 'Sonstige Prüfung', +}; + +export type PruefungErgebnis = + | 'bestanden' + | 'bestanden_mit_maengeln' + | 'nicht_bestanden' + | 'ausstehend'; + +export type WartungslogArt = + | 'Inspektion' + | 'Reparatur' + | 'Kraftstoff' + | 'Reifenwechsel' + | 'Hauptuntersuchung' + | 'Reinigung' + | 'Sonstiges'; + +// ── API Response Shapes ─────────────────────────────────────────────────────── + +export interface FahrzeugListItem { + id: string; + bezeichnung: string; + kurzname: string | null; + amtliches_kennzeichen: string | null; + baujahr: number | null; + hersteller: string | null; + besatzung_soll: string | null; + status: FahrzeugStatus; + status_bemerkung: string | null; + bild_url: string | null; + hu_faellig_am: string | null; // ISO date string from API + hu_tage_bis_faelligkeit: number | null; + au_faellig_am: string | null; + au_tage_bis_faelligkeit: number | null; + uvv_faellig_am: string | null; + uvv_tage_bis_faelligkeit: number | null; + leiter_faellig_am: string | null; + leiter_tage_bis_faelligkeit: number | null; + naechste_pruefung_tage: number | null; +} + +export interface PruefungStatus { + pruefung_id: string | null; + faellig_am: string | null; + tage_bis_faelligkeit: number | null; + ergebnis: PruefungErgebnis | null; +} + +export interface FahrzeugPruefung { + id: string; + fahrzeug_id: string; + pruefung_art: PruefungArt; + faellig_am: string; + durchgefuehrt_am: string | null; + ergebnis: PruefungErgebnis | null; + naechste_faelligkeit: string | null; + pruefende_stelle: string | null; + kosten: number | null; + dokument_url: string | null; + bemerkung: string | null; + erfasst_von: string | null; + created_at: string; +} + +export interface FahrzeugWartungslog { + id: string; + fahrzeug_id: string; + datum: string; + art: WartungslogArt | null; + beschreibung: string; + km_stand: number | null; + kraftstoff_liter: number | null; + kosten: number | null; + externe_werkstatt: string | null; + erfasst_von: string | null; + created_at: string; +} + +export interface FahrzeugDetail { + id: string; + bezeichnung: string; + kurzname: string | null; + amtliches_kennzeichen: string | null; + fahrgestellnummer: string | null; + baujahr: number | null; + hersteller: string | null; + typ_schluessel: string | null; + besatzung_soll: string | null; + status: FahrzeugStatus; + status_bemerkung: string | null; + standort: string; + bild_url: string | null; + created_at: string; + updated_at: string; + pruefstatus: { + hu: PruefungStatus; + au: PruefungStatus; + uvv: PruefungStatus; + leiter: PruefungStatus; + }; + naechste_pruefung_tage: number | null; + pruefungen: FahrzeugPruefung[]; + wartungslog: FahrzeugWartungslog[]; +} + +export interface VehicleStats { + total: number; + einsatzbereit: number; + ausserDienst: number; + inLehrgang: number; + inspectionsDue: number; + inspectionsOverdue: number; +} + +export interface InspectionAlert { + fahrzeugId: string; + bezeichnung: string; + kurzname: string | null; + pruefungId: string; + pruefungArt: PruefungArt; + faelligAm: string; + tage: number; +} + +// ── Request Payload Types ───────────────────────────────────────────────────── + +export interface CreateFahrzeugPayload { + bezeichnung: string; + kurzname?: string; + amtliches_kennzeichen?: string; + fahrgestellnummer?: string; + baujahr?: number; + hersteller?: string; + typ_schluessel?: string; + besatzung_soll?: string; + status?: FahrzeugStatus; + status_bemerkung?: string; + standort?: string; + bild_url?: string; +} + +export type UpdateFahrzeugPayload = Partial; + +export interface UpdateStatusPayload { + status: FahrzeugStatus; + bemerkung?: string; +} + +export interface CreatePruefungPayload { + pruefung_art: PruefungArt; + faellig_am: string; + durchgefuehrt_am?: string; + ergebnis?: PruefungErgebnis; + pruefende_stelle?: string; + kosten?: number; + dokument_url?: string; + bemerkung?: string; +} + +export interface CreateWartungslogPayload { + datum: string; + art?: WartungslogArt; + beschreibung: string; + km_stand?: number; + kraftstoff_liter?: number; + kosten?: number; + externe_werkstatt?: string; +}