Files
dashboard/frontend/src/pages/Einsaetze.tsx
Matthias Hochmeister c5e8337a69 add features
2026-02-27 19:47:20 +01:00

507 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
Skeleton,
IconButton,
Tooltip,
Alert,
Stack,
} from '@mui/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>
);
}
return (
<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>
<Typography variant="h5" fontWeight={700}>
{item.value}
</Typography>
</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>
);
}
export default Einsaetze;