add features
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user