add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:50:14 +01:00
parent c5e8337a69
commit 620bacc6b5
46 changed files with 14095 additions and 1 deletions

View File

@@ -0,0 +1,266 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Grid,
MenuItem,
CircularProgress,
Alert,
Typography,
} from '@mui/material';
import { incidentsApi, EINSATZ_ARTEN, EINSATZ_ART_LABELS, CreateEinsatzPayload } from '../../services/incidents';
import { useNotification } from '../../contexts/NotificationContext';
interface CreateEinsatzDialogProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
// Default alarm_time = now (rounded to minute)
function nowISO(): string {
const d = new Date();
d.setSeconds(0, 0);
return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
}
const INITIAL_FORM: CreateEinsatzPayload & { alarm_time_local: string } = {
alarm_time: '',
alarm_time_local: nowISO(),
einsatz_art: 'Brand',
einsatz_stichwort: '',
strasse: '',
hausnummer: '',
ort: '',
bericht_kurz: '',
alarmierung_art: 'ILS',
status: 'aktiv',
};
const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
open,
onClose,
onSuccess,
}) => {
const notification = useNotification();
const [form, setForm] = useState({ ...INITIAL_FORM, alarm_time_local: nowISO() });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!form.alarm_time_local) {
setError('Alarmzeit ist pflicht.');
return;
}
if (!form.einsatz_art) {
setError('Einsatzart ist pflicht.');
return;
}
setLoading(true);
try {
// Convert local datetime string to UTC ISO string
const payload: CreateEinsatzPayload = {
alarm_time: new Date(form.alarm_time_local).toISOString(),
einsatz_art: form.einsatz_art,
einsatz_stichwort: form.einsatz_stichwort || null,
strasse: form.strasse || null,
hausnummer: form.hausnummer || null,
ort: form.ort || null,
bericht_kurz: form.bericht_kurz || null,
alarmierung_art: form.alarmierung_art || 'ILS',
status: form.status || 'aktiv',
};
await incidentsApi.create(payload);
notification.showSuccess('Einsatz erfolgreich angelegt');
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() });
onSuccess();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Fehler beim Anlegen des Einsatzes';
setError(msg);
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (loading) return;
setError(null);
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() });
onClose();
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
PaperProps={{ component: 'form', onSubmit: handleSubmit }}
>
<DialogTitle>
<Typography variant="h6" component="div">
Neuen Einsatz anlegen
</Typography>
</DialogTitle>
<DialogContent dividers>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<Grid container spacing={2}>
{/* Alarmzeit — most important field */}
<Grid item xs={12} sm={7}>
<TextField
label="Alarmzeit *"
name="alarm_time_local"
type="datetime-local"
value={form.alarm_time_local}
onChange={handleChange}
InputLabelProps={{ shrink: true }}
fullWidth
required
helperText="DD.MM.YYYY HH:mm"
inputProps={{
'aria-label': 'Alarmzeit',
// HTML datetime-local uses YYYY-MM-DDTHH:mm format
}}
/>
</Grid>
{/* Einsatzart */}
<Grid item xs={12} sm={5}>
<TextField
label="Einsatzart *"
name="einsatz_art"
select
value={form.einsatz_art}
onChange={handleChange}
fullWidth
required
>
{EINSATZ_ARTEN.map((art) => (
<MenuItem key={art} value={art}>
{EINSATZ_ART_LABELS[art]}
</MenuItem>
))}
</TextField>
</Grid>
{/* Stichwort */}
<Grid item xs={12} sm={5}>
<TextField
label="Einsatzstichwort"
name="einsatz_stichwort"
value={form.einsatz_stichwort ?? ''}
onChange={handleChange}
fullWidth
placeholder="z.B. B2, THL 1"
inputProps={{ maxLength: 30 }}
/>
</Grid>
{/* Alarmierungsart */}
<Grid item xs={12} sm={7}>
<TextField
label="Alarmierungsart"
name="alarmierung_art"
select
value={form.alarmierung_art}
onChange={handleChange}
fullWidth
>
{['ILS', 'DME', 'Telefon', 'Vor_Ort', 'Sonstiges'].map((a) => (
<MenuItem key={a} value={a}>
{a === 'Vor_Ort' ? 'Vor Ort' : a}
</MenuItem>
))}
</TextField>
</Grid>
{/* Location */}
<Grid item xs={12} sm={8}>
<TextField
label="Straße"
name="strasse"
value={form.strasse ?? ''}
onChange={handleChange}
fullWidth
inputProps={{ maxLength: 150 }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Hausnr."
name="hausnummer"
value={form.hausnummer ?? ''}
onChange={handleChange}
fullWidth
inputProps={{ maxLength: 20 }}
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Ort"
name="ort"
value={form.ort ?? ''}
onChange={handleChange}
fullWidth
inputProps={{ maxLength: 100 }}
/>
</Grid>
{/* Short description */}
<Grid item xs={12}>
<TextField
label="Kurzbeschreibung"
name="bericht_kurz"
value={form.bericht_kurz ?? ''}
onChange={handleChange}
fullWidth
multiline
rows={2}
inputProps={{ maxLength: 255 }}
helperText={`${(form.bericht_kurz ?? '').length}/255`}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={handleClose} disabled={loading}>
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={loading}
startIcon={loading ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{loading ? 'Speichere...' : 'Einsatz anlegen'}
</Button>
</DialogActions>
</Dialog>
);
};
export default CreateEinsatzDialog;

View File

@@ -0,0 +1,258 @@
import React from 'react';
import {
Box,
Card,
CardContent,
Typography,
useTheme,
Skeleton,
Grid,
} from '@mui/material';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
PieChart,
Pie,
Cell,
ResponsiveContainer,
} from 'recharts';
import { EinsatzStats, EINSATZ_ART_LABELS, EinsatzArt } from '../../services/incidents';
// ---------------------------------------------------------------------------
// MONTH LABELS (German locale — avoids date-fns dependency for short labels)
// ---------------------------------------------------------------------------
const MONAT_KURZ = [
'', // 1-indexed
'Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez',
];
// ---------------------------------------------------------------------------
// PIE CHART COLORS — keyed by EinsatzArt
// Uses MUI theme palette where possible, fallback to a curated palette
// ---------------------------------------------------------------------------
const ART_COLORS: Record<EinsatzArt, string> = {
Brand: '#d32f2f',
THL: '#1976d2',
ABC: '#7b1fa2',
BMA: '#f57c00',
Hilfeleistung: '#388e3c',
Fehlalarm: '#757575',
Brandsicherheitswache: '#0288d1',
};
// ---------------------------------------------------------------------------
// HELPERS
// ---------------------------------------------------------------------------
/** Build a full 12-month array from sparse MonthlyStatRow data */
function buildMonthlyData(
thisYear: { monat: number; anzahl: number }[],
prevYear: { monat: number; anzahl: number }[],
currentYear: number
) {
const map = new Map<number, { thisYear: number; prevYear: number }>();
for (let m = 1; m <= 12; m++) {
map.set(m, { thisYear: 0, prevYear: 0 });
}
for (const row of thisYear) {
const existing = map.get(row.monat)!;
map.set(row.monat, { ...existing, thisYear: row.anzahl });
}
for (const row of prevYear) {
const existing = map.get(row.monat)!;
map.set(row.monat, { ...existing, prevYear: row.anzahl });
}
return Array.from(map.entries()).map(([monat, counts]) => ({
monat: MONAT_KURZ[monat],
[String(currentYear)]: counts.thisYear,
[String(currentYear - 1)]: counts.prevYear,
}));
}
// ---------------------------------------------------------------------------
// COMPONENT
// ---------------------------------------------------------------------------
interface IncidentStatsChartProps {
stats: EinsatzStats | null;
loading?: boolean;
}
const IncidentStatsChart: React.FC<IncidentStatsChartProps> = ({ stats, loading = false }) => {
const theme = useTheme();
if (loading || !stats) {
return (
<Grid container spacing={3}>
<Grid item xs={12} md={7}>
<Card>
<CardContent>
<Skeleton variant="text" width={200} height={28} sx={{ mb: 2 }} />
<Skeleton variant="rectangular" height={260} />
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={5}>
<Card>
<CardContent>
<Skeleton variant="text" width={160} height={28} sx={{ mb: 2 }} />
<Skeleton variant="circular" width={220} height={220} sx={{ mx: 'auto' }} />
</CardContent>
</Card>
</Grid>
</Grid>
);
}
const monthlyData = buildMonthlyData(
stats.monthly,
stats.prev_year_monthly,
stats.jahr
);
const pieData = stats.by_art.map((row) => ({
name: EINSATZ_ART_LABELS[row.einsatz_art],
value: row.anzahl,
art: row.einsatz_art,
}));
const thisYearKey = String(stats.jahr);
const prevYearKey = String(stats.jahr - 1);
return (
<Grid container spacing={3}>
{/* BAR CHART: Incidents per month (year-over-year) */}
<Grid item xs={12} md={7}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Einsätze pro Monat {stats.jahr} vs. {stats.jahr - 1}
</Typography>
<Box sx={{ width: '100%', height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={monthlyData}
margin={{ top: 4, right: 16, left: -8, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke={theme.palette.divider}
/>
<XAxis
dataKey="monat"
tick={{ fontSize: 12, fill: theme.palette.text.secondary }}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 12, fill: theme.palette.text.secondary }}
/>
<Tooltip
contentStyle={{
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend
wrapperStyle={{ fontSize: 13 }}
/>
<Bar
dataKey={thisYearKey}
name={thisYearKey}
fill={theme.palette.primary.main}
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey={prevYearKey}
name={prevYearKey}
fill={theme.palette.secondary.light}
radius={[4, 4, 0, 0]}
opacity={0.7}
/>
</BarChart>
</ResponsiveContainer>
</Box>
</CardContent>
</Card>
</Grid>
{/* PIE CHART: Incidents by Einsatzart */}
<Grid item xs={12} md={5}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Einsatzarten {stats.jahr}
</Typography>
{pieData.length === 0 ? (
<Box
sx={{
height: 260,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="body2" color="text.secondary">
Keine Daten für dieses Jahr
</Typography>
</Box>
) : (
<Box sx={{ width: '100%', height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="45%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
label={({ name, percent }) =>
percent > 0.05 ? `${(percent * 100).toFixed(0)}%` : ''
}
labelLine={false}
>
{pieData.map((entry) => (
<Cell
key={entry.art}
fill={ART_COLORS[entry.art as EinsatzArt] ?? theme.palette.grey[500]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number, name: string) => [
`${value} Einsätze`,
name,
]}
contentStyle={{
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
fontSize: 13,
}}
/>
<Legend
iconType="circle"
iconSize={10}
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
/>
</PieChart>
</ResponsiveContainer>
</Box>
)}
</CardContent>
</Card>
</Grid>
</Grid>
);
};
export default IncidentStatsChart;