From f3ad989a9eea0e1c4672c950d0b4009b817c26fe Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Mon, 16 Mar 2026 15:01:09 +0100 Subject: [PATCH] update --- .../src/controllers/atemschutz.controller.ts | 44 +++++ backend/src/controllers/booking.controller.ts | 14 +- backend/src/controllers/events.controller.ts | 33 ++++ .../controllers/serviceMonitor.controller.ts | 65 +++++-- backend/src/controllers/vehicle.controller.ts | 64 +++++++ backend/src/routes/atemschutz.routes.ts | 1 + backend/src/routes/events.routes.ts | 10 + backend/src/routes/serviceMonitor.routes.ts | 1 + backend/src/routes/vehicle.routes.ts | 1 + backend/src/services/atemschutz.service.ts | 39 ++++ backend/src/services/audit.service.ts | 62 +++++++ backend/src/services/events.service.ts | 42 +++++ backend/src/services/vehicle.service.ts | 37 ++++ .../admin/NotificationBroadcastTab.tsx | 173 ++++++++++++++++-- .../dashboard/AtemschutzExpiryNotifier.tsx | 20 ++ frontend/src/components/dashboard/index.ts | 1 + frontend/src/pages/Dashboard.tsx | 3 + frontend/src/pages/FahrzeugBuchungen.tsx | 66 ++++++- frontend/src/pages/Fahrzeuge.tsx | 28 +++ frontend/src/pages/Veranstaltungen.tsx | 68 ++++++- frontend/src/services/admin.ts | 3 +- frontend/src/services/atemschutz.ts | 5 + frontend/src/services/bookings.ts | 24 ++- frontend/src/services/events.ts | 10 + frontend/src/services/vehicles.ts | 7 + frontend/src/types/admin.types.ts | 6 + frontend/src/types/booking.types.ts | 11 ++ frontend/src/types/events.types.ts | 8 + 28 files changed, 794 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/dashboard/AtemschutzExpiryNotifier.tsx diff --git a/backend/src/controllers/atemschutz.controller.ts b/backend/src/controllers/atemschutz.controller.ts index 95ae057..0272b58 100644 --- a/backend/src/controllers/atemschutz.controller.ts +++ b/backend/src/controllers/atemschutz.controller.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import atemschutzService from '../services/atemschutz.service'; +import notificationService from '../services/notification.service'; import { CreateAtemschutzSchema, UpdateAtemschutzSchema } from '../models/atemschutz.model'; import logger from '../utils/logger'; @@ -159,6 +160,49 @@ class AtemschutzController { } } + async getExpiring(req: Request, res: Response): Promise { + try { + const expiring = await atemschutzService.getExpiringCertifications(30); + + // Side-effect: create notifications for expiring certifications (dedup via DB constraint) + for (const item of expiring) { + if (item.untersuchung_status !== 'ok') { + await notificationService.createNotification({ + user_id: item.user_id, + typ: 'atemschutz_expiry', + titel: item.untersuchung_status === 'abgelaufen' + ? 'G26 Untersuchung abgelaufen' + : 'G26 Untersuchung läuft bald ab', + nachricht: `Ihre G26 Untersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`, + schwere: item.untersuchung_status === 'abgelaufen' ? 'fehler' : 'warnung', + quell_typ: 'atemschutz_untersuchung', + quell_id: item.id, + link: '/atemschutz', + }); + } + if (item.leistungstest_status !== 'ok') { + await notificationService.createNotification({ + user_id: item.user_id, + typ: 'atemschutz_expiry', + titel: item.leistungstest_status === 'abgelaufen' + ? 'Leistungstest abgelaufen' + : 'Leistungstest läuft bald ab', + nachricht: `Ihr Leistungstest ${item.leistungstest_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`, + schwere: item.leistungstest_status === 'abgelaufen' ? 'fehler' : 'warnung', + quell_typ: 'atemschutz_leistungstest', + quell_id: item.id, + link: '/atemschutz', + }); + } + } + + res.status(200).json({ success: true, data: expiring }); + } catch (error) { + logger.error('Atemschutz getExpiring error', { error }); + res.status(500).json({ success: false, message: 'Ablaufende Zertifizierungen konnten nicht geladen werden' }); + } + } + async delete(req: Request, res: Response): Promise { try { const { id } = req.params as Record; diff --git a/backend/src/controllers/booking.controller.ts b/backend/src/controllers/booking.controller.ts index c3474d5..9a431d9 100644 --- a/backend/src/controllers/booking.controller.ts +++ b/backend/src/controllers/booking.controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { ZodError } from 'zod'; import bookingService from '../services/booking.service'; +import vehicleService from '../services/vehicle.service'; import { CreateBuchungSchema, UpdateBuchungSchema, @@ -52,12 +53,13 @@ class BookingController { res.status(400).json({ success: false, message: 'from und to sind erforderlich' }); return; } - const bookings = await bookingService.getBookingsByRange( - new Date(from as string), - new Date(to as string), - fahrzeugId as string | undefined - ); - res.json({ success: true, data: bookings }); + const fromDate = new Date(from as string); + const toDate = new Date(to as string); + const [bookings, maintenanceWindows] = await Promise.all([ + bookingService.getBookingsByRange(fromDate, toDate, fahrzeugId as string | undefined), + vehicleService.getMaintenanceWindows(fromDate, toDate), + ]); + res.json({ success: true, data: { bookings, maintenanceWindows } }); } catch (error) { logger.error('Booking getCalendarRange error', { error }); res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' }); diff --git a/backend/src/controllers/events.controller.ts b/backend/src/controllers/events.controller.ts index f2ff0d3..9aec201 100644 --- a/backend/src/controllers/events.controller.ts +++ b/backend/src/controllers/events.controller.ts @@ -118,6 +118,39 @@ class EventsController { } }; + // ------------------------------------------------------------------------- + // GET /api/events/conflicts?from=&to=&excludeId= + // ------------------------------------------------------------------------- + checkConflicts = async (req: Request, res: Response): Promise => { + try { + const fromStr = req.query.from as string | undefined; + const toStr = req.query.to as string | undefined; + const excludeId = req.query.excludeId 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; + } + + const data = await eventsService.checkConflicts(from, to, excludeId); + res.json({ success: true, data }); + } catch (error) { + logger.error('checkConflicts error', { error }); + res.status(500).json({ success: false, message: 'Fehler bei der Konfliktprüfung' }); + } + }; + // ------------------------------------------------------------------------- // GET /api/events/calendar?from=&to= // ------------------------------------------------------------------------- diff --git a/backend/src/controllers/serviceMonitor.controller.ts b/backend/src/controllers/serviceMonitor.controller.ts index be9e8ee..946724f 100644 --- a/backend/src/controllers/serviceMonitor.controller.ts +++ b/backend/src/controllers/serviceMonitor.controller.ts @@ -21,8 +21,37 @@ const broadcastSchema = z.object({ nachricht: z.string().min(1).max(2000), schwere: z.enum(['info', 'warnung', 'fehler']).default('info'), targetGroup: z.string().optional(), + targetDienstgrad: z.array(z.string()).optional(), }); +const broadcastFilterSchema = z.object({ + targetGroup: z.string().optional(), + targetDienstgrad: z.array(z.string()).optional(), +}); + +function buildFilteredUserQuery(filters: { targetGroup?: string; targetDienstgrad?: string[] }): { text: string; values: unknown[] } { + const conditions: string[] = ['u.is_active = TRUE']; + const values: unknown[] = []; + let paramIndex = 1; + + if (filters.targetGroup) { + conditions.push(`$${paramIndex} = ANY(u.authentik_groups)`); + values.push(filters.targetGroup); + paramIndex++; + } + + if (filters.targetDienstgrad && filters.targetDienstgrad.length > 0) { + conditions.push(`mp.dienstgrad = ANY($${paramIndex})`); + values.push(filters.targetDienstgrad); + paramIndex++; + } + + const needsJoin = filters.targetDienstgrad && filters.targetDienstgrad.length > 0; + const text = `SELECT DISTINCT u.id FROM users u${needsJoin ? ' LEFT JOIN member_profiles mp ON mp.user_id = u.id' : ''} WHERE ${conditions.join(' AND ')}`; + + return { text, values }; +} + class ServiceMonitorController { async getAll(_req: Request, res: Response): Promise { try { @@ -160,21 +189,11 @@ class ServiceMonitorController { async broadcastNotification(req: Request, res: Response): Promise { try { - const { titel, nachricht, schwere, targetGroup } = broadcastSchema.parse(req.body); + const { titel, nachricht, schwere, targetGroup, targetDienstgrad } = broadcastSchema.parse(req.body); - let users; - if (targetGroup) { - const result = await pool.query( - `SELECT id FROM users WHERE is_active = TRUE AND $1 = ANY(authentik_groups)`, - [targetGroup] - ); - users = result.rows; - } else { - const result = await pool.query( - `SELECT id FROM users WHERE is_active = TRUE` - ); - users = result.rows; - } + const query = buildFilteredUserQuery({ targetGroup, targetDienstgrad }); + const result = await pool.query(query.text, query.values); + const users = result.rows; let sent = 0; for (const user of users) { @@ -198,6 +217,24 @@ class ServiceMonitorController { res.status(500).json({ success: false, message: 'Failed to broadcast notification' }); } } + + async broadcastPreview(req: Request, res: Response): Promise { + try { + const { targetGroup, targetDienstgrad } = broadcastFilterSchema.parse(req.body); + + const query = buildFilteredUserQuery({ targetGroup, targetDienstgrad }); + const result = await pool.query(query.text, query.values); + + res.json({ success: true, data: { count: result.rows.length } }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues }); + return; + } + logger.error('Failed to preview broadcast', { error }); + res.status(500).json({ success: false, message: 'Failed to preview broadcast' }); + } + } } export default new ServiceMonitorController(); diff --git a/backend/src/controllers/vehicle.controller.ts b/backend/src/controllers/vehicle.controller.ts index 3fa9a50..8a00fb7 100644 --- a/backend/src/controllers/vehicle.controller.ts +++ b/backend/src/controllers/vehicle.controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import vehicleService from '../services/vehicle.service'; +import equipmentService from '../services/equipment.service'; import { FahrzeugStatus } from '../models/vehicle.model'; import logger from '../utils/logger'; @@ -140,6 +141,69 @@ class VehicleController { } } + async exportAlerts(_req: Request, res: Response): Promise { + try { + const escape = (v: unknown): string => { + if (v === null || v === undefined) return ''; + const str = String(v); + let safe = str.replace(/"/g, '""'); + if (/^[=+@\-]/.test(safe)) safe = "'" + safe; + return `"${safe}"`; + }; + + const formatDate = (d: Date | string | null): string => { + if (!d) return ''; + const date = typeof d === 'string' ? new Date(d) : d; + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}.${month}.${year}`; + }; + + const [vehicleAlerts, equipmentAlerts] = await Promise.all([ + vehicleService.getUpcomingInspections(365), + equipmentService.getUpcomingInspections(365), + ]); + + const header = 'Typ,Bezeichnung,Kurzname,Prüfungsart,Fällig am,Tage verbleibend'; + const rows: string[] = []; + + for (const a of vehicleAlerts) { + const pruefungsart = a.type === '57a' ? '§57a Überprüfung' : 'Nächste Wartung'; + rows.push([ + escape('Fahrzeug'), + escape(a.bezeichnung), + escape(a.kurzname), + escape(pruefungsart), + escape(formatDate(a.faelligAm)), + escape(a.tage), + ].join(',')); + } + + for (const e of equipmentAlerts) { + rows.push([ + escape('Ausrüstung'), + escape(e.bezeichnung), + escape(''), + escape('Prüfung'), + escape(formatDate(e.naechste_pruefung_am)), + escape(e.pruefung_tage_bis_faelligkeit), + ].join(',')); + } + + const today = new Date(); + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + const csv = '\uFEFF' + header + '\n' + rows.join('\n'); + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="pruefungen_${dateStr}.csv"`); + res.status(200).send(csv); + } catch (error) { + logger.error('exportAlerts error', { error }); + res.status(500).json({ success: false, message: 'Prüfungsexport konnte nicht erstellt werden' }); + } + } + async getVehicle(req: Request, res: Response): Promise { try { const { id } = req.params as Record; diff --git a/backend/src/routes/atemschutz.routes.ts b/backend/src/routes/atemschutz.routes.ts index 29ac6f1..bd1c789 100644 --- a/backend/src/routes/atemschutz.routes.ts +++ b/backend/src/routes/atemschutz.routes.ts @@ -9,6 +9,7 @@ const router = Router(); router.get('/', authenticate, atemschutzController.list.bind(atemschutzController)); router.get('/stats', authenticate, atemschutzController.getStats.bind(atemschutzController)); +router.get('/expiring', authenticate, atemschutzController.getExpiring.bind(atemschutzController)); router.get('/my-status', authenticate, atemschutzController.getMyStatus.bind(atemschutzController)); router.get('/user/:userId', authenticate, atemschutzController.getByUserId.bind(atemschutzController)); router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController)); diff --git a/backend/src/routes/events.routes.ts b/backend/src/routes/events.routes.ts index 7bb44eb..c3ed2f0 100644 --- a/backend/src/routes/events.routes.ts +++ b/backend/src/routes/events.routes.ts @@ -58,6 +58,16 @@ router.delete( */ router.get('/groups', authenticate, eventsController.getAvailableGroups.bind(eventsController)); +// --------------------------------------------------------------------------- +// Conflict check — must come before /:id +// --------------------------------------------------------------------------- + +/** + * GET /api/events/conflicts?from=&to=&excludeId= + * Check for overlapping events in the given time range. + */ +router.get('/conflicts', authenticate, eventsController.checkConflicts.bind(eventsController)); + // --------------------------------------------------------------------------- // Calendar & upcoming — specific routes must come before /:id // --------------------------------------------------------------------------- diff --git a/backend/src/routes/serviceMonitor.routes.ts b/backend/src/routes/serviceMonitor.routes.ts index 17cbd9b..088ee63 100644 --- a/backend/src/routes/serviceMonitor.routes.ts +++ b/backend/src/routes/serviceMonitor.routes.ts @@ -17,5 +17,6 @@ router.delete('/services/:id', ...auth, serviceMonitorController.delete.bind(ser router.get('/system/health', ...auth, serviceMonitorController.getSystemHealth.bind(serviceMonitorController)); router.get('/users', ...auth, serviceMonitorController.getUsers.bind(serviceMonitorController)); router.post('/notifications/broadcast', ...auth, serviceMonitorController.broadcastNotification.bind(serviceMonitorController)); +router.post('/notifications/broadcast/preview', ...auth, serviceMonitorController.broadcastPreview.bind(serviceMonitorController)); export default router; diff --git a/backend/src/routes/vehicle.routes.ts b/backend/src/routes/vehicle.routes.ts index 279d35b..94759a1 100644 --- a/backend/src/routes/vehicle.routes.ts +++ b/backend/src/routes/vehicle.routes.ts @@ -10,6 +10,7 @@ const router = Router(); router.get('/', authenticate, vehicleController.listVehicles.bind(vehicleController)); router.get('/stats', authenticate, vehicleController.getStats.bind(vehicleController)); router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleController)); +router.get('/alerts/export', authenticate, requirePermission('vehicles:read'), vehicleController.exportAlerts.bind(vehicleController)); router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController)); router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController)); diff --git a/backend/src/services/atemschutz.service.ts b/backend/src/services/atemschutz.service.ts index 33927b3..518e96b 100644 --- a/backend/src/services/atemschutz.service.ts +++ b/backend/src/services/atemschutz.service.ts @@ -216,6 +216,45 @@ class AtemschutzService { } } + // ========================================================================= + // EXPIRING CERTIFICATIONS + // ========================================================================= + + async getExpiringCertifications(daysAhead = 30): Promise { + try { + const result = await pool.query(` + SELECT + at.id, at.user_id, u.email, + COALESCE(u.name, u.email) as user_name, + at.untersuchung_gueltig_bis, + at.leistungstest_gueltig_bis, + CASE + WHEN at.untersuchung_gueltig_bis < CURRENT_DATE THEN 'abgelaufen' + WHEN at.untersuchung_gueltig_bis <= CURRENT_DATE + $1 THEN 'bald_faellig' + ELSE 'ok' + END as untersuchung_status, + CASE + WHEN at.leistungstest_gueltig_bis < CURRENT_DATE THEN 'abgelaufen' + WHEN at.leistungstest_gueltig_bis <= CURRENT_DATE + $1 THEN 'bald_faellig' + ELSE 'ok' + END as leistungstest_status + FROM atemschutz_traeger at + JOIN users u ON u.id = at.user_id + WHERE at.einsatzbereit = TRUE + AND ( + at.untersuchung_gueltig_bis <= CURRENT_DATE + $1 + OR at.leistungstest_gueltig_bis <= CURRENT_DATE + $1 + ) + ORDER BY LEAST(at.untersuchung_gueltig_bis, at.leistungstest_gueltig_bis) ASC + `, [`${daysAhead} days`]); + + return result.rows; + } catch (error) { + logger.error('AtemschutzService.getExpiringCertifications fehlgeschlagen', { error }); + throw new Error('Ablaufende Atemschutz-Zertifizierungen konnten nicht geladen werden'); + } + } + // ========================================================================= // DASHBOARD KPI / STATISTIKEN // ========================================================================= diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index 78d08e1..4a68131 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -14,6 +14,7 @@ import pool from '../config/database'; import logger from '../utils/logger'; +import notificationService from './notification.service'; // --------------------------------------------------------------------------- // Enums — kept as const objects rather than TypeScript enums so that the @@ -188,6 +189,9 @@ class AuditService { resource_id: entry.resource_id, user_id: entry.user_id, }); + + // Fire-and-forget — never block the audit log write + this.alertAdminsIfSensitive(entry).catch(() => {}); } catch (error) { // GDPR obligation: log the failure so it can be investigated, but // NEVER propagate — the main request must complete successfully. @@ -203,6 +207,64 @@ class AuditService { } } + // ------------------------------------------------------------------------- + // Audit alert notifications for admins + // ------------------------------------------------------------------------- + + /** + * alertAdminsIfSensitive — checks whether an audit entry represents a + * sensitive action and, if so, creates a notification for every active + * admin user (except the actor themselves). + * + * This method MUST NEVER throw. All errors are caught and logged. + */ + async alertAdminsIfSensitive(entry: AuditLogInput): Promise { + try { + const sensitiveActions: Record = { + 'PERMISSION_DENIED': 'Zugriff verweigert', + 'DELETE': 'Datensatz gelöscht', + 'ROLE_CHANGE': 'Rolle geändert', + }; + + const isUserUpdate = + entry.resource_type === AuditResourceType.USER && + entry.action === AuditAction.UPDATE; + const isSensitive = sensitiveActions[entry.action] || isUserUpdate; + + if (!isSensitive) return; + + // Get all active admin users + const { rows: admins } = await pool.query( + "SELECT id FROM users WHERE is_active = TRUE AND 'dashboard_admin' = ANY(authentik_groups)" + ); + + if (admins.length === 0) return; + + const titel = sensitiveActions[entry.action] || 'Benutzer-Änderung'; + const nachricht = `${entry.action} auf ${entry.resource_type}${entry.resource_id ? ' ' + entry.resource_id : ''} durch ${entry.user_email ?? 'System'}`; + + for (const admin of admins) { + // Don't notify the admin about their own actions + if (admin.id === entry.user_id) continue; + + await notificationService.createNotification({ + user_id: admin.id, + typ: 'audit_alert', + titel, + nachricht, + schwere: entry.action === 'PERMISSION_DENIED' ? 'warnung' : 'info', + quell_typ: 'audit_alert', + quell_id: `${entry.action}_${entry.resource_type}_${entry.resource_id ?? Date.now()}`, + }); + } + } catch (error) { + logger.error('alertAdminsIfSensitive failed', { + error: error instanceof Error ? error.message : String(error), + action: entry.action, + }); + } + } + // ------------------------------------------------------------------------- // Query — admin UI // ------------------------------------------------------------------------- diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index 39d0791..c515db4 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -614,6 +614,48 @@ class EventsService { })); } + // ------------------------------------------------------------------------- + // CONFLICT CHECK + // ------------------------------------------------------------------------- + + /** + * Returns events that overlap with the given time range. + * Used to warn users about scheduling conflicts before creating/updating events. + */ + async checkConflicts( + datumVon: Date, + datumBis: Date, + excludeId?: string + ): Promise> { + const params: any[] = [datumVon, datumBis]; + let excludeClause = ''; + if (excludeId) { + excludeClause = ' AND v.id != $3'; + params.push(excludeId); + } + + const result = await pool.query( + `SELECT v.id, v.titel, v.datum_von, v.datum_bis, + k.name AS kategorie_name + FROM veranstaltungen v + LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id + WHERE v.abgesagt = FALSE + AND ($1::timestamptz, $2::timestamptz) OVERLAPS (v.datum_von, v.datum_bis) + ${excludeClause} + ORDER BY v.datum_von ASC + LIMIT 10`, + params + ); + + return result.rows.map((row) => ({ + id: row.id, + titel: row.titel, + datum_von: new Date(row.datum_von), + datum_bis: new Date(row.datum_bis), + kategorie_name: row.kategorie_name ?? null, + })); + } + // ------------------------------------------------------------------------- // ICAL EXPORT // ------------------------------------------------------------------------- diff --git a/backend/src/services/vehicle.service.ts b/backend/src/services/vehicle.service.ts index b22fc77..2695297 100644 --- a/backend/src/services/vehicle.service.ts +++ b/backend/src/services/vehicle.service.ts @@ -406,6 +406,43 @@ class VehicleService { } } + // ========================================================================= + // MAINTENANCE WINDOWS (for booking calendar overlay) + // ========================================================================= + + async getMaintenanceWindows( + from: Date, + to: Date + ): Promise< + { + id: string; + bezeichnung: string; + kurzname: string | null; + status: string; + status_bemerkung: string | null; + ausser_dienst_von: string; + ausser_dienst_bis: string; + }[] + > { + try { + const result = await pool.query( + `SELECT id, bezeichnung, kurzname, status, status_bemerkung, + ausser_dienst_von, ausser_dienst_bis + FROM fahrzeuge + WHERE deleted_at IS NULL + AND status IN ('ausser_dienst_wartung', 'ausser_dienst_schaden') + AND ausser_dienst_von IS NOT NULL + AND ausser_dienst_bis IS NOT NULL + AND (ausser_dienst_von, ausser_dienst_bis) OVERLAPS ($1, $2)`, + [from, to] + ); + return result.rows; + } catch (error) { + logger.error('VehicleService.getMaintenanceWindows failed', { error }); + throw new Error('Failed to fetch maintenance windows'); + } + } + // ========================================================================= // DASHBOARD KPI // ========================================================================= diff --git a/frontend/src/components/admin/NotificationBroadcastTab.tsx b/frontend/src/components/admin/NotificationBroadcastTab.tsx index f9c8d75..e11e63a 100644 --- a/frontend/src/components/admin/NotificationBroadcastTab.tsx +++ b/frontend/src/components/admin/NotificationBroadcastTab.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Box, TextField, @@ -11,20 +11,49 @@ import { DialogContentText, DialogActions, CircularProgress, + FormControlLabel, + Checkbox, + Chip, + OutlinedInput, + InputLabel, + FormControl, + Select, } from '@mui/material'; +import type { SelectChangeEvent } from '@mui/material'; import SendIcon from '@mui/icons-material/Send'; +import PeopleIcon from '@mui/icons-material/People'; import { useMutation } from '@tanstack/react-query'; import { adminApi } from '../../services/admin'; import { useNotification } from '../../contexts/NotificationContext'; import type { BroadcastPayload } from '../../types/admin.types'; +const DIENSTGRAD_OPTIONS = [ + 'Mitglied', + 'Maschinist', + 'Truppführer', + 'Gruppenführer', + 'Zugkommandant', + 'Kommandant', +]; + +const GROUP_OPTIONS = [ + 'dashboard_admin', + 'dashboard_kommando', + 'dashboard_gruppenfuehrer', +]; + function NotificationBroadcastTab() { const { showSuccess, showError } = useNotification(); const [titel, setTitel] = useState(''); const [nachricht, setNachricht] = useState(''); const [schwere, setSchwere] = useState<'info' | 'warnung' | 'fehler'>('info'); const [targetGroup, setTargetGroup] = useState(''); + const [targetDienstgrad, setTargetDienstgrad] = useState([]); + const [alleBenutzer, setAlleBenutzer] = useState(true); const [confirmOpen, setConfirmOpen] = useState(false); + const [previewCount, setPreviewCount] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const debounceRef = useRef | null>(null); const broadcastMutation = useMutation({ mutationFn: (data: BroadcastPayload) => adminApi.broadcast(data), @@ -34,26 +63,74 @@ function NotificationBroadcastTab() { setNachricht(''); setSchwere('info'); setTargetGroup(''); + setTargetDienstgrad([]); + setAlleBenutzer(true); + setPreviewCount(null); }, onError: () => { showError('Fehler beim Senden der Benachrichtigung'); }, }); + const fetchPreview = useCallback(() => { + if (alleBenutzer) { + // For "Alle Benutzer" we still fetch the count (no filters) + setPreviewLoading(true); + adminApi.broadcastPreview({}) + .then((result) => setPreviewCount(result.count)) + .catch(() => setPreviewCount(null)) + .finally(() => setPreviewLoading(false)); + return; + } + + const payload: { targetGroup?: string; targetDienstgrad?: string[] } = {}; + if (targetGroup.trim()) payload.targetGroup = targetGroup.trim(); + if (targetDienstgrad.length > 0) payload.targetDienstgrad = targetDienstgrad; + + setPreviewLoading(true); + adminApi.broadcastPreview(payload) + .then((result) => setPreviewCount(result.count)) + .catch(() => setPreviewCount(null)) + .finally(() => setPreviewLoading(false)); + }, [alleBenutzer, targetGroup, targetDienstgrad]); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(fetchPreview, 300); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [fetchPreview]); + const handleSubmit = () => { setConfirmOpen(true); }; const handleConfirm = () => { setConfirmOpen(false); - broadcastMutation.mutate({ - titel, - nachricht, - schwere, - ...(targetGroup.trim() ? { targetGroup: targetGroup.trim() } : {}), - }); + const payload: BroadcastPayload = { titel, nachricht, schwere }; + if (!alleBenutzer) { + if (targetGroup.trim()) payload.targetGroup = targetGroup.trim(); + if (targetDienstgrad.length > 0) payload.targetDienstgrad = targetDienstgrad; + } + broadcastMutation.mutate(payload); }; + const handleDienstgradChange = (event: SelectChangeEvent) => { + const value = event.target.value; + setTargetDienstgrad(typeof value === 'string' ? value.split(',') : value); + }; + + const filtersActive = !alleBenutzer && (targetGroup.trim() || targetDienstgrad.length > 0); + + const filterDescription = (() => { + if (alleBenutzer) return 'alle aktiven Benutzer'; + const parts: string[] = []; + if (targetGroup.trim()) parts.push(`Gruppe "${targetGroup.trim()}"`); + if (targetDienstgrad.length > 0) parts.push(`Dienstgrad: ${targetDienstgrad.join(', ')}`); + return parts.length > 0 ? parts.join(' + ') : 'alle aktiven Benutzer'; + })(); + return ( Benachrichtigung senden @@ -91,15 +168,77 @@ function NotificationBroadcastTab() { Fehler - setTargetGroup(e.target.value)} - helperText="Leer lassen um an alle aktiven Benutzer zu senden" - sx={{ mb: 3 }} + { + setAlleBenutzer(e.target.checked); + if (e.target.checked) { + setTargetGroup(''); + setTargetDienstgrad([]); + } + }} + /> + } + label="Alle Benutzer" + sx={{ mb: 2, display: 'block' }} /> + {!alleBenutzer && ( + <> + setTargetGroup(e.target.value)} + sx={{ mb: 2 }} + > + + Keine Einschraenkung + + {GROUP_OPTIONS.map((g) => ( + {g} + ))} + + + + Dienstgrad + + + + )} + + + + {previewLoading ? ( + + ) : ( + + {previewCount !== null + ? `Wird an ${previewCount} Benutzer gesendet` + : 'Empfaengeranzahl wird geladen...'} + + )} + + {hasOverdue && ( diff --git a/frontend/src/pages/Veranstaltungen.tsx b/frontend/src/pages/Veranstaltungen.tsx index 59bcde8..eccd496 100644 --- a/frontend/src/pages/Veranstaltungen.tsx +++ b/frontend/src/pages/Veranstaltungen.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Box, Typography, @@ -60,6 +60,7 @@ import type { VeranstaltungKategorie, GroupInfo, CreateVeranstaltungInput, + ConflictEvent, } from '../types/events.types'; // --------------------------------------------------------------------------- @@ -609,6 +610,46 @@ function EventFormDialog({ } }, [open, editingEvent]); + // ----------------------------------------------------------------------- + // Conflict detection — debounced check when dates change + // ----------------------------------------------------------------------- + const [conflicts, setConflicts] = useState([]); + const conflictTimerRef = useRef | null>(null); + + useEffect(() => { + // Clear conflicts when dialog closes + if (!open) { + setConflicts([]); + return; + } + + const vonDate = new Date(form.datum_von); + const bisDate = new Date(form.datum_bis); + if (isNaN(vonDate.getTime()) || isNaN(bisDate.getTime()) || bisDate <= vonDate) { + setConflicts([]); + return; + } + + if (conflictTimerRef.current) clearTimeout(conflictTimerRef.current); + conflictTimerRef.current = setTimeout(async () => { + try { + const result = await eventsApi.checkConflicts( + vonDate.toISOString(), + bisDate.toISOString(), + editingEvent?.id + ); + setConflicts(result); + } catch { + // Silently ignore — conflict check is advisory only + setConflicts([]); + } + }, 500); + + return () => { + if (conflictTimerRef.current) clearTimeout(conflictTimerRef.current); + }; + }, [open, form.datum_von, form.datum_bis, editingEvent]); + const handleChange = (field: keyof CreateVeranstaltungInput, value: unknown) => { if (field === 'kategorie_id' && !editingEvent) { // Auto-fill zielgruppen / alle_gruppen from the selected category (only for new events) @@ -771,6 +812,31 @@ function EventFormDialog({ fullWidth /> + {/* Conflict warning */} + {conflicts.length > 0 && ( + + + Überschneidung mit bestehenden Veranstaltungen: + + {conflicts.map((c) => { + const von = new Date(c.datum_von); + const bis = new Date(c.datum_bis); + const fmtDate = (d: Date) => + `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; + const fmtTime = (d: Date) => + `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + const range = sameDay(von, bis) + ? `${fmtDate(von)} ${fmtTime(von)} - ${fmtTime(bis)}` + : `${fmtDate(von)} ${fmtTime(von)} - ${fmtDate(bis)} ${fmtTime(bis)}`; + return ( + + {'\u2022'} "{c.titel}" ({range}) + + ); + })} + + )} + {/* Ort */} { success: boolean; @@ -26,6 +26,7 @@ export const adminApi = { getSystemHealth: () => api.get>('/api/admin/system/health').then(r => r.data.data), getUsers: () => api.get>('/api/admin/users').then(r => r.data.data), broadcast: (data: BroadcastPayload) => api.post>('/api/admin/notifications/broadcast', data).then(r => r.data.data), + broadcastPreview: (data: BroadcastPreviewPayload) => api.post>('/api/admin/notifications/broadcast/preview', data).then(r => r.data.data), getPingHistory: (serviceId: string) => api.get>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data), fdiskSyncLogs: () => api.get>('/api/admin/fdisk-sync/logs').then(r => r.data.data), fdiskSyncTrigger: (force = false) => api.post>('/api/admin/fdisk-sync/trigger', { force }).then(r => r.data.data), diff --git a/frontend/src/services/atemschutz.ts b/frontend/src/services/atemschutz.ts index 9900176..10053a2 100644 --- a/frontend/src/services/atemschutz.ts +++ b/frontend/src/services/atemschutz.ts @@ -76,4 +76,9 @@ export const atemschutzApi = { async delete(id: string): Promise { await api.delete(`/api/atemschutz/${id}`); }, + + async getExpiring(): Promise { + const response = await api.get<{ success: boolean; data: any[] }>('/api/atemschutz/expiring'); + return response.data?.data ?? []; + }, }; diff --git a/frontend/src/services/bookings.ts b/frontend/src/services/bookings.ts index 612b7ad..31449e7 100644 --- a/frontend/src/services/bookings.ts +++ b/frontend/src/services/bookings.ts @@ -4,6 +4,7 @@ import type { FahrzeugBuchung, Fahrzeug, CreateBuchungInput, + MaintenanceWindow, } from '../types/booking.types'; // --------------------------------------------------------------------------- @@ -22,15 +23,22 @@ export const bookingApi = { // Calendar / listing // ------------------------------------------------------------------------- - getCalendarRange(from: Date, to: Date, fahrzeugId?: string): Promise { + getCalendarRange( + from: Date, + to: Date, + fahrzeugId?: string + ): Promise<{ bookings: FahrzeugBuchungListItem[]; maintenanceWindows: MaintenanceWindow[] }> { return api - .get>('/api/bookings/calendar', { - params: { - from: from.toISOString(), - to: to.toISOString(), - ...(fahrzeugId ? { fahrzeugId } : {}), - }, - }) + .get>( + '/api/bookings/calendar', + { + params: { + from: from.toISOString(), + to: to.toISOString(), + ...(fahrzeugId ? { fahrzeugId } : {}), + }, + } + ) .then((r) => r.data.data); }, diff --git a/frontend/src/services/events.ts b/frontend/src/services/events.ts index cbed252..4345710 100644 --- a/frontend/src/services/events.ts +++ b/frontend/src/services/events.ts @@ -5,6 +5,7 @@ import type { Veranstaltung, GroupInfo, CreateVeranstaltungInput, + ConflictEvent, } from '../types/events.types'; // --------------------------------------------------------------------------- @@ -153,4 +154,13 @@ export const eventsApi = { .post>('/api/events/import', { events }) .then((r) => r.data.data); }, + + /** Check for overlapping events in a time range */ + checkConflicts(from: string, to: string, excludeId?: string): Promise { + return api + .get>('/api/events/conflicts', { + params: { from, to, ...(excludeId ? { excludeId } : {}) }, + }) + .then((r) => r.data.data); + }, }; diff --git a/frontend/src/services/vehicles.ts b/frontend/src/services/vehicles.ts index 3c25ea8..7d430e4 100644 --- a/frontend/src/services/vehicles.ts +++ b/frontend/src/services/vehicles.ts @@ -93,4 +93,11 @@ export const vehiclesApi = { } return response.data.data; }, + + async exportAlerts(): Promise { + const response = await api.get('/api/vehicles/alerts/export', { + responseType: 'blob', + }); + return response.data as Blob; + }, }; diff --git a/frontend/src/types/admin.types.ts b/frontend/src/types/admin.types.ts index cdc61cc..a8ec51e 100644 --- a/frontend/src/types/admin.types.ts +++ b/frontend/src/types/admin.types.ts @@ -58,4 +58,10 @@ export interface BroadcastPayload { nachricht: string; schwere?: 'info' | 'warnung' | 'fehler'; targetGroup?: string; + targetDienstgrad?: string[]; +} + +export interface BroadcastPreviewPayload { + targetGroup?: string; + targetDienstgrad?: string[]; } diff --git a/frontend/src/types/booking.types.ts b/frontend/src/types/booking.types.ts index 1e69160..995d985 100644 --- a/frontend/src/types/booking.types.ts +++ b/frontend/src/types/booking.types.ts @@ -47,10 +47,21 @@ export interface Fahrzeug { kurzname: string | null; amtliches_kennzeichen: string | null; status: string; + status_bemerkung?: string | null; ausser_dienst_von: string | null; ausser_dienst_bis: string | null; } +export interface MaintenanceWindow { + id: string; + bezeichnung: string; + kurzname: string | null; + status: string; + status_bemerkung: string | null; + ausser_dienst_von: string; + ausser_dienst_bis: string; +} + export interface CreateBuchungInput { fahrzeugId: string; titel: string; diff --git a/frontend/src/types/events.types.ts b/frontend/src/types/events.types.ts index 971623a..d985ed0 100644 --- a/frontend/src/types/events.types.ts +++ b/frontend/src/types/events.types.ts @@ -76,3 +76,11 @@ export interface CreateVeranstaltungInput { anmeldung_bis?: string | null; wiederholung?: WiederholungConfig | null; } + +export interface ConflictEvent { + id: string; + titel: string; + datum_von: string; // ISO + datum_bis: string; // ISO + kategorie_name: string | null; +}