add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:47:20 +01:00
parent 44e22a9fc6
commit c5e8337a69
11 changed files with 1554 additions and 194 deletions

View File

@@ -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",

View File

@@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/einsaetze/:id"
element={
<ProtectedRoute>
<EinsatzDetail />
</ProtectedRoute>
}
/>
<Route
path="/fahrzeuge"
element={
@@ -63,6 +76,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/fahrzeuge/:id"
element={
<ProtectedRoute>
<FahrzeugDetail />
</ProtectedRoute>
}
/>
<Route
path="/ausruestung"
element={
@@ -79,6 +100,30 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/mitglieder/:userId"
element={
<ProtectedRoute>
<MitgliedDetail />
</ProtectedRoute>
}
/>
<Route
path="/kalender"
element={
<ProtectedRoute>
<Kalender />
</ProtectedRoute>
}
/>
<Route
path="/training/:id"
element={
<ProtectedRoute>
<UebungDetail />
</ProtectedRoute>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</AuthProvider>

View File

@@ -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: <People />,
path: '/mitglieder',
},
{
text: 'Dienstkalender',
icon: <CalendarIcon />,
path: '/kalender',
},
];
interface SidebarProps {

View File

@@ -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<VehicleStats | null>(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() {
</Fade>
)}
</Grid>
{/* Live vehicle KPI — einsatzbereit count from API */}
<Grid item xs={12} sm={6} md={3}>
{dataLoading ? (
<SkeletonCard variant="basic" />
@@ -125,9 +137,13 @@ function Dashboard() {
<Fade in={true} timeout={600} style={{ transitionDelay: '350ms' }}>
<Box>
<StatsCard
title="Fahrzeuge"
value="5"
icon={LocalFireDepartment}
title="Fahrzeuge einsatzbereit"
value={
vehicleStats
? `${vehicleStats.einsatzbereit}/${vehicleStats.total}`
: '—'
}
icon={DirectionsCar}
color="success.main"
/>
</Box>
@@ -135,6 +151,15 @@ function Dashboard() {
)}
</Grid>
{/* Inspection Alerts Panel — safety-critical, shown immediately */}
<Grid item xs={12}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '380ms' }}>
<Box>
<InspectionAlerts daysAhead={30} hideWhenEmpty={true} />
</Box>
</Fade>
</Grid>
{/* Service Integration Cards */}
<Grid item xs={12}>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>

View File

@@ -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: <LocalFireDepartment sx={{ color: 'error.main' }} />,
},
{
label: 'Häufigste Einsatzart',
value: stats?.haeufigste_art
? EINSATZ_ART_LABELS[stats.haeufigste_art]
: '—',
icon: <Build sx={{ color: 'primary.main' }} />,
},
{
label: 'Ø Hilfsfrist',
value: stats?.avg_hilfsfrist_min !== null && stats?.avg_hilfsfrist_min !== undefined
? `${stats.avg_hilfsfrist_min} min`
: '—',
icon: <Warning sx={{ color: 'warning.main' }} />,
},
{
label: 'Abgeschlossen',
value: stats ? String(stats.abgeschlossen) : '—',
icon: <CheckCircle sx={{ color: 'success.main' }} />,
},
];
if (loading) {
return (
<Grid container spacing={2} sx={{ mb: 3 }}>
{[0, 1, 2, 3].map((i) => (
<Grid item xs={6} sm={3} key={i}>
<Card>
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Skeleton width="60%" height={18} />
<Skeleton width="40%" height={32} sx={{ mt: 0.5 }} />
</CardContent>
</Card>
</Grid>
))}
</Grid>
);
}
function Einsaetze() {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Einsatzübersicht
</Typography>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<LocalFireDepartment color="primary" sx={{ fontSize: 48, mr: 2 }} />
<Box>
<Typography variant="h6">Einsatzverwaltung</Typography>
<Typography variant="body2" color="text.secondary">
Diese Funktion wird in Kürze verfügbar sein
<Grid container spacing={2} sx={{ mb: 3 }}>
{items.map((item) => (
<Grid item xs={6} sm={3} key={item.label}>
<Card>
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
{item.icon}
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', fontSize: '0.68rem' }}>
{item.label}
</Typography>
</Box>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="body1" color="text.secondary" paragraph>
Geplante Features:
<Typography variant="h5" fontWeight={700}>
{item.value}
</Typography>
<ul>
<li>
<Typography variant="body2" color="text.secondary">
Einsatzliste mit Filteroptionen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Einsatzberichte erstellen und verwalten
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Statistiken und Auswertungen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Einsatzdokumentation
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Alarmstufen und Kategorien
</Typography>
</li>
</ul>
</Box>
</CardContent>
</Card>
</CardContent>
</Card>
</Grid>
))}
</Grid>
);
}
// ---------------------------------------------------------------------------
// MAIN PAGE
// ---------------------------------------------------------------------------
function Einsaetze() {
const navigate = useNavigate();
// List state
const [items, setItems] = useState<EinsatzListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
const [listLoading, setListLoading] = useState(true);
const [listError, setListError] = useState<string | null>(null);
// Filters
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [selectedArts, setSelectedArts] = useState<EinsatzArt[]>([]);
// Stats
const [stats, setStats] = useState<EinsatzStats | null>(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<string, unknown> = {
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<typeof incidentsApi.getAll>[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<HTMLInputElement>) => {
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 (
<DashboardLayout>
<Container maxWidth="xl">
{/* Page header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Einsatzübersicht
</Typography>
<Typography variant="body2" color="text.secondary">
Einsatzprotokoll Feuerwehr Rems
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Tooltip title="Statistik aktualisieren">
<IconButton onClick={fetchStats} size="small">
<Refresh />
</IconButton>
</Tooltip>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={() => setCreateOpen(true)}
>
Neuer Einsatz
</Button>
</Stack>
</Box>
{/* KPI summary cards */}
<StatsSummaryBar stats={stats} loading={statsLoading} />
{/* Charts */}
<Box sx={{ mb: 3 }}>
<IncidentStatsChart stats={stats} loading={statsLoading} />
</Box>
{/* Filters */}
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<FilterList fontSize="small" color="action" />
<Typography variant="subtitle2">Filter</Typography>
{(dateFrom || dateTo || selectedArts.length > 0) && (
<Button size="small" onClick={handleResetFilters} sx={{ ml: 'auto' }}>
Filter zurücksetzen
</Button>
)}
</Box>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} sm={4} md={3}>
<TextField
label="Von (Alarmzeit)"
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(0); }}
InputLabelProps={{ shrink: true }}
size="small"
fullWidth
inputProps={{ 'aria-label': 'Von-Datum' }}
/>
</Grid>
<Grid item xs={12} sm={4} md={3}>
<TextField
label="Bis (Alarmzeit)"
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(0); }}
InputLabelProps={{ shrink: true }}
size="small"
fullWidth
inputProps={{ 'aria-label': 'Bis-Datum' }}
/>
</Grid>
</Grid>
{/* Einsatzart chips */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5 }}>
{EINSATZ_ARTEN.map((art) => (
<Chip
key={art}
label={EINSATZ_ART_LABELS[art]}
color={selectedArts.includes(art) ? ART_CHIP_COLOR[art] : 'default'}
variant={selectedArts.includes(art) ? 'filled' : 'outlined'}
size="small"
onClick={() => toggleArtFilter(art)}
clickable
/>
))}
</Box>
</Paper>
{/* Error state */}
{listError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setListError(null)}>
{listError}
</Alert>
)}
{/* Incident table */}
<Paper>
<TableContainer>
<Table size="small" aria-label="Einsatzliste">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 700, whiteSpace: 'nowrap' }}>Datum / Uhrzeit</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Nr.</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Einsatzart</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Stichwort</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Ort</TableCell>
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Hilfsfrist</TableCell>
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Dauer</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Status</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Einsatzleiter</TableCell>
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Kräfte</TableCell>
</TableRow>
</TableHead>
<TableBody>
{listLoading
? Array.from({ length: rowsPerPage > 10 ? 10 : rowsPerPage }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 10 }).map((__, j) => (
<TableCell key={j}>
<Skeleton variant="text" />
</TableCell>
))}
</TableRow>
))
: items.length === 0
? (
<TableRow>
<TableCell colSpan={10} align="center" sx={{ py: 6 }}>
<Box sx={{ color: 'text.disabled', fontSize: 48, mb: 1 }}>
<LocalFireDepartment fontSize="inherit" />
</Box>
<Typography variant="body1" color="text.secondary">
Keine Einsätze gefunden
</Typography>
</TableCell>
</TableRow>
)
: items.map((row) => (
<TableRow
key={row.id}
hover
onClick={() => handleRowClick(row.id)}
sx={{ cursor: 'pointer' }}
>
<TableCell sx={{ whiteSpace: 'nowrap', fontSize: '0.8125rem' }}>
{formatDE(row.alarm_time)}
</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{row.einsatz_nr}
</TableCell>
<TableCell>
<Chip
label={row.einsatz_art}
color={ART_CHIP_COLOR[row.einsatz_art]}
size="small"
sx={{ fontSize: '0.7rem' }}
/>
</TableCell>
<TableCell sx={{ fontSize: '0.8125rem' }}>
{row.einsatz_stichwort ?? '—'}
</TableCell>
<TableCell sx={{ fontSize: '0.8125rem' }}>
{[row.strasse, row.ort].filter(Boolean).join(', ') || '—'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8125rem', whiteSpace: 'nowrap' }}>
{durationLabel(row.hilfsfrist_min)}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8125rem', whiteSpace: 'nowrap' }}>
{durationLabel(row.dauer_min)}
</TableCell>
<TableCell>
<Chip
label={EINSATZ_STATUS_LABELS[row.status]}
color={STATUS_CHIP_COLOR[row.status]}
size="small"
sx={{ fontSize: '0.7rem' }}
/>
</TableCell>
<TableCell sx={{ fontSize: '0.8125rem' }}>
{row.einsatzleiter_name ?? '—'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8125rem' }}>
{row.personal_count > 0 ? row.personal_count : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={handlePageChange}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleRowsPerPageChange}
rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage="Einträge pro Seite:"
labelDisplayedRows={({ from, to, count }) =>
`${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}`
}
/>
</Paper>
{/* Create dialog */}
<CreateEinsatzDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={handleCreateSuccess}
/>
</Container>
</DashboardLayout>
);

View File

@@ -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: <CheckCircle fontSize="small" /> },
[FahrzeugStatus.AusserDienstWartung]: { color: 'warning', icon: <PauseCircle fontSize="small" /> },
[FahrzeugStatus.AusserDienstSchaden]: { color: 'error', icon: <ErrorIcon fontSize="small" /> },
[FahrzeugStatus.InLehrgang]: { color: 'info', icon: <School fontSize="small" /> },
};
// ── 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<VehicleCardProps> = ({ 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 (
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
border: isSchaden ? '2px solid' : undefined,
borderColor: isSchaden ? 'error.main' : undefined,
position: 'relative',
}}
>
{isSchaden && (
<Tooltip title="Fahrzeug außer Dienst (Schaden) — nicht einsatzbereit!">
<ReportProblem
color="error"
sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1 }}
/>
</Tooltip>
)}
<CardActionArea
onClick={() => onClick(vehicle.id)}
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
>
{/* Vehicle image / placeholder */}
{vehicle.bild_url ? (
<CardMedia
component="img"
height="140"
image={vehicle.bild_url}
alt={vehicle.bezeichnung}
sx={{ objectFit: 'cover' }}
/>
) : (
<Box
sx={{
height: 120,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'action.hover',
}}
>
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled' }} />
</Box>
)}
<CardContent sx={{ flexGrow: 1, pb: '8px !important' }}>
{/* Title row */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 0.5 }}>
<Box>
<Typography variant="h6" component="div" lineHeight={1.2}>
{vehicle.bezeichnung}
{vehicle.kurzname && (
<Typography component="span" variant="body2" color="text.secondary" sx={{ ml: 1 }}>
({vehicle.kurzname})
</Typography>
)}
</Typography>
{vehicle.amtliches_kennzeichen && (
<Typography variant="body2" color="text.secondary">
{vehicle.amtliches_kennzeichen}
</Typography>
)}
</Box>
</Box>
{/* Status badge */}
<Box sx={{ mb: 1 }}>
<Chip
icon={statusCfg.icon}
label={FahrzeugStatusLabel[status]}
color={statusCfg.color}
size="small"
variant="outlined"
/>
</Box>
{/* Crew config */}
{vehicle.besatzung_soll && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Besatzung: {vehicle.besatzung_soll}
{vehicle.baujahr && ` · Bj. ${vehicle.baujahr}`}
</Typography>
)}
{/* Inspection badges */}
{inspBadges.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{inspBadges.map((b) => {
const color = inspBadgeColor(b.tage);
const label = inspBadgeLabel(b.art, b.tage, b.faelligAm);
if (!label) return null;
return (
<Tooltip
key={b.art}
title={`${PruefungArtLabel[b.art as PruefungArt] ?? b.art}: ${
b.tage !== null && b.tage < 0
? `Seit ${Math.abs(b.tage)} Tagen überfällig!`
: `Fällig am ${new Date(b.faelligAm!).toLocaleDateString('de-DE')}`
}`}
>
<Chip
size="small"
label={label}
color={color}
variant={color === 'default' ? 'outlined' : 'filled'}
icon={b.tage !== null && b.tage < 0 ? <Warning fontSize="small" /> : undefined}
sx={{ fontSize: '0.7rem' }}
/>
</Tooltip>
);
})}
</Box>
)}
</CardContent>
</CardActionArea>
</Card>
);
};
// ── Main Page ─────────────────────────────────────────────────────────────────
function Fahrzeuge() {
const navigate = useNavigate();
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Fahrzeugverwaltung
</Typography>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<DirectionsCar color="primary" sx={{ fontSize: 48, mr: 2 }} />
<Box>
<Typography variant="h6">Fahrzeuge</Typography>
<Typography variant="body2" color="text.secondary">
Diese Funktion wird in Kürze verfügbar sein
<Container maxWidth="xl">
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
Fahrzeugverwaltung
</Typography>
{!loading && (
<Typography variant="body2" color="text.secondary">
{vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt
{' · '}
<Typography
component="span"
variant="body2"
color="success.main"
fontWeight={600}
>
{einsatzbereit} einsatzbereit
</Typography>
</Box>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="body1" color="text.secondary" paragraph>
Geplante Features:
</Typography>
<ul>
<li>
<Typography variant="body2" color="text.secondary">
Fahrzeugliste mit Details
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Wartungspläne und -historie
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Tankbuch und Kilometerstände
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
TÜV/HU Erinnerungen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Fahrzeugdokumentation
</Typography>
</li>
</ul>
</Box>
</CardContent>
</Card>
)}
</Box>
</Box>
{/* Overdue inspection global warning */}
{hasOverdue && (
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungsfrist.
Betroffene Fahrzeuge dürfen bis zur Durchführung der Prüfung ggf. nicht eingesetzt werden.
</Alert>
)}
{/* Search bar */}
<TextField
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
value={search}
onChange={(e) => setSearch(e.target.value)}
fullWidth
size="small"
sx={{ mb: 3, maxWidth: 480 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
{/* Loading state */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{/* Error state */}
{!loading && error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* Empty state */}
{!loading && !error && filtered.length === 0 && (
<Box sx={{ textAlign: 'center', py: 8 }}>
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
{vehicles.length === 0
? 'Noch keine Fahrzeuge erfasst'
: 'Kein Fahrzeug entspricht dem Suchbegriff'}
</Typography>
</Box>
)}
{/* Vehicle grid */}
{!loading && !error && filtered.length > 0 && (
<Grid container spacing={3}>
{filtered.map((vehicle) => (
<Grid item key={vehicle.id} xs={12} sm={6} md={4} lg={3}>
<VehicleCard
vehicle={vehicle}
onClick={(id) => navigate(`/fahrzeuge/${id}`)}
/>
</Grid>
))}
</Grid>
)}
{/* FAB — add vehicle (shown to write-role users only; role check done server-side) */}
<Fab
color="primary"
aria-label="Fahrzeug hinzufügen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => navigate('/fahrzeuge/neu')}
>
<Add />
</Fab>
</Container>
</DashboardLayout>
);

View File

@@ -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<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState<T>(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<MemberListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// --- filter / pagination state ---
const [searchInput, setSearchInput] = useState('');
const debouncedSearch = useDebounce(searchInput, 300);
const [selectedStatus, setSelectedStatus] = useState<StatusEnum[]>([]);
const [selectedDienstgrad, setSelectedDienstgrad] = useState<DienstgradEnum[]>([]);
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<StatusEnum[]>) => {
setSelectedStatus(e.target.value as StatusEnum[]);
setPage(0);
};
const handleDienstgradChange = (e: SelectChangeEvent<DienstgradEnum[]>) => {
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 (
<DashboardLayout>
<Container maxWidth="lg">
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
Mitgliederverwaltung
</Typography>
<Container maxWidth="xl">
{/* Page heading */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3, gap: 2, flexWrap: 'wrap' }}>
<Typography variant="h4" sx={{ flex: 1 }}>
Mitgliederverwaltung
</Typography>
<Typography variant="body2" color="text.secondary">
{loading ? '...' : `${total} Mitglieder`}
</Typography>
</Box>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<People color="primary" sx={{ fontSize: 48, mr: 2 }} />
<Box>
<Typography variant="h6">Mitglieder</Typography>
<Typography variant="body2" color="text.secondary">
Diese Funktion wird in Kürze verfügbar sein
</Typography>
</Box>
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="body1" color="text.secondary" paragraph>
Geplante Features:
</Typography>
<ul>
<li>
<Typography variant="body2" color="text.secondary">
Mitgliederliste mit Kontaktdaten
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Qualifikationen und Lehrgänge
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Anwesenheitsverwaltung
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Dienstpläne und -einteilungen
</Typography>
</li>
<li>
<Typography variant="body2" color="text.secondary">
Atemschutz-G26 Untersuchungen
</Typography>
</li>
</ul>
</Box>
</CardContent>
</Card>
{/* Toolbar: search + filters */}
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
<TextField
placeholder="Suche nach Name, E-Mail oder Mitgliedsnummer…"
size="small"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
sx={{ flex: '1 1 280px', minWidth: 220 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
/>
{/* Status filter */}
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select<StatusEnum[]>
multiple
value={selectedStatus}
onChange={handleStatusChange}
input={<OutlinedInput label="Status" />}
renderValue={(selected) =>
selected.length === 0 ? 'Alle' : `${selected.length} gewählt`
}
>
{STATUS_VALUES.map((s) => (
<MenuItem key={s} value={s}>
{STATUS_LABELS[s]}
</MenuItem>
))}
</Select>
</FormControl>
{/* Dienstgrad filter */}
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Dienstgrad</InputLabel>
<Select<DienstgradEnum[]>
multiple
value={selectedDienstgrad}
onChange={handleDienstgradChange}
input={<OutlinedInput label="Dienstgrad" />}
renderValue={(selected) =>
selected.length === 0 ? 'Alle' : `${selected.length} gewählt`
}
>
{DIENSTGRAD_VALUES.map((dg) => (
<MenuItem key={dg} value={dg}>
{dg}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{/* Active filter chips */}
{activeFilters > 0 && (
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
{selectedStatus.map((s) => (
<Chip
key={s}
label={STATUS_LABELS[s]}
size="small"
color={STATUS_COLORS[s]}
onDelete={() => handleRemoveStatusChip(s)}
/>
))}
{selectedDienstgrad.map((dg) => (
<Chip
key={dg}
label={dg}
size="small"
onDelete={() => handleRemoveDienstgradChip(dg)}
/>
))}
</Box>
)}
{/* Error state */}
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Table */}
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
<TableContainer>
<Table stickyHeader size="small" aria-label="Mitgliederliste">
<TableHead>
<TableRow>
<TableCell sx={{ width: 56 }}>Foto</TableCell>
<TableCell>Name</TableCell>
<TableCell>Mitgliedsnr.</TableCell>
<TableCell>Dienstgrad</TableCell>
<TableCell>Funktion</TableCell>
<TableCell>Status</TableCell>
<TableCell>Eintrittsdatum</TableCell>
<TableCell>Telefon</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 6 }}>
<CircularProgress size={32} />
</TableCell>
</TableRow>
) : members.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<PeopleIcon sx={{ fontSize: 48, color: 'text.disabled' }} />
<Typography color="text.secondary">
Keine Mitglieder gefunden.
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
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 (
<TableRow
key={member.id}
hover
onClick={() => handleRowClick(member.id)}
sx={{ cursor: 'pointer' }}
aria-label={`Mitglied ${displayName} öffnen`}
>
{/* Avatar */}
<TableCell>
<Avatar
src={member.profile_picture_url ?? undefined}
alt={displayName}
sx={{ width: 36, height: 36, fontSize: '0.875rem' }}
>
{initials}
</Avatar>
</TableCell>
{/* Name + email */}
<TableCell>
<Typography variant="body2" fontWeight={500}>
{displayName}
</Typography>
<Typography variant="caption" color="text.secondary">
{member.email}
</Typography>
</TableCell>
{/* Mitgliedsnr */}
<TableCell>
<Typography variant="body2">
{member.mitglieds_nr ?? '—'}
</Typography>
</TableCell>
{/* Dienstgrad */}
<TableCell>
{member.dienstgrad ? (
<Chip label={member.dienstgrad} size="small" variant="outlined" />
) : (
<Typography variant="body2" color="text.secondary"></Typography>
)}
</TableCell>
{/* Funktion(en) */}
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{member.funktion.length > 0
? member.funktion.map((f) => (
<Chip key={f} label={f} size="small" variant="outlined" color="secondary" />
))
: <Typography variant="body2" color="text.secondary"></Typography>
}
</Box>
</TableCell>
{/* Status */}
<TableCell>
{member.status ? (
<Chip
label={STATUS_LABELS[member.status]}
size="small"
color={STATUS_COLORS[member.status]}
/>
) : (
<Typography variant="body2" color="text.secondary"></Typography>
)}
</TableCell>
{/* Eintrittsdatum */}
<TableCell>
<Typography variant="body2">
{member.eintrittsdatum
? new Date(member.eintrittsdatum).toLocaleDateString('de-AT')
: '—'}
</Typography>
</TableCell>
{/* Telefon */}
<TableCell>
<Typography variant="body2">
{formatPhone(member.telefon_mobil)}
</Typography>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(_e, newPage) => setPage(newPage)}
rowsPerPage={pageSize}
rowsPerPageOptions={[pageSize]}
labelDisplayedRows={({ from, to, count }) =>
`${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}`
}
/>
</Paper>
</Container>
{/* FAB — only visible to Kommandant/Admin */}
{canWrite && (
<Tooltip title="Neues Mitglied anlegen">
<Fab
color="primary"
aria-label="Neues Mitglied anlegen"
onClick={() => navigate('/mitglieder/neu')}
sx={{
position: 'fixed',
bottom: 32,
right: 32,
zIndex: (theme) => theme.zIndex.speedDial,
}}
>
<AddIcon />
</Fab>
</Tooltip>
)}
</DashboardLayout>
);
}