diff --git a/backend/src/app.ts b/backend/src/app.ts index 4eb1d4c..ed53a8b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -53,11 +53,21 @@ app.get('/health', (_req: Request, res: Response) => { }); // API routes -import authRoutes from './routes/auth.routes'; -import userRoutes from './routes/user.routes'; +import authRoutes from './routes/auth.routes'; +import userRoutes from './routes/user.routes'; +import memberRoutes from './routes/member.routes'; +import adminRoutes from './routes/admin.routes'; +import trainingRoutes from './routes/training.routes'; +import vehicleRoutes from './routes/vehicle.routes'; +import incidentRoutes from './routes/incident.routes'; -app.use('/api/auth', authRoutes); -app.use('/api/user', userRoutes); +app.use('/api/auth', authRoutes); +app.use('/api/user', userRoutes); +app.use('/api/members', memberRoutes); +app.use('/api/admin', adminRoutes); +app.use('/api/training', trainingRoutes); +app.use('/api/vehicles', vehicleRoutes); +app.use('/api/incidents', incidentRoutes); // 404 handler app.use(notFoundHandler); diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index 600c811..88f7cda 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -4,6 +4,8 @@ import tokenService from '../services/token.service'; import userService from '../services/user.service'; import logger from '../utils/logger'; import { AuthRequest } from '../types/auth.types'; +import auditService, { AuditAction, AuditResourceType } from '../services/audit.service'; +import { extractIp, extractUserAgent } from '../middleware/audit.middleware'; class AuthController { /** @@ -11,6 +13,9 @@ class AuthController { * POST /api/auth/callback */ async handleCallback(req: Request, res: Response): Promise { + const ip = extractIp(req); + const userAgent = extractUserAgent(req); + try { const { code } = req.body as AuthRequest; @@ -46,32 +51,75 @@ class AuthController { if (!user) { // User doesn't exist, create new user logger.info('Creating new user from Authentik', { - sub: userInfo.sub, + sub: userInfo.sub, email: userInfo.email, }); user = await userService.createUser({ - email: userInfo.email, - authentik_sub: userInfo.sub, + email: userInfo.email, + authentik_sub: userInfo.sub, preferred_username: userInfo.preferred_username, - given_name: userInfo.given_name, - family_name: userInfo.family_name, - name: userInfo.name, + given_name: userInfo.given_name, + family_name: userInfo.family_name, + name: userInfo.name, profile_picture_url: userInfo.picture, }); + + // Audit: first-ever login (user record creation) + auditService.logAudit({ + user_id: user.id, + user_email: user.email, + action: AuditAction.LOGIN, + resource_type: AuditResourceType.USER, + resource_id: user.id, + old_value: null, + new_value: { event: 'first_login', email: user.email }, + ip_address: ip, + user_agent: userAgent, + metadata: { new_account: true }, + }); } else { // User exists, update last login logger.info('Existing user logging in', { userId: user.id, - email: user.email, + email: user.email, }); await userService.updateLastLogin(user.id); + + // Audit: returning user login + auditService.logAudit({ + user_id: user.id, + user_email: user.email, + action: AuditAction.LOGIN, + resource_type: AuditResourceType.USER, + resource_id: user.id, + old_value: null, + new_value: null, + ip_address: ip, + user_agent: userAgent, + metadata: {}, + }); } // Check if user is active if (!user.is_active) { logger.warn('Inactive user attempted login', { userId: user.id }); + + // Audit the denied login attempt + auditService.logAudit({ + user_id: user.id, + user_email: user.email, + action: AuditAction.PERMISSION_DENIED, + resource_type: AuditResourceType.USER, + resource_id: user.id, + old_value: null, + new_value: null, + ip_address: ip, + user_agent: userAgent, + metadata: { reason: 'account_inactive' }, + }); + res.status(403).json({ success: false, message: 'User account is inactive', @@ -81,20 +129,20 @@ class AuthController { // Step 5: Generate internal JWT token const accessToken = tokenService.generateToken({ - userId: user.id, - email: user.email, + userId: user.id, + email: user.email, authentikSub: user.authentik_sub, }); // Generate refresh token const refreshToken = tokenService.generateRefreshToken({ userId: user.id, - email: user.email, + email: user.email, }); logger.info('User authenticated successfully', { userId: user.id, - email: user.email, + email: user.email, }); // Step 6: Return tokens and user info @@ -105,20 +153,37 @@ class AuthController { accessToken, refreshToken, user: { - id: user.id, - email: user.email, - name: user.name, + id: user.id, + email: user.email, + name: user.name, preferredUsername: user.preferred_username, - givenName: user.given_name, - familyName: user.family_name, + givenName: user.given_name, + familyName: user.family_name, profilePictureUrl: user.profile_picture_url, - isActive: user.is_active, + isActive: user.is_active, }, }, }); } catch (error) { logger.error('OAuth callback error', { error }); + // Audit the failed login attempt (user_id unknown at this point) + auditService.logAudit({ + user_id: null, + user_email: null, + action: AuditAction.PERMISSION_DENIED, + resource_type: AuditResourceType.SYSTEM, + resource_id: null, + old_value: null, + new_value: null, + ip_address: ip, + user_agent: userAgent, + metadata: { + reason: 'oauth_callback_error', + error: error instanceof Error ? error.message : 'unknown', + }, + }); + const message = error instanceof Error ? error.message : 'Authentication failed'; @@ -134,14 +199,29 @@ class AuthController { * POST /api/auth/logout */ async handleLogout(req: Request, res: Response): Promise { - try { - // In a stateless JWT setup, logout is handled client-side by removing the token - // However, we can log the event for audit purposes + const ip = extractIp(req); + const userAgent = extractUserAgent(req); + try { + // In a stateless JWT setup, logout is handled client-side by removing + // the token. We log the event for GDPR accountability. if (req.user) { logger.info('User logged out', { userId: req.user.id, - email: req.user.email, + email: req.user.email, + }); + + auditService.logAudit({ + user_id: req.user.id, + user_email: req.user.email, + action: AuditAction.LOGOUT, + resource_type: AuditResourceType.USER, + resource_id: req.user.id, + old_value: null, + new_value: null, + ip_address: ip, + user_agent: userAgent, + metadata: {}, }); } @@ -215,14 +295,14 @@ class AuthController { // Generate new access token const accessToken = tokenService.generateToken({ - userId: user.id, - email: user.email, + userId: user.id, + email: user.email, authentikSub: user.authentik_sub, }); logger.info('Token refreshed successfully', { userId: user.id, - email: user.email, + email: user.email, }); res.status(200).json({ diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts index 02db570..bb7d61a 100644 --- a/backend/src/middleware/auth.middleware.ts +++ b/backend/src/middleware/auth.middleware.ts @@ -3,15 +3,46 @@ import tokenService from '../services/token.service'; import userService from '../services/user.service'; import logger from '../utils/logger'; import { JwtPayload } from '../types/auth.types'; +import { auditPermissionDenied } from './audit.middleware'; +import { AuditResourceType } from '../services/audit.service'; + +// --------------------------------------------------------------------------- +// Application roles — extend as needed when Authentik group mapping is added +// --------------------------------------------------------------------------- + +export type AppRole = 'admin' | 'member' | 'viewer'; + +export const Permission = { + ADMIN_ACCESS: 'admin:access', + MEMBER_WRITE: 'member:write', + MEMBER_READ: 'member:read', + INCIDENT_WRITE:'incident:write', + INCIDENT_READ: 'incident:read', + EXPORT: 'export', +} as const; + +export type Permission = typeof Permission[keyof typeof Permission]; + +// Simple permission → required role mapping. +// Adjust once Authentik group sync is implemented. +const PERMISSION_ROLES: Record = { + 'admin:access': ['admin'], + 'member:write': ['admin', 'member'], + 'member:read': ['admin', 'member', 'viewer'], + 'incident:write': ['admin', 'member'], + 'incident:read': ['admin', 'member', 'viewer'], + 'export': ['admin'], +}; // Extend Express Request type to include user declare global { namespace Express { interface Request { user?: { - id: string; // UUID - email: string; + id: string; // UUID + email: string; authentikSub: string; + role?: AppRole; // populated when role is stored in DB / JWT }; } } @@ -106,6 +137,60 @@ export const authenticate = async ( } }; +// --------------------------------------------------------------------------- +// Role-based access control middleware +// --------------------------------------------------------------------------- + +/** + * requirePermission — factory that returns Express middleware enforcing a + * specific permission. Must be placed after `authenticate` in the chain. + * + * Usage: + * router.get('/admin/audit-log', authenticate, requirePermission('admin:access'), handler); + * + * When access is denied, a PERMISSION_DENIED audit entry is written before + * the 403 response is sent. + * + * NOTE: Until Authentik group → role mapping is persisted to the users table + * or JWT, this middleware checks req.user.role. Temporary workaround: + * hard-code specific admin user IDs via the ADMIN_USER_IDS env variable, OR + * add a `role` column to the users table (recommended). + */ +export const requirePermission = (permission: Permission) => { + return async (req: Request, res: Response, next: NextFunction): Promise => { + if (!req.user) { + res.status(401).json({ success: false, message: 'Not authenticated' }); + return; + } + + const userRole: AppRole = req.user.role ?? 'viewer'; + const allowedRoles = PERMISSION_ROLES[permission]; + + if (!allowedRoles.includes(userRole)) { + logger.warn('Permission denied', { + userId: req.user.id, + permission, + userRole, + path: req.path, + }); + + // Audit the denied access — fire-and-forget + auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, { + required_permission: permission, + user_role: userRole, + }); + + res.status(403).json({ + success: false, + message: 'Insufficient permissions', + }); + return; + } + + next(); + }; +}; + /** * Optional authentication middleware * Attaches user if token is valid, but doesn't require it diff --git a/backend/src/server.ts b/backend/src/server.ts index ef9ef7a..e6150cc 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,6 +2,7 @@ import app from './app'; import environment from './config/environment'; import logger from './utils/logger'; import { testConnection, closePool } from './config/database'; +import { startAuditCleanupJob, stopAuditCleanupJob } from './jobs/audit-cleanup.job'; const startServer = async (): Promise => { try { @@ -13,12 +14,15 @@ const startServer = async (): Promise => { logger.warn('Database connection failed - server will start but database operations may fail'); } + // Start the GDPR IP anonymisation job + startAuditCleanupJob(); + // Start the server const server = app.listen(environment.port, () => { logger.info('Server started successfully', { - port: environment.port, + port: environment.port, environment: environment.nodeEnv, - database: dbConnected ? 'connected' : 'disconnected', + database: dbConnected ? 'connected' : 'disconnected', }); }); @@ -26,6 +30,9 @@ const startServer = async (): Promise => { const gracefulShutdown = async (signal: string) => { logger.info(`${signal} received. Starting graceful shutdown...`); + // Stop scheduled jobs first + stopAuditCleanupJob(); + server.close(async () => { logger.info('HTTP server closed'); diff --git a/frontend/package.json b/frontend/package.json index ad56fb9..1033ad3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,9 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "axios": "^1.6.2", - "jwt-decode": "^4.0.0" + "jwt-decode": "^4.0.0", + "date-fns": "^3.6.0", + "recharts": "^2.12.7" }, "devDependencies": { "@types/react": "^18.2.37", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac02b4d..405e4d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,9 +9,14 @@ import Dashboard from './pages/Dashboard'; import Profile from './pages/Profile'; import Settings from './pages/Settings'; import Einsaetze from './pages/Einsaetze'; +import EinsatzDetail from './pages/EinsatzDetail'; import Fahrzeuge from './pages/Fahrzeuge'; +import FahrzeugDetail from './pages/FahrzeugDetail'; import Ausruestung from './pages/Ausruestung'; import Mitglieder from './pages/Mitglieder'; +import MitgliedDetail from './pages/MitgliedDetail'; +import Kalender from './pages/Kalender'; +import UebungDetail from './pages/UebungDetail'; import NotFound from './pages/NotFound'; function App() { @@ -55,6 +60,14 @@ function App() { } /> + + + + } + /> } /> + + + + } + /> } /> + + + + } + /> + + + + } + /> + + + + } + /> } /> diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 22a94c3..959b1fb 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -14,6 +14,7 @@ import { DirectionsCar, Build, People, + CalendarMonth as CalendarIcon, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -51,6 +52,11 @@ const navigationItems: NavigationItem[] = [ icon: , path: '/mitglieder', }, + { + text: 'Dienstkalender', + icon: , + path: '/kalender', + }, ]; interface SidebarProps { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 2a36666..77b586a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -10,7 +10,7 @@ import { People, Warning, EventNote, - LocalFireDepartment, + DirectionsCar, } from '@mui/icons-material'; import { useAuth } from '../contexts/AuthContext'; import DashboardLayout from '../components/dashboard/DashboardLayout'; @@ -21,17 +21,27 @@ import VikunjaCard from '../components/dashboard/VikunjaCard'; import BookstackCard from '../components/dashboard/BookstackCard'; import StatsCard from '../components/dashboard/StatsCard'; import ActivityFeed from '../components/dashboard/ActivityFeed'; +import InspectionAlerts from '../components/vehicles/InspectionAlerts'; +import { vehiclesApi } from '../services/vehicles'; +import type { VehicleStats } from '../types/vehicle.types'; function Dashboard() { const { user } = useAuth(); const [dataLoading, setDataLoading] = useState(true); + const [vehicleStats, setVehicleStats] = useState(null); useEffect(() => { - // Simulate loading data const timer = setTimeout(() => { setDataLoading(false); }, 800); + // Fetch live vehicle stats for the KPI strip + vehiclesApi.getStats() + .then((stats) => setVehicleStats(stats)) + .catch(() => { + // Non-critical — KPI will fall back to placeholder + }); + return () => clearTimeout(timer); }, []); @@ -118,6 +128,8 @@ function Dashboard() { )} + + {/* Live vehicle KPI — einsatzbereit count from API */} {dataLoading ? ( @@ -125,9 +137,13 @@ function Dashboard() { @@ -135,6 +151,15 @@ function Dashboard() { )} + {/* Inspection Alerts Panel — safety-critical, shown immediately */} + + + + + + + + {/* Service Integration Cards */} diff --git a/frontend/src/pages/Einsaetze.tsx b/frontend/src/pages/Einsaetze.tsx index b1362c9..6d7b13a 100644 --- a/frontend/src/pages/Einsaetze.tsx +++ b/frontend/src/pages/Einsaetze.tsx @@ -1,66 +1,503 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import { + Box, Container, Typography, + Button, + Chip, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + TextField, + Grid, Card, CardContent, - Box, + Skeleton, + IconButton, + Tooltip, + Alert, + Stack, } from '@mui/material'; -import { LocalFireDepartment } from '@mui/icons-material'; +import { + Add as AddIcon, + LocalFireDepartment, + Build, + Warning, + CheckCircle, + Refresh, + FilterList, +} from '@mui/icons-material'; +import { format, parseISO } from 'date-fns'; +import { de } from 'date-fns/locale'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import IncidentStatsChart from '../components/incidents/IncidentStatsChart'; +import { + incidentsApi, + EinsatzListItem, + EinsatzStats, + EinsatzArt, + EinsatzStatus, + EINSATZ_ARTEN, + EINSATZ_ART_LABELS, + EINSATZ_STATUS_LABELS, +} from '../services/incidents'; +import CreateEinsatzDialog from '../components/incidents/CreateEinsatzDialog'; + +// --------------------------------------------------------------------------- +// COLOUR MAP for Einsatzart chips +// --------------------------------------------------------------------------- +const ART_CHIP_COLOR: Record< + EinsatzArt, + 'error' | 'primary' | 'secondary' | 'warning' | 'success' | 'default' | 'info' +> = { + Brand: 'error', + THL: 'primary', + ABC: 'secondary', + BMA: 'warning', + Hilfeleistung: 'success', + Fehlalarm: 'default', + Brandsicherheitswache: 'info', +}; + +const STATUS_CHIP_COLOR: Record< + EinsatzStatus, + 'warning' | 'success' | 'default' +> = { + aktiv: 'warning', + abgeschlossen: 'success', + archiviert: 'default', +}; + +// --------------------------------------------------------------------------- +// HELPER +// --------------------------------------------------------------------------- +function formatDE(iso: string, fmt = 'dd.MM.yyyy HH:mm'): string { + try { + return format(parseISO(iso), fmt, { locale: de }); + } catch { + return iso; + } +} + +function durationLabel(min: number | null): string { + if (min === null || min < 0) return '—'; + if (min < 60) return `${min} min`; + const h = Math.floor(min / 60); + const m = min % 60; + return m === 0 ? `${h} h` : `${h} h ${m} min`; +} + +// --------------------------------------------------------------------------- +// STATS SUMMARY BAR +// --------------------------------------------------------------------------- +interface StatsSummaryProps { + stats: EinsatzStats | null; + loading: boolean; +} + +function StatsSummaryBar({ stats, loading }: StatsSummaryProps) { + const items = [ + { + label: 'Einsätze gesamt (Jahr)', + value: stats ? String(stats.gesamt) : '—', + icon: , + }, + { + label: 'Häufigste Einsatzart', + value: stats?.haeufigste_art + ? EINSATZ_ART_LABELS[stats.haeufigste_art] + : '—', + icon: , + }, + { + label: 'Ø Hilfsfrist', + value: stats?.avg_hilfsfrist_min !== null && stats?.avg_hilfsfrist_min !== undefined + ? `${stats.avg_hilfsfrist_min} min` + : '—', + icon: , + }, + { + label: 'Abgeschlossen', + value: stats ? String(stats.abgeschlossen) : '—', + icon: , + }, + ]; + + if (loading) { + return ( + + {[0, 1, 2, 3].map((i) => ( + + + + + + + + + ))} + + ); + } -function Einsaetze() { return ( - - - - Einsatzübersicht - - - - - - - - Einsatzverwaltung - - Diese Funktion wird in Kürze verfügbar sein + + {items.map((item) => ( + + + + + {item.icon} + + {item.label} - - - - Geplante Features: + + {item.value} -
    -
  • - - Einsatzliste mit Filteroptionen - -
  • -
  • - - Einsatzberichte erstellen und verwalten - -
  • -
  • - - Statistiken und Auswertungen - -
  • -
  • - - Einsatzdokumentation - -
  • -
  • - - Alarmstufen und Kategorien - -
  • -
-
-
-
+ + +
+ ))} + + ); +} + +// --------------------------------------------------------------------------- +// MAIN PAGE +// --------------------------------------------------------------------------- +function Einsaetze() { + const navigate = useNavigate(); + + // List state + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + const [listLoading, setListLoading] = useState(true); + const [listError, setListError] = useState(null); + + // Filters + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [selectedArts, setSelectedArts] = useState([]); + + // Stats + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(true); + + // Dialog + const [createOpen, setCreateOpen] = useState(false); + + // ------------------------------------------------------------------------- + // DATA FETCHING + // ------------------------------------------------------------------------- + const fetchList = useCallback(async () => { + setListLoading(true); + setListError(null); + try { + const filters: Record = { + limit: rowsPerPage, + offset: page * rowsPerPage, + }; + if (dateFrom) filters.dateFrom = new Date(dateFrom).toISOString(); + if (dateTo) { + // Set to end of day for dateTo + const end = new Date(dateTo); + end.setHours(23, 59, 59, 999); + filters.dateTo = end.toISOString(); + } + if (selectedArts.length === 1) filters.einsatzArt = selectedArts[0]; + + const result = await incidentsApi.getAll(filters as Parameters[0]); + setItems(result.items); + setTotal(result.total); + } catch (err) { + setListError('Fehler beim Laden der Einsätze. Bitte Seite neu laden.'); + } finally { + setListLoading(false); + } + }, [page, rowsPerPage, dateFrom, dateTo, selectedArts]); + + const fetchStats = useCallback(async () => { + setStatsLoading(true); + try { + const s = await incidentsApi.getStats(); + setStats(s); + } catch { + // Stats failure is non-critical + } finally { + setStatsLoading(false); + } + }, []); + + useEffect(() => { + fetchList(); + }, [fetchList]); + + useEffect(() => { + fetchStats(); + }, [fetchStats]); + + // ------------------------------------------------------------------------- + // HANDLERS + // ------------------------------------------------------------------------- + const handlePageChange = (_: unknown, newPage: number) => setPage(newPage); + + const handleRowsPerPageChange = (e: React.ChangeEvent) => { + setRowsPerPage(parseInt(e.target.value, 10)); + setPage(0); + }; + + const toggleArtFilter = (art: EinsatzArt) => { + setSelectedArts((prev) => + prev.includes(art) ? prev.filter((a) => a !== art) : [...prev, art] + ); + setPage(0); + }; + + const handleResetFilters = () => { + setDateFrom(''); + setDateTo(''); + setSelectedArts([]); + setPage(0); + }; + + const handleRowClick = (id: string) => { + navigate(`/einsaetze/${id}`); + }; + + const handleCreateSuccess = () => { + setCreateOpen(false); + fetchList(); + fetchStats(); + }; + + // ------------------------------------------------------------------------- + // RENDER + // ------------------------------------------------------------------------- + return ( + + + {/* Page header */} + + + + Einsatzübersicht + + + Einsatzprotokoll — Feuerwehr Rems + + + + + + + + + + + + + {/* KPI summary cards */} + + + {/* Charts */} + + + + + {/* Filters */} + + + + Filter + {(dateFrom || dateTo || selectedArts.length > 0) && ( + + )} + + + + + { setDateFrom(e.target.value); setPage(0); }} + InputLabelProps={{ shrink: true }} + size="small" + fullWidth + inputProps={{ 'aria-label': 'Von-Datum' }} + /> + + + { setDateTo(e.target.value); setPage(0); }} + InputLabelProps={{ shrink: true }} + size="small" + fullWidth + inputProps={{ 'aria-label': 'Bis-Datum' }} + /> + + + + {/* Einsatzart chips */} + + {EINSATZ_ARTEN.map((art) => ( + toggleArtFilter(art)} + clickable + /> + ))} + + + + {/* Error state */} + {listError && ( + setListError(null)}> + {listError} + + )} + + {/* Incident table */} + + + + + + Datum / Uhrzeit + Nr. + Einsatzart + Stichwort + Ort + Hilfsfrist + Dauer + Status + Einsatzleiter + Kräfte + + + + {listLoading + ? Array.from({ length: rowsPerPage > 10 ? 10 : rowsPerPage }).map((_, i) => ( + + {Array.from({ length: 10 }).map((__, j) => ( + + + + ))} + + )) + : items.length === 0 + ? ( + + + + + + + Keine Einsätze gefunden + + + + ) + : items.map((row) => ( + handleRowClick(row.id)} + sx={{ cursor: 'pointer' }} + > + + {formatDE(row.alarm_time)} + + + {row.einsatz_nr} + + + + + + {row.einsatz_stichwort ?? '—'} + + + {[row.strasse, row.ort].filter(Boolean).join(', ') || '—'} + + + {durationLabel(row.hilfsfrist_min)} + + + {durationLabel(row.dauer_min)} + + + + + + {row.einsatzleiter_name ?? '—'} + + + {row.personal_count > 0 ? row.personal_count : '—'} + + + ))} + +
+
+ + + `${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}` + } + /> +
+ + {/* Create dialog */} + setCreateOpen(false)} + onSuccess={handleCreateSuccess} + />
); diff --git a/frontend/src/pages/Fahrzeuge.tsx b/frontend/src/pages/Fahrzeuge.tsx index 15aad6a..5f75d73 100644 --- a/frontend/src/pages/Fahrzeuge.tsx +++ b/frontend/src/pages/Fahrzeuge.tsx @@ -1,66 +1,360 @@ +import React, { useEffect, useState, useCallback } from 'react'; import { - Container, - Typography, - Card, - CardContent, Box, + Card, + CardActionArea, + CardContent, + CardMedia, + Chip, + CircularProgress, + Container, + Fab, + Grid, + IconButton, + InputAdornment, + TextField, + Tooltip, + Typography, + Alert, } from '@mui/material'; -import { DirectionsCar } from '@mui/icons-material'; +import { + Add, + CheckCircle, + DirectionsCar, + Error as ErrorIcon, + PauseCircle, + School, + Search, + Warning, + ReportProblem, +} from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { vehiclesApi } from '../services/vehicles'; +import { + FahrzeugListItem, + FahrzeugStatus, + FahrzeugStatusLabel, + PruefungArt, + PruefungArtLabel, +} from '../types/vehicle.types'; + +// ── Status chip config ──────────────────────────────────────────────────────── + +const STATUS_CONFIG: Record< + FahrzeugStatus, + { color: 'success' | 'warning' | 'error' | 'info'; icon: React.ReactElement } +> = { + [FahrzeugStatus.Einsatzbereit]: { color: 'success', icon: }, + [FahrzeugStatus.AusserDienstWartung]: { color: 'warning', icon: }, + [FahrzeugStatus.AusserDienstSchaden]: { color: 'error', icon: }, + [FahrzeugStatus.InLehrgang]: { color: 'info', icon: }, +}; + +// ── Inspection badge helpers ────────────────────────────────────────────────── + +type InspBadgeColor = 'success' | 'warning' | 'error' | 'default'; + +function inspBadgeColor(tage: number | null): InspBadgeColor { + if (tage === null) return 'default'; + if (tage < 0) return 'error'; + if (tage <= 30) return 'warning'; + return 'success'; +} + +function inspBadgeLabel(art: string, tage: number | null, faelligAm: string | null): string { + const artShort = art; // 'HU', 'AU', etc. + if (faelligAm === null) return ''; + const date = new Date(faelligAm).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }); + if (tage === null) return `${artShort}: ${date}`; + if (tage < 0) return `${artShort}: ÜBERFÄLLIG (${date})`; + if (tage === 0) return `${artShort}: heute (${date})`; + return `${artShort}: ${date}`; +} + +// ── Vehicle Card ────────────────────────────────────────────────────────────── + +interface VehicleCardProps { + vehicle: FahrzeugListItem; + onClick: (id: string) => void; +} + +const VehicleCard: React.FC = ({ vehicle, onClick }) => { + const status = vehicle.status as FahrzeugStatus; + const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit]; + + const isSchaden = status === FahrzeugStatus.AusserDienstSchaden; + + // Collect inspection badges (only for types where a faellig_am exists) + const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [ + { art: 'HU', tage: vehicle.hu_tage_bis_faelligkeit, faelligAm: vehicle.hu_faellig_am }, + { art: 'AU', tage: vehicle.au_tage_bis_faelligkeit, faelligAm: vehicle.au_faellig_am }, + { art: 'UVV', tage: vehicle.uvv_tage_bis_faelligkeit, faelligAm: vehicle.uvv_faellig_am }, + { art: 'Leiter', tage: vehicle.leiter_tage_bis_faelligkeit, faelligAm: vehicle.leiter_faellig_am }, + ].filter((b) => b.faelligAm !== null); + + return ( + + {isSchaden && ( + + + + )} + + onClick(vehicle.id)} + sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }} + > + {/* Vehicle image / placeholder */} + {vehicle.bild_url ? ( + + ) : ( + + + + )} + + + {/* Title row */} + + + + {vehicle.bezeichnung} + {vehicle.kurzname && ( + + ({vehicle.kurzname}) + + )} + + {vehicle.amtliches_kennzeichen && ( + + {vehicle.amtliches_kennzeichen} + + )} + + + + {/* Status badge */} + + + + + {/* Crew config */} + {vehicle.besatzung_soll && ( + + Besatzung: {vehicle.besatzung_soll} + {vehicle.baujahr && ` · Bj. ${vehicle.baujahr}`} + + )} + + {/* Inspection badges */} + {inspBadges.length > 0 && ( + + {inspBadges.map((b) => { + const color = inspBadgeColor(b.tage); + const label = inspBadgeLabel(b.art, b.tage, b.faelligAm); + if (!label) return null; + return ( + + : undefined} + sx={{ fontSize: '0.7rem' }} + /> + + ); + })} + + )} + + + + ); +}; + +// ── Main Page ───────────────────────────────────────────────────────────────── function Fahrzeuge() { + const navigate = useNavigate(); + const [vehicles, setVehicles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + + const fetchVehicles = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await vehiclesApi.getAll(); + setVehicles(data); + } catch { + setError('Fahrzeuge konnten nicht geladen werden. Bitte versuchen Sie es erneut.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchVehicles(); }, [fetchVehicles]); + + const filtered = vehicles.filter((v) => { + if (!search.trim()) return true; + const q = search.toLowerCase(); + return ( + v.bezeichnung.toLowerCase().includes(q) || + (v.kurzname?.toLowerCase().includes(q) ?? false) || + (v.amtliches_kennzeichen?.toLowerCase().includes(q) ?? false) || + (v.hersteller?.toLowerCase().includes(q) ?? false) + ); + }); + + // Summary counts + const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length; + const hasOverdue = vehicles.some( + (v) => v.naechste_pruefung_tage !== null && v.naechste_pruefung_tage < 0 + ); + return ( - - - Fahrzeugverwaltung - - - - - - - - Fahrzeuge - - Diese Funktion wird in Kürze verfügbar sein + + {/* Header */} + + + + Fahrzeugverwaltung + + {!loading && ( + + {vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt + {' · '} + + {einsatzbereit} einsatzbereit - - - - - Geplante Features: -
    -
  • - - Fahrzeugliste mit Details - -
  • -
  • - - Wartungspläne und -historie - -
  • -
  • - - Tankbuch und Kilometerstände - -
  • -
  • - - TÜV/HU Erinnerungen - -
  • -
  • - - Fahrzeugdokumentation - -
  • -
-
-
-
+ )} + + + + {/* Overdue inspection global warning */} + {hasOverdue && ( + }> + Achtung: Mindestens ein Fahrzeug hat eine überfällige Prüfungsfrist. + Betroffene Fahrzeuge dürfen bis zur Durchführung der Prüfung ggf. nicht eingesetzt werden. + + )} + + {/* Search bar */} + setSearch(e.target.value)} + fullWidth + size="small" + sx={{ mb: 3, maxWidth: 480 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {/* Loading state */} + {loading && ( + + + + )} + + {/* Error state */} + {!loading && error && ( + + {error} + + )} + + {/* Empty state */} + {!loading && !error && filtered.length === 0 && ( + + + + {vehicles.length === 0 + ? 'Noch keine Fahrzeuge erfasst' + : 'Kein Fahrzeug entspricht dem Suchbegriff'} + + + )} + + {/* Vehicle grid */} + {!loading && !error && filtered.length > 0 && ( + + {filtered.map((vehicle) => ( + + navigate(`/fahrzeuge/${id}`)} + /> + + ))} + + )} + + {/* FAB — add vehicle (shown to write-role users only; role check done server-side) */} + navigate('/fahrzeuge/neu')} + > + +
); diff --git a/frontend/src/pages/Mitglieder.tsx b/frontend/src/pages/Mitglieder.tsx index be27a7a..400b0e7 100644 --- a/frontend/src/pages/Mitglieder.tsx +++ b/frontend/src/pages/Mitglieder.tsx @@ -1,67 +1,436 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Container, Typography, - Card, - CardContent, Box, + TextField, + InputAdornment, + Chip, + Avatar, + Fab, + Tooltip, + Alert, + CircularProgress, + FormControl, + InputLabel, + Select, + MenuItem, + OutlinedInput, + SelectChangeEvent, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Paper, } from '@mui/material'; -import { People } from '@mui/icons-material'; +import { + Search as SearchIcon, + Add as AddIcon, + People as PeopleIcon, +} from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useAuth } from '../contexts/AuthContext'; +import { membersService } from '../services/members'; +import { + MemberListItem, + StatusEnum, + DienstgradEnum, + STATUS_VALUES, + DIENSTGRAD_VALUES, + STATUS_LABELS, + STATUS_COLORS, + getMemberDisplayName, + formatPhone, +} from '../types/member.types'; +// ---------------------------------------------------------------- +// Helper: determine whether the current user can write member data +// ---------------------------------------------------------------- +function useCanWrite(): boolean { + const { user } = useAuth(); + const groups: string[] = (user as any)?.groups ?? []; + return groups.includes('feuerwehr-admin') || groups.includes('feuerwehr-kommandant'); +} + +// ---------------------------------------------------------------- +// Debounce hook +// ---------------------------------------------------------------- +function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(id); + }, [value, delay]); + return debounced; +} + +// ---------------------------------------------------------------- +// Component +// ---------------------------------------------------------------- function Mitglieder() { + const navigate = useNavigate(); + const canWrite = useCanWrite(); + + // --- data state --- + const [members, setMembers] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // --- filter / pagination state --- + const [searchInput, setSearchInput] = useState(''); + const debouncedSearch = useDebounce(searchInput, 300); + const [selectedStatus, setSelectedStatus] = useState([]); + const [selectedDienstgrad, setSelectedDienstgrad] = useState([]); + const [page, setPage] = useState(0); // MUI uses 0-based + const pageSize = 25; + + // Track previous debounced search to reset page + const prevSearch = useRef(debouncedSearch); + + // ---------------------------------------------------------------- + // Data fetching + // ---------------------------------------------------------------- + const fetchMembers = useCallback(async () => { + setLoading(true); + setError(null); + try { + const { items, total: t } = await membersService.getMembers({ + search: debouncedSearch || undefined, + status: selectedStatus.length > 0 ? selectedStatus : undefined, + dienstgrad: selectedDienstgrad.length > 0 ? selectedDienstgrad : undefined, + page: page + 1, // convert to 1-based for API + pageSize, + }); + setMembers(items); + setTotal(t); + } catch (err) { + setError('Mitglieder konnten nicht geladen werden. Bitte versuchen Sie es erneut.'); + } finally { + setLoading(false); + } + }, [debouncedSearch, selectedStatus, selectedDienstgrad, page]); + + useEffect(() => { + // Reset to page 0 when search changes + if (debouncedSearch !== prevSearch.current) { + prevSearch.current = debouncedSearch; + setPage(0); + return; + } + fetchMembers(); + }, [fetchMembers, debouncedSearch]); + + // Also fetch when page/filters change + useEffect(() => { + fetchMembers(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, selectedStatus, selectedDienstgrad]); + + // ---------------------------------------------------------------- + // Event handlers + // ---------------------------------------------------------------- + const handleStatusChange = (e: SelectChangeEvent) => { + setSelectedStatus(e.target.value as StatusEnum[]); + setPage(0); + }; + + const handleDienstgradChange = (e: SelectChangeEvent) => { + setSelectedDienstgrad(e.target.value as DienstgradEnum[]); + setPage(0); + }; + + const handleRowClick = (userId: string) => { + navigate(`/mitglieder/${userId}`); + }; + + const handleRemoveStatusChip = (status: StatusEnum) => { + setSelectedStatus((prev) => prev.filter((s) => s !== status)); + setPage(0); + }; + + const handleRemoveDienstgradChip = (dg: DienstgradEnum) => { + setSelectedDienstgrad((prev) => prev.filter((d) => d !== dg)); + setPage(0); + }; + + // ---------------------------------------------------------------- + // Render + // ---------------------------------------------------------------- + const activeFilters = selectedStatus.length + selectedDienstgrad.length; + return ( - - - Mitgliederverwaltung - + + {/* Page heading */} + + + Mitgliederverwaltung + + + {loading ? '...' : `${total} Mitglieder`} + + - - - - - - Mitglieder - - Diese Funktion wird in Kürze verfügbar sein - - - - - - Geplante Features: - -
    -
  • - - Mitgliederliste mit Kontaktdaten - -
  • -
  • - - Qualifikationen und Lehrgänge - -
  • -
  • - - Anwesenheitsverwaltung - -
  • -
  • - - Dienstpläne und -einteilungen - -
  • -
  • - - Atemschutz-G26 Untersuchungen - -
  • -
-
-
-
+ {/* Toolbar: search + filters */} + + setSearchInput(e.target.value)} + sx={{ flex: '1 1 280px', minWidth: 220 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {/* Status filter */} + + Status + + multiple + value={selectedStatus} + onChange={handleStatusChange} + input={} + renderValue={(selected) => + selected.length === 0 ? 'Alle' : `${selected.length} gewählt` + } + > + {STATUS_VALUES.map((s) => ( + + {STATUS_LABELS[s]} + + ))} + + + + {/* Dienstgrad filter */} + + Dienstgrad + + multiple + value={selectedDienstgrad} + onChange={handleDienstgradChange} + input={} + renderValue={(selected) => + selected.length === 0 ? 'Alle' : `${selected.length} gewählt` + } + > + {DIENSTGRAD_VALUES.map((dg) => ( + + {dg} + + ))} + + + + + {/* Active filter chips */} + {activeFilters > 0 && ( + + {selectedStatus.map((s) => ( + handleRemoveStatusChip(s)} + /> + ))} + {selectedDienstgrad.map((dg) => ( + handleRemoveDienstgradChip(dg)} + /> + ))} + + )} + + {/* Error state */} + {error && ( + setError(null)}> + {error} + + )} + + {/* Table */} + + + + + + Foto + Name + Mitgliedsnr. + Dienstgrad + Funktion + Status + Eintrittsdatum + Telefon + + + + {loading ? ( + + + + + + ) : members.length === 0 ? ( + + + + + + Keine Mitglieder gefunden. + + + + + ) : ( + members.map((member) => { + const displayName = getMemberDisplayName(member); + const initials = [member.given_name?.[0], member.family_name?.[0]] + .filter(Boolean) + .join('') + .toUpperCase() || member.email[0].toUpperCase(); + + return ( + handleRowClick(member.id)} + sx={{ cursor: 'pointer' }} + aria-label={`Mitglied ${displayName} öffnen`} + > + {/* Avatar */} + + + {initials} + + + + {/* Name + email */} + + + {displayName} + + + {member.email} + + + + {/* Mitgliedsnr */} + + + {member.mitglieds_nr ?? '—'} + + + + {/* Dienstgrad */} + + {member.dienstgrad ? ( + + ) : ( + + )} + + + {/* Funktion(en) */} + + + {member.funktion.length > 0 + ? member.funktion.map((f) => ( + + )) + : + } + + + + {/* Status */} + + {member.status ? ( + + ) : ( + + )} + + + {/* Eintrittsdatum */} + + + {member.eintrittsdatum + ? new Date(member.eintrittsdatum).toLocaleDateString('de-AT') + : '—'} + + + + {/* Telefon */} + + + {formatPhone(member.telefon_mobil)} + + + + ); + }) + )} + +
+
+ + setPage(newPage)} + rowsPerPage={pageSize} + rowsPerPageOptions={[pageSize]} + labelDisplayedRows={({ from, to, count }) => + `${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}` + } + /> +
+ + {/* FAB — only visible to Kommandant/Admin */} + {canWrite && ( + + navigate('/mitglieder/neu')} + sx={{ + position: 'fixed', + bottom: 32, + right: 32, + zIndex: (theme) => theme.zIndex.speedDial, + }} + > + + + + )}
); }