add features
This commit is contained in:
266
frontend/src/components/incidents/CreateEinsatzDialog.tsx
Normal file
266
frontend/src/components/incidents/CreateEinsatzDialog.tsx
Normal 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;
|
||||
258
frontend/src/components/incidents/IncidentStatsChart.tsx
Normal file
258
frontend/src/components/incidents/IncidentStatsChart.tsx
Normal 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;
|
||||
247
frontend/src/components/training/UpcomingEvents.tsx
Normal file
247
frontend/src/components/training/UpcomingEvents.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Typography,
|
||||
Button,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckIcon,
|
||||
Cancel as CancelIcon,
|
||||
HelpOutline as UnknownIcon,
|
||||
Star as StarIcon,
|
||||
CalendarMonth as CalendarIcon,
|
||||
ArrowForward as ArrowIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { trainingApi } from '../../services/training';
|
||||
import type { UebungListItem, TeilnahmeStatus, UebungTyp } from '../../types/training.types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TYP_COLORS: Record<UebungTyp, 'primary' | 'secondary' | 'warning' | 'default' | 'error' | 'info' | 'success'> = {
|
||||
'Übungsabend': 'primary',
|
||||
'Lehrgang': 'secondary',
|
||||
'Sonderdienst': 'warning',
|
||||
'Versammlung': 'default',
|
||||
'Gemeinschaftsübung': 'info',
|
||||
'Sonstiges': 'default',
|
||||
};
|
||||
|
||||
const WEEKDAY_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||||
|
||||
function formatEventDate(isoString: string): string {
|
||||
const d = new Date(isoString);
|
||||
return `${WEEKDAY_SHORT[d.getDay()]}, ${String(d.getDate()).padStart(2, '0')}. ${MONTH_SHORT[d.getMonth()]}`;
|
||||
}
|
||||
|
||||
function formatEventTime(vonIso: string, bisIso: string): string {
|
||||
const von = new Date(vonIso);
|
||||
const bis = new Date(bisIso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(von.getHours())}:${pad(von.getMinutes())} – ${pad(bis.getHours())}:${pad(bis.getMinutes())} Uhr`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RSVP Status badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RsvpBadge({ status }: { status: TeilnahmeStatus | undefined }) {
|
||||
if (!status || status === 'unbekannt') {
|
||||
return (
|
||||
<Tooltip title="Noch keine Rückmeldung">
|
||||
<UnknownIcon sx={{ color: 'text.disabled', fontSize: 20 }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (status === 'zugesagt') {
|
||||
return (
|
||||
<Tooltip title="Zugesagt">
|
||||
<CheckIcon sx={{ color: 'success.main', fontSize: 20 }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (status === 'erschienen') {
|
||||
return (
|
||||
<Tooltip title="Erschienen">
|
||||
<CheckIcon sx={{ color: 'success.dark', fontSize: 20 }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (status === 'abgesagt' || status === 'entschuldigt') {
|
||||
return (
|
||||
<Tooltip title={status === 'entschuldigt' ? 'Entschuldigt' : 'Abgesagt'}>
|
||||
<CancelIcon sx={{ color: 'error.main', fontSize: 20 }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single event row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EventRow({ event }: { event: UebungListItem }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
disablePadding
|
||||
onClick={() => navigate(`/training/${event.id}`)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
mb: 0.5,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': { backgroundColor: 'action.hover' },
|
||||
opacity: event.abgesagt ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
{/* Date column */}
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 72,
|
||||
mr: 1.5,
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
color: 'text.secondary',
|
||||
lineHeight: 1.2,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
{formatEventDate(event.datum_von)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
color: 'text.disabled',
|
||||
fontSize: '0.65rem',
|
||||
}}
|
||||
>
|
||||
{formatEventTime(event.datum_von, event.datum_bis)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Title + chip */}
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{event.pflichtveranstaltung && (
|
||||
<StarIcon sx={{ fontSize: 14, color: 'warning.main', flexShrink: 0 }} />
|
||||
)}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: event.pflichtveranstaltung ? 700 : 400,
|
||||
textDecoration: event.abgesagt ? 'line-through' : 'none',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{event.titel}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Chip
|
||||
label={event.typ}
|
||||
size="small"
|
||||
color={TYP_COLORS[event.typ]}
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.65rem', height: 18, mt: 0.25 }}
|
||||
/>
|
||||
}
|
||||
sx={{ my: 0 }}
|
||||
/>
|
||||
|
||||
{/* RSVP status */}
|
||||
<Box sx={{ ml: 1, flexShrink: 0 }}>
|
||||
<RsvpBadge status={event.eigener_status} />
|
||||
</Box>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main widget component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function UpcomingEvents() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['training', 'upcoming', 3],
|
||||
queryFn: () => trainingApi.getUpcoming(3),
|
||||
staleTime: 5 * 60 * 1000, // 5 min
|
||||
});
|
||||
|
||||
const events = useMemo(() => data ?? [], [data]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1, gap: 1 }}>
|
||||
<CalendarIcon color="primary" fontSize="small" />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
Nächste Dienste
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoading && (
|
||||
<Box>
|
||||
{[1, 2, 3].map((n) => (
|
||||
<Skeleton key={n} variant="rectangular" height={56} sx={{ borderRadius: 1, mb: 0.5 }} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Dienste konnten nicht geladen werden.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && events.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Keine bevorstehenden Veranstaltungen.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && events.length > 0 && (
|
||||
<List dense disablePadding>
|
||||
{events.map((event) => (
|
||||
<EventRow key={event.id} event={event} />
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<ArrowIcon />}
|
||||
onClick={() => navigate('/kalender')}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Zum Kalender
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
159
frontend/src/components/vehicles/InspectionAlerts.tsx
Normal file
159
frontend/src/components/vehicles/InspectionAlerts.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Link,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { vehiclesApi } from '../../services/vehicles';
|
||||
import { InspectionAlert, PruefungArtLabel, PruefungArt } from '../../types/vehicle.types';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
type Urgency = 'overdue' | 'urgent' | 'warning';
|
||||
|
||||
function getUrgency(tage: number): Urgency {
|
||||
if (tage < 0) return 'overdue';
|
||||
if (tage <= 14) return 'urgent';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
const URGENCY_CONFIG: Record<Urgency, { severity: 'error' | 'warning'; label: string }> = {
|
||||
overdue: { severity: 'error', label: 'Überfällig' },
|
||||
urgent: { severity: 'error', label: 'Dringend (≤ 14 Tage)' },
|
||||
warning: { severity: 'warning', label: 'Fällig in Kürze (≤ 30 Tage)' },
|
||||
};
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InspectionAlertsProps {
|
||||
/** How many days ahead to fetch — default 30 */
|
||||
daysAhead?: number;
|
||||
/** Collapse into a single banner if no alerts */
|
||||
hideWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
|
||||
daysAhead = 30,
|
||||
hideWhenEmpty = true,
|
||||
}) => {
|
||||
const [alerts, setAlerts] = useState<InspectionAlert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await vehiclesApi.getAlerts(daysAhead);
|
||||
if (mounted) setAlerts(data);
|
||||
} catch {
|
||||
if (mounted) setError('Prüfungshinweise konnten nicht geladen werden.');
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlerts();
|
||||
return () => { mounted = false; };
|
||||
}, [daysAhead]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Prüfungsfristen werden geprüft…
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">{error}</Alert>;
|
||||
}
|
||||
|
||||
if (alerts.length === 0) {
|
||||
if (hideWhenEmpty) return null;
|
||||
return (
|
||||
<Alert severity="success">
|
||||
Alle Prüfungsfristen sind aktuell. Keine Fälligkeiten in den nächsten {daysAhead} Tagen.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Group by urgency
|
||||
const overdue = alerts.filter((a) => a.tage < 0);
|
||||
const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14);
|
||||
const warning = alerts.filter((a) => a.tage > 14);
|
||||
|
||||
const groups: Array<{ urgency: Urgency; items: InspectionAlert[] }> = [
|
||||
{ urgency: 'overdue', items: overdue },
|
||||
{ urgency: 'urgent', items: urgent },
|
||||
{ urgency: 'warning', items: warning },
|
||||
].filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{groups.map(({ urgency, items }) => {
|
||||
const { severity, label } = URGENCY_CONFIG[urgency];
|
||||
return (
|
||||
<Alert key={urgency} severity={severity} variant="outlined">
|
||||
<AlertTitle sx={{ fontWeight: 600 }}>{label}</AlertTitle>
|
||||
<Box component="ul" sx={{ m: 0, pl: 2 }}>
|
||||
{items.map((alert) => {
|
||||
const artLabel = PruefungArtLabel[alert.pruefungArt as PruefungArt] ?? alert.pruefungArt;
|
||||
const dateStr = formatDate(alert.faelligAm);
|
||||
const tageText = alert.tage < 0
|
||||
? `seit ${Math.abs(alert.tage)} Tag${Math.abs(alert.tage) === 1 ? '' : 'en'} überfällig`
|
||||
: alert.tage === 0
|
||||
? 'heute fällig'
|
||||
: `fällig in ${alert.tage} Tag${alert.tage === 1 ? '' : 'en'}`;
|
||||
|
||||
return (
|
||||
<Collapse key={alert.pruefungId} in timeout="auto">
|
||||
<Box component="li" sx={{ mb: 0.5 }}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/fahrzeuge/${alert.fahrzeugId}`}
|
||||
color="inherit"
|
||||
underline="hover"
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
{alert.bezeichnung}
|
||||
{alert.kurzname ? ` (${alert.kurzname})` : ''}
|
||||
</Link>
|
||||
{' — '}
|
||||
<strong>{artLabel}</strong>
|
||||
{' '}
|
||||
<Typography component="span" variant="body2">
|
||||
{tageText} ({dateStr})
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default InspectionAlerts;
|
||||
Reference in New Issue
Block a user