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

@@ -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>
);