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;
|
||||
643
frontend/src/pages/EinsatzDetail.tsx
Normal file
643
frontend/src/pages/EinsatzDetail.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Divider,
|
||||
Avatar,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Paper,
|
||||
TextField,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Edit,
|
||||
Save,
|
||||
Cancel,
|
||||
LocalFireDepartment,
|
||||
AccessTime,
|
||||
DirectionsCar,
|
||||
People,
|
||||
LocationOn,
|
||||
Description,
|
||||
PictureAsPdf,
|
||||
} from '@mui/icons-material';
|
||||
import { format, parseISO, differenceInMinutes } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import {
|
||||
incidentsApi,
|
||||
EinsatzDetail,
|
||||
EinsatzStatus,
|
||||
EINSATZ_ART_LABELS,
|
||||
EINSATZ_STATUS_LABELS,
|
||||
EinsatzArt,
|
||||
} from '../services/incidents';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// COLOUR MAPS
|
||||
// ---------------------------------------------------------------------------
|
||||
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',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HELPERS
|
||||
// ---------------------------------------------------------------------------
|
||||
function formatDE(iso: string | null | undefined, fmt = 'dd.MM.yyyy HH:mm'): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return format(parseISO(iso), fmt, { locale: de });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function minuteDiff(start: string | null, end: string | null): string {
|
||||
if (!start || !end) return '—';
|
||||
try {
|
||||
const mins = differenceInMinutes(parseISO(end), parseISO(start));
|
||||
if (mins < 0) return '—';
|
||||
if (mins < 60) return `${mins} min`;
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return m === 0 ? `${h} h` : `${h} h ${m} min`;
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function initials(givenName: string | null, familyName: string | null, name: string | null): string {
|
||||
if (givenName && familyName) return `${givenName[0]}${familyName[0]}`.toUpperCase();
|
||||
if (name) return name.slice(0, 2).toUpperCase();
|
||||
return '??';
|
||||
}
|
||||
|
||||
function displayName(p: EinsatzDetail['personal'][0]): string {
|
||||
if (p.given_name && p.family_name) return `${p.given_name} ${p.family_name}`;
|
||||
if (p.name) return p.name;
|
||||
return p.email;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TIMELINE STEP
|
||||
// ---------------------------------------------------------------------------
|
||||
interface TimelineStepProps {
|
||||
label: string;
|
||||
time: string | null;
|
||||
duration?: string;
|
||||
isFirst?: boolean;
|
||||
}
|
||||
|
||||
const TimelineStep: React.FC<TimelineStepProps> = ({ label, time, duration, isFirst }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 24 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
bgcolor: time ? 'primary.main' : 'action.disabled',
|
||||
border: '2px solid',
|
||||
borderColor: time ? 'primary.main' : 'action.disabled',
|
||||
flexShrink: 0,
|
||||
mt: 0.5,
|
||||
}}
|
||||
/>
|
||||
{!isFirst && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 2,
|
||||
flexGrow: 1,
|
||||
minHeight: 32,
|
||||
bgcolor: time ? 'primary.light' : 'action.disabled',
|
||||
my: 0.25,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ pb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', fontSize: '0.68rem' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body1" fontWeight={time ? 600 : 400} color={time ? 'text.primary' : 'text.disabled'}>
|
||||
{time ? formatDE(time) : 'Nicht erfasst'}
|
||||
</Typography>
|
||||
{duration && (
|
||||
<Typography variant="caption" color="primary.main" sx={{ fontWeight: 500 }}>
|
||||
+{duration}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MAIN PAGE
|
||||
// ---------------------------------------------------------------------------
|
||||
function EinsatzDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const notification = useNotification();
|
||||
|
||||
const [einsatz, setEinsatz] = useState<EinsatzDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Edit mode for bericht fields
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [berichtKurz, setBerichtKurz] = useState('');
|
||||
const [berichtText, setBerichtText] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// FETCH
|
||||
// -------------------------------------------------------------------------
|
||||
const fetchEinsatz = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await incidentsApi.getById(id);
|
||||
setEinsatz(data);
|
||||
setBerichtKurz(data.bericht_kurz ?? '');
|
||||
setBerichtText(data.bericht_text ?? '');
|
||||
} catch (err) {
|
||||
setError('Einsatz konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEinsatz();
|
||||
}, [fetchEinsatz]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SAVE BERICHT
|
||||
// -------------------------------------------------------------------------
|
||||
const handleSaveBericht = async () => {
|
||||
if (!id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await incidentsApi.update(id, {
|
||||
bericht_kurz: berichtKurz || null,
|
||||
bericht_text: berichtText || null,
|
||||
});
|
||||
notification.showSuccess('Einsatzbericht gespeichert');
|
||||
setEditing(false);
|
||||
fetchEinsatz();
|
||||
} catch (err) {
|
||||
notification.showError('Fehler beim Speichern des Berichts');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditing(false);
|
||||
setBerichtKurz(einsatz?.bericht_kurz ?? '');
|
||||
setBerichtText(einsatz?.bericht_text ?? '');
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PDF EXPORT (placeholder)
|
||||
// -------------------------------------------------------------------------
|
||||
const handleExportPdf = () => {
|
||||
notification.showInfo('PDF-Export wird in Kürze verfügbar sein.');
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// LOADING STATE
|
||||
// -------------------------------------------------------------------------
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Skeleton width={120} height={36} sx={{ mb: 2 }} />
|
||||
<Skeleton width={300} height={48} sx={{ mb: 3 }} />
|
||||
<Grid container spacing={3}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Grid item xs={12} md={4} key={i}>
|
||||
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2 }} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !einsatz) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/einsaetze')}
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
Zurück zur Übersicht
|
||||
</Button>
|
||||
<Alert severity="error">{error ?? 'Einsatz nicht gefunden.'}</Alert>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const address = [einsatz.strasse, einsatz.hausnummer, einsatz.ort]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
{/* Back + Actions */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/einsaetze')}
|
||||
variant="text"
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="PDF exportieren (Vorschau)">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<PictureAsPdf />}
|
||||
onClick={handleExportPdf}
|
||||
size="small"
|
||||
>
|
||||
PDF Export
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{!editing ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Edit />}
|
||||
onClick={() => setEditing(true)}
|
||||
size="small"
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Cancel />}
|
||||
onClick={handleCancelEdit}
|
||||
size="small"
|
||||
disabled={saving}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<Save />}
|
||||
onClick={handleSaveBericht}
|
||||
size="small"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Speichere...' : 'Speichern'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* HEADER */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: 1 }}>
|
||||
<Chip
|
||||
icon={<LocalFireDepartment />}
|
||||
label={EINSATZ_ART_LABELS[einsatz.einsatz_art]}
|
||||
color={ART_CHIP_COLOR[einsatz.einsatz_art]}
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Chip
|
||||
label={EINSATZ_STATUS_LABELS[einsatz.status]}
|
||||
color={STATUS_CHIP_COLOR[einsatz.status]}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
{einsatz.einsatz_stichwort && (
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
{einsatz.einsatz_stichwort}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="h4" fontWeight={700}>
|
||||
Einsatz {einsatz.einsatz_nr}
|
||||
</Typography>
|
||||
{address && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||
<LocationOn fontSize="small" color="action" />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{address}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* LEFT COLUMN: Timeline + Vehicles */}
|
||||
<Grid item xs={12} md={4}>
|
||||
{/* Timeline card */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<AccessTime color="primary" />
|
||||
<Typography variant="h6">Zeitlinie</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Reversed order: last step first (top = Alarm) */}
|
||||
<TimelineStep
|
||||
label="Alarmzeit"
|
||||
time={einsatz.alarm_time}
|
||||
/>
|
||||
<TimelineStep
|
||||
label="Ausrückzeit"
|
||||
time={einsatz.ausrueck_time}
|
||||
duration={minuteDiff(einsatz.alarm_time, einsatz.ausrueck_time)}
|
||||
/>
|
||||
<TimelineStep
|
||||
label="Ankunftszeit (Hilfsfrist)"
|
||||
time={einsatz.ankunft_time}
|
||||
duration={minuteDiff(einsatz.alarm_time, einsatz.ankunft_time)}
|
||||
/>
|
||||
<TimelineStep
|
||||
isFirst
|
||||
label="Einrückzeit"
|
||||
time={einsatz.einrueck_time}
|
||||
duration={minuteDiff(einsatz.alarm_time, einsatz.einrueck_time)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{(einsatz.hilfsfrist_min !== null || einsatz.dauer_min !== null) && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Grid container spacing={1}>
|
||||
{einsatz.hilfsfrist_min !== null && (
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Hilfsfrist
|
||||
</Typography>
|
||||
<Typography variant="body1" fontWeight={700} color={einsatz.hilfsfrist_min > 10 ? 'error.main' : 'success.main'}>
|
||||
{einsatz.hilfsfrist_min} min
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
{einsatz.dauer_min !== null && (
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Gesamtdauer
|
||||
</Typography>
|
||||
<Typography variant="body1" fontWeight={700}>
|
||||
{einsatz.dauer_min < 60
|
||||
? `${einsatz.dauer_min} min`
|
||||
: `${Math.floor(einsatz.dauer_min / 60)} h ${einsatz.dauer_min % 60} min`}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vehicles card */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<DirectionsCar color="primary" />
|
||||
<Typography variant="h6">Fahrzeuge</Typography>
|
||||
<Chip
|
||||
label={einsatz.fahrzeuge.length}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ ml: 'auto' }}
|
||||
/>
|
||||
</Box>
|
||||
{einsatz.fahrzeuge.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Fahrzeuge zugewiesen
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={1}>
|
||||
{einsatz.fahrzeuge.map((f) => (
|
||||
<Paper
|
||||
key={f.fahrzeug_id}
|
||||
variant="outlined"
|
||||
sx={{ p: 1.25, borderRadius: 2 }}
|
||||
>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
{f.bezeichnung}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{f.kennzeichen}
|
||||
{f.fahrzeug_typ ? ` · ${f.fahrzeug_typ}` : ''}
|
||||
</Typography>
|
||||
{(f.ausrueck_time || f.einrueck_time) && (
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{formatDE(f.ausrueck_time, 'HH:mm')} → {formatDE(f.einrueck_time, 'HH:mm')}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* RIGHT COLUMN: Personnel + Bericht */}
|
||||
<Grid item xs={12} md={8}>
|
||||
{/* Personnel card */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<People color="primary" />
|
||||
<Typography variant="h6">Einsatzkräfte</Typography>
|
||||
<Chip
|
||||
label={einsatz.personal.length}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ ml: 'auto' }}
|
||||
/>
|
||||
</Box>
|
||||
{einsatz.einsatzleiter_name && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', fontSize: '0.68rem' }}>
|
||||
Einsatzleiter
|
||||
</Typography>
|
||||
<Typography variant="body1" fontWeight={600}>
|
||||
{einsatz.einsatzleiter_name}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{einsatz.personal.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Einsatzkräfte zugewiesen
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
|
||||
{einsatz.personal.map((p) => (
|
||||
<Box
|
||||
key={p.user_id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
minWidth: 200,
|
||||
maxWidth: 260,
|
||||
flex: '1 1 200px',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
sx={{ width: 36, height: 36, bgcolor: 'primary.main', fontSize: '0.8rem' }}
|
||||
>
|
||||
{initials(p.given_name, p.family_name, p.name)}
|
||||
</Avatar>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
noWrap
|
||||
title={displayName(p)}
|
||||
>
|
||||
{displayName(p)}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={p.funktion}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.68rem', height: 18 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bericht card */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Description color="primary" />
|
||||
<Typography variant="h6">Einsatzbericht</Typography>
|
||||
</Box>
|
||||
|
||||
{editing ? (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Kurzbeschreibung"
|
||||
value={berichtKurz}
|
||||
onChange={(e) => setBerichtKurz(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
inputProps={{ maxLength: 255 }}
|
||||
helperText={`${berichtKurz.length}/255`}
|
||||
/>
|
||||
<TextField
|
||||
label="Ausführlicher Bericht"
|
||||
value={berichtText}
|
||||
onChange={(e) => setBerichtText(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={8}
|
||||
placeholder="Detaillierter Einsatzbericht..."
|
||||
helperText="Nur für Kommandant und Admin sichtbar"
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', fontSize: '0.68rem' }}>
|
||||
Kurzbeschreibung
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{einsatz.bericht_kurz ?? (
|
||||
<Typography component="span" color="text.disabled" fontStyle="italic">
|
||||
Keine Kurzbeschreibung erfasst
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{einsatz.bericht_text !== undefined && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', fontSize: '0.68rem' }}>
|
||||
Ausführlicher Bericht
|
||||
</Typography>
|
||||
{einsatz.bericht_text ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ whiteSpace: 'pre-wrap', mt: 0.5, lineHeight: 1.7 }}
|
||||
>
|
||||
{einsatz.bericht_text}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled" fontStyle="italic">
|
||||
Kein Bericht erfasst
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Footer meta */}
|
||||
<Box sx={{ mt: 3, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
Angelegt: {formatDE(einsatz.created_at, 'dd.MM.yyyy HH:mm')}
|
||||
{' · '}
|
||||
Zuletzt geändert: {formatDE(einsatz.updated_at, 'dd.MM.yyyy HH:mm')}
|
||||
{' · '}
|
||||
Alarmierung via {einsatz.alarmierung_art}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default EinsatzDetail;
|
||||
898
frontend/src/pages/FahrzeugDetail.tsx
Normal file
898
frontend/src/pages/FahrzeugDetail.tsx
Normal file
@@ -0,0 +1,898 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
Fab,
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Timeline,
|
||||
TimelineConnector,
|
||||
TimelineContent,
|
||||
TimelineDot,
|
||||
TimelineItem,
|
||||
TimelineOppositeContent,
|
||||
TimelineSeparator,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
ArrowBack,
|
||||
Assignment,
|
||||
Build,
|
||||
CheckCircle,
|
||||
DirectionsCar,
|
||||
Error as ErrorIcon,
|
||||
LocalFireDepartment,
|
||||
PauseCircle,
|
||||
ReportProblem,
|
||||
School,
|
||||
Warning,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { vehiclesApi } from '../services/vehicles';
|
||||
import {
|
||||
FahrzeugDetail,
|
||||
FahrzeugPruefung,
|
||||
FahrzeugWartungslog,
|
||||
FahrzeugStatus,
|
||||
FahrzeugStatusLabel,
|
||||
PruefungArt,
|
||||
PruefungArtLabel,
|
||||
CreatePruefungPayload,
|
||||
CreateWartungslogPayload,
|
||||
WartungslogArt,
|
||||
PruefungErgebnis,
|
||||
} from '../types/vehicle.types';
|
||||
|
||||
// ── Tab Panel ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
|
||||
<div role="tabpanel" hidden={value !== index}>
|
||||
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Status config ─────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_ICONS: Record<FahrzeugStatus, React.ReactElement> = {
|
||||
[FahrzeugStatus.Einsatzbereit]: <CheckCircle color="success" />,
|
||||
[FahrzeugStatus.AusserDienstWartung]: <PauseCircle color="warning" />,
|
||||
[FahrzeugStatus.AusserDienstSchaden]: <ErrorIcon color="error" />,
|
||||
[FahrzeugStatus.InLehrgang]: <School color="info" />,
|
||||
};
|
||||
|
||||
const STATUS_CHIP_COLOR: Record<FahrzeugStatus, 'success' | 'warning' | 'error' | 'info'> = {
|
||||
[FahrzeugStatus.Einsatzbereit]: 'success',
|
||||
[FahrzeugStatus.AusserDienstWartung]: 'warning',
|
||||
[FahrzeugStatus.AusserDienstSchaden]: 'error',
|
||||
[FahrzeugStatus.InLehrgang]: 'info',
|
||||
};
|
||||
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function inspectionBadgeColor(tage: number | null): 'success' | 'warning' | 'error' | 'default' {
|
||||
if (tage === null) return 'default';
|
||||
if (tage < 0) return 'error';
|
||||
if (tage <= 30) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
// ── Übersicht Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface UebersichtTabProps {
|
||||
vehicle: FahrzeugDetail;
|
||||
onStatusUpdated: () => void;
|
||||
}
|
||||
|
||||
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated }) => {
|
||||
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
||||
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
|
||||
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const handleSaveStatus = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
await vehiclesApi.updateStatus(vehicle.id, { status: newStatus, bemerkung });
|
||||
setStatusDialogOpen(false);
|
||||
onStatusUpdated();
|
||||
} catch {
|
||||
setSaveError('Status konnte nicht gespeichert werden.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{isSchaden && (
|
||||
<Alert severity="error" icon={<ReportProblem />} sx={{ mb: 2 }}>
|
||||
<strong>Schaden gemeldet</strong> — dieses Fahrzeug ist nicht einsatzbereit.
|
||||
{vehicle.status_bemerkung && ` Bemerkung: ${vehicle.status_bemerkung}`}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Status panel */}
|
||||
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{STATUS_ICONS[vehicle.status]}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Aktueller Status
|
||||
</Typography>
|
||||
<Chip
|
||||
label={FahrzeugStatusLabel[vehicle.status]}
|
||||
color={STATUS_CHIP_COLOR[vehicle.status]}
|
||||
size="small"
|
||||
/>
|
||||
{vehicle.status_bemerkung && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{vehicle.status_bemerkung}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setNewStatus(vehicle.status);
|
||||
setBemerkung(vehicle.status_bemerkung ?? '');
|
||||
setStatusDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Status ändern
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Vehicle data grid */}
|
||||
<Grid container spacing={2}>
|
||||
{[
|
||||
{ label: 'Bezeichnung', value: vehicle.bezeichnung },
|
||||
{ label: 'Kurzname', value: vehicle.kurzname },
|
||||
{ label: 'Kennzeichen', value: vehicle.amtliches_kennzeichen },
|
||||
{ label: 'Fahrgestellnr.', value: vehicle.fahrgestellnummer },
|
||||
{ label: 'Baujahr', value: vehicle.baujahr?.toString() },
|
||||
{ label: 'Hersteller', value: vehicle.hersteller },
|
||||
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
|
||||
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
|
||||
{ label: 'Standort', value: vehicle.standort },
|
||||
].map(({ label, value }) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={label}>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body1">{value ?? '—'}</Typography>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Inspection status quick view */}
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
|
||||
Prüffristen Übersicht
|
||||
</Typography>
|
||||
<Grid container spacing={1.5}>
|
||||
{Object.entries(vehicle.pruefstatus).map(([key, ps]) => {
|
||||
const art = key.toUpperCase() as PruefungArt;
|
||||
const label = PruefungArtLabel[art] ?? key;
|
||||
const color = inspectionBadgeColor(ps.tage_bis_faelligkeit);
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={3} key={key}>
|
||||
<Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
|
||||
{label}
|
||||
</Typography>
|
||||
{ps.faellig_am ? (
|
||||
<>
|
||||
<Chip
|
||||
size="small"
|
||||
color={color}
|
||||
label={
|
||||
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
|
||||
? `ÜBERFÄLLIG (${fmtDate(ps.faellig_am)})`
|
||||
: `Fällig: ${fmtDate(ps.faellig_am)}`
|
||||
}
|
||||
icon={
|
||||
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
|
||||
? <Warning fontSize="small" />
|
||||
: undefined
|
||||
}
|
||||
sx={{ mt: 0.5 }}
|
||||
/>
|
||||
{ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit >= 0 && (
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
in {ps.tage_bis_faelligkeit} Tagen
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
Keine Daten
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{/* Status change dialog */}
|
||||
<Dialog
|
||||
open={statusDialogOpen}
|
||||
onClose={() => setStatusDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Fahrzeugstatus ändern</DialogTitle>
|
||||
<DialogContent>
|
||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||
<FormControl fullWidth sx={{ mb: 2, mt: 1 }}>
|
||||
<InputLabel id="status-select-label">Neuer Status</InputLabel>
|
||||
<Select
|
||||
labelId="status-select-label"
|
||||
label="Neuer Status"
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(e.target.value as FahrzeugStatus)}
|
||||
>
|
||||
{Object.values(FahrzeugStatus).map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{FahrzeugStatusLabel[s]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Bemerkung (optional)"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={bemerkung}
|
||||
onChange={(e) => setBemerkung(e.target.value)}
|
||||
placeholder="z.B. Fahrzeug in Werkstatt, voraussichtlich ab 01.03. wieder einsatzbereit"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setStatusDialogOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSaveStatus}
|
||||
disabled={saving}
|
||||
startIcon={saving ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Prüfungen Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface PruefungenTabProps {
|
||||
fahrzeugId: string;
|
||||
pruefungen: FahrzeugPruefung[];
|
||||
onAdded: () => void;
|
||||
}
|
||||
|
||||
const ERGEBNIS_LABELS: Record<PruefungErgebnis, string> = {
|
||||
bestanden: 'Bestanden',
|
||||
bestanden_mit_maengeln: 'Bestanden mit Mängeln',
|
||||
nicht_bestanden: 'Nicht bestanden',
|
||||
ausstehend: 'Ausstehend',
|
||||
};
|
||||
|
||||
const ERGEBNIS_COLORS: Record<PruefungErgebnis, 'success' | 'warning' | 'error' | 'default'> = {
|
||||
bestanden: 'success',
|
||||
bestanden_mit_maengeln: 'warning',
|
||||
nicht_bestanden: 'error',
|
||||
ausstehend: 'default',
|
||||
};
|
||||
|
||||
const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, onAdded }) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const emptyForm: CreatePruefungPayload = {
|
||||
pruefung_art: PruefungArt.HU,
|
||||
faellig_am: '',
|
||||
durchgefuehrt_am: '',
|
||||
ergebnis: 'ausstehend',
|
||||
pruefende_stelle: '',
|
||||
kosten: undefined,
|
||||
bemerkung: '',
|
||||
};
|
||||
|
||||
const [form, setForm] = useState<CreatePruefungPayload>(emptyForm);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.faellig_am) {
|
||||
setSaveError('Fälligkeitsdatum ist erforderlich.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const payload: CreatePruefungPayload = {
|
||||
...form,
|
||||
durchgefuehrt_am: form.durchgefuehrt_am || undefined,
|
||||
kosten: form.kosten !== undefined && form.kosten !== null ? Number(form.kosten) : undefined,
|
||||
};
|
||||
await vehiclesApi.addPruefung(fahrzeugId, payload);
|
||||
setDialogOpen(false);
|
||||
setForm(emptyForm);
|
||||
onAdded();
|
||||
} catch {
|
||||
setSaveError('Prüfung konnte nicht gespeichert werden.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{pruefungen.length === 0 ? (
|
||||
<Typography color="text.secondary">Noch keine Prüfungen erfasst.</Typography>
|
||||
) : (
|
||||
<Stack divider={<Divider />} spacing={0}>
|
||||
{pruefungen.map((p) => {
|
||||
const ergebnis = (p.ergebnis ?? 'ausstehend') as PruefungErgebnis;
|
||||
const isFaellig = !p.durchgefuehrt_am && new Date(p.faellig_am) < new Date();
|
||||
return (
|
||||
<Box key={p.id} sx={{ py: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
||||
<Box sx={{ minWidth: 140 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{PruefungArtLabel[p.pruefung_art] ?? p.pruefung_art}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
Fällig: {fmtDate(p.faellig_am)}
|
||||
</Typography>
|
||||
{isFaellig && !p.durchgefuehrt_am && (
|
||||
<Chip label="ÜBERFÄLLIG" color="error" size="small" sx={{ mt: 0.5 }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 0.5 }}>
|
||||
<Chip
|
||||
label={ERGEBNIS_LABELS[ergebnis]}
|
||||
color={ERGEBNIS_COLORS[ergebnis]}
|
||||
size="small"
|
||||
/>
|
||||
{p.durchgefuehrt_am && (
|
||||
<Chip
|
||||
label={`Durchgeführt: ${fmtDate(p.durchgefuehrt_am)}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
{p.naechste_faelligkeit && (
|
||||
<Chip
|
||||
label={`Nächste: ${fmtDate(p.naechste_faelligkeit)}`}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{p.pruefende_stelle && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{p.pruefende_stelle}
|
||||
{p.kosten != null && ` · ${p.kosten.toFixed(2)} €`}
|
||||
</Typography>
|
||||
)}
|
||||
{p.bemerkung && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{p.bemerkung}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* FAB */}
|
||||
<Fab
|
||||
color="primary"
|
||||
size="small"
|
||||
aria-label="Prüfung hinzufügen"
|
||||
sx={{ position: 'fixed', bottom: 32, right: 32 }}
|
||||
onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
|
||||
>
|
||||
<Add />
|
||||
</Fab>
|
||||
|
||||
{/* Add inspection dialog */}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Prüfung erfassen</DialogTitle>
|
||||
<DialogContent>
|
||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Prüfungsart</InputLabel>
|
||||
<Select
|
||||
label="Prüfungsart"
|
||||
value={form.pruefung_art}
|
||||
onChange={(e) => setForm((f) => ({ ...f, pruefung_art: e.target.value as PruefungArt }))}
|
||||
>
|
||||
{Object.values(PruefungArt).map((art) => (
|
||||
<MenuItem key={art} value={art}>{PruefungArtLabel[art]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Ergebnis</InputLabel>
|
||||
<Select
|
||||
label="Ergebnis"
|
||||
value={form.ergebnis ?? 'ausstehend'}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ergebnis: e.target.value as PruefungErgebnis }))}
|
||||
>
|
||||
{(Object.keys(ERGEBNIS_LABELS) as PruefungErgebnis[]).map((e) => (
|
||||
<MenuItem key={e} value={e}>{ERGEBNIS_LABELS[e]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Fällig am *"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={form.faellig_am}
|
||||
onChange={(e) => setForm((f) => ({ ...f, faellig_am: e.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Durchgeführt am"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={form.durchgefuehrt_am ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, durchgefuehrt_am: e.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={8}>
|
||||
<TextField
|
||||
label="Prüfende Stelle"
|
||||
fullWidth
|
||||
value={form.pruefende_stelle ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))}
|
||||
placeholder="z.B. TÜV Süd Stuttgart"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Kosten (€)"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={form.kosten ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, kosten: e.target.value ? Number(e.target.value) : undefined }))}
|
||||
inputProps={{ min: 0, step: 0.01 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Bemerkung"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={form.bemerkung ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, bemerkung: e.target.value }))}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
startIcon={saving ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Wartung Tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface WartungTabProps {
|
||||
fahrzeugId: string;
|
||||
wartungslog: FahrzeugWartungslog[];
|
||||
onAdded: () => void;
|
||||
}
|
||||
|
||||
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
|
||||
Kraftstoff: <LocalFireDepartment color="action" />,
|
||||
Reparatur: <Build color="warning" />,
|
||||
Inspektion: <Assignment color="primary" />,
|
||||
Hauptuntersuchung:<CheckCircle color="success" />,
|
||||
default: <Build color="action" />,
|
||||
};
|
||||
|
||||
const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded }) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const emptyForm: CreateWartungslogPayload = {
|
||||
datum: '',
|
||||
art: undefined,
|
||||
beschreibung: '',
|
||||
km_stand: undefined,
|
||||
kraftstoff_liter: undefined,
|
||||
kosten: undefined,
|
||||
externe_werkstatt: '',
|
||||
};
|
||||
|
||||
const [form, setForm] = useState<CreateWartungslogPayload>(emptyForm);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.datum || !form.beschreibung.trim()) {
|
||||
setSaveError('Datum und Beschreibung sind erforderlich.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
await vehiclesApi.addWartungslog(fahrzeugId, {
|
||||
...form,
|
||||
externe_werkstatt: form.externe_werkstatt || undefined,
|
||||
});
|
||||
setDialogOpen(false);
|
||||
setForm(emptyForm);
|
||||
onAdded();
|
||||
} catch {
|
||||
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{wartungslog.length === 0 ? (
|
||||
<Typography color="text.secondary">Noch keine Wartungseinträge erfasst.</Typography>
|
||||
) : (
|
||||
// MUI Timeline is available via @mui/lab — using Paper list as fallback
|
||||
// since @mui/lab is not in current package.json
|
||||
<Stack divider={<Divider />} spacing={0}>
|
||||
{wartungslog.map((entry) => {
|
||||
const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default;
|
||||
return (
|
||||
<Box key={entry.id} sx={{ py: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
|
||||
<Box sx={{ mt: 0.25 }}>{artIcon}</Box>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.25 }}>
|
||||
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
|
||||
{entry.art && (
|
||||
<Chip label={entry.art} size="small" variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="body2">{entry.beschreibung}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||
{[
|
||||
entry.km_stand != null && `${entry.km_stand.toLocaleString('de-DE')} km`,
|
||||
entry.kraftstoff_liter != null && `${entry.kraftstoff_liter.toFixed(1)} L`,
|
||||
entry.kosten != null && `${entry.kosten.toFixed(2)} €`,
|
||||
entry.externe_werkstatt && entry.externe_werkstatt,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Fab
|
||||
color="primary"
|
||||
size="small"
|
||||
aria-label="Wartung eintragen"
|
||||
sx={{ position: 'fixed', bottom: 32, right: 32 }}
|
||||
onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
|
||||
>
|
||||
<Add />
|
||||
</Fab>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Wartung / Service eintragen</DialogTitle>
|
||||
<DialogContent>
|
||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Datum *"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={form.datum}
|
||||
onChange={(e) => setForm((f) => ({ ...f, datum: e.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Art</InputLabel>
|
||||
<Select
|
||||
label="Art"
|
||||
value={form.art ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, art: (e.target.value || undefined) as WartungslogArt | undefined }))}
|
||||
>
|
||||
<MenuItem value="">— Bitte wählen —</MenuItem>
|
||||
{(['Inspektion', 'Reparatur', 'Kraftstoff', 'Reifenwechsel', 'Hauptuntersuchung', 'Reinigung', 'Sonstiges'] as WartungslogArt[]).map((a) => (
|
||||
<MenuItem key={a} value={a}>{a}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Beschreibung *"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={form.beschreibung}
|
||||
onChange={(e) => setForm((f) => ({ ...f, beschreibung: e.target.value }))}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="km-Stand"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={form.km_stand ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, km_stand: e.target.value ? Number(e.target.value) : undefined }))}
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Kraftstoff (L)"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={form.kraftstoff_liter ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, kraftstoff_liter: e.target.value ? Number(e.target.value) : undefined }))}
|
||||
inputProps={{ min: 0, step: 0.1 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Kosten (€)"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={form.kosten ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, kosten: e.target.value ? Number(e.target.value) : undefined }))}
|
||||
inputProps={{ min: 0, step: 0.01 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Externe Werkstatt"
|
||||
fullWidth
|
||||
value={form.externe_werkstatt ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, externe_werkstatt: e.target.value }))}
|
||||
placeholder="Name der Werkstatt (wenn extern)"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
startIcon={saving ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function FahrzeugDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [vehicle, setVehicle] = useState<FahrzeugDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
const fetchVehicle = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await vehiclesApi.getById(id);
|
||||
setVehicle(data);
|
||||
} catch {
|
||||
setError('Fahrzeug konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { fetchVehicle(); }, [fetchVehicle]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !vehicle) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Alert severity="error">{error ?? 'Fahrzeug nicht gefunden.'}</Alert>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/fahrzeuge')}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Zurück zur Übersicht
|
||||
</Button>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
{/* Breadcrumb / back */}
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/fahrzeuge')}
|
||||
sx={{ mb: 2 }}
|
||||
size="small"
|
||||
>
|
||||
Fahrzeugübersicht
|
||||
</Button>
|
||||
|
||||
{/* Page title */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} />
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1">
|
||||
{vehicle.bezeichnung}
|
||||
{vehicle.kurzname && (
|
||||
<Typography component="span" variant="h5" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{vehicle.kurzname}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
{vehicle.amtliches_kennzeichen && (
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{vehicle.amtliches_kennzeichen}
|
||||
{vehicle.hersteller && ` · ${vehicle.hersteller}`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
<Chip
|
||||
icon={STATUS_ICONS[vehicle.status]}
|
||||
label={FahrzeugStatusLabel[vehicle.status]}
|
||||
color={STATUS_CHIP_COLOR[vehicle.status]}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, v) => setActiveTab(v)}
|
||||
aria-label="Fahrzeug Detailansicht"
|
||||
>
|
||||
<Tab label="Übersicht" />
|
||||
<Tab
|
||||
label={
|
||||
vehicle.naechste_pruefung_tage !== null && vehicle.naechste_pruefung_tage < 0
|
||||
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
Prüfungen <Warning color="error" fontSize="small" />
|
||||
</Box>
|
||||
: 'Prüfungen'
|
||||
}
|
||||
/>
|
||||
<Tab label="Wartung" />
|
||||
<Tab label="Einsätze" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Tab content */}
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<UebersichtTab vehicle={vehicle} onStatusUpdated={fetchVehicle} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<PruefungenTab
|
||||
fahrzeugId={vehicle.id}
|
||||
pruefungen={vehicle.pruefungen}
|
||||
onAdded={fetchVehicle}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<WartungTab
|
||||
fahrzeugId={vehicle.id}
|
||||
wartungslog={vehicle.wartungslog}
|
||||
onAdded={fetchVehicle}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={3}>
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<LocalFireDepartment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Einsatzhistorie
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
Die Einsatzverknüpfung wird in Tier 2 (Einsatzverwaltung) implementiert.
|
||||
</Typography>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default FahrzeugDetail;
|
||||
804
frontend/src/pages/Kalender.tsx
Normal file
804
frontend/src/pages/Kalender.tsx
Normal file
@@ -0,0 +1,804 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
ButtonGroup,
|
||||
Button,
|
||||
Popover,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Snackbar,
|
||||
Alert,
|
||||
Skeleton,
|
||||
Divider,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Today as TodayIcon,
|
||||
CalendarViewMonth as CalendarIcon,
|
||||
ViewList as ListViewIcon,
|
||||
Star as StarIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Cancel as CancelIcon,
|
||||
HelpOutline as UnknownIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { trainingApi } from '../services/training';
|
||||
import type { UebungListItem, UebungTyp, TeilnahmeStatus } from '../types/training.types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants & helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
const MONTH_LABELS = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
];
|
||||
|
||||
const TYP_DOT_COLOR: Record<UebungTyp, string> = {
|
||||
'Übungsabend': '#1976d2', // blue
|
||||
'Lehrgang': '#7b1fa2', // purple
|
||||
'Sonderdienst': '#e65100', // orange
|
||||
'Versammlung': '#616161', // gray
|
||||
'Gemeinschaftsübung': '#00796b', // teal
|
||||
'Sonstiges': '#9e9e9e', // light gray
|
||||
};
|
||||
|
||||
const TYP_CHIP_COLOR: Record<
|
||||
UebungTyp,
|
||||
'primary' | 'secondary' | 'warning' | 'default' | 'error' | 'info' | 'success'
|
||||
> = {
|
||||
'Übungsabend': 'primary',
|
||||
'Lehrgang': 'secondary',
|
||||
'Sonderdienst': 'warning',
|
||||
'Versammlung': 'default',
|
||||
'Gemeinschaftsübung': 'info',
|
||||
'Sonstiges': 'default',
|
||||
};
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
const c = new Date(d);
|
||||
c.setHours(0, 0, 0, 0);
|
||||
return c;
|
||||
}
|
||||
|
||||
function sameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns calendar grid cells for the month view — always 6×7 (42 cells) */
|
||||
function buildMonthGrid(year: number, month: number): Date[] {
|
||||
// month is 0-indexed
|
||||
const firstDay = new Date(year, month, 1);
|
||||
// ISO week starts Monday; getDay() returns 0=Sun → convert to Mon=0
|
||||
const dayOfWeek = (firstDay.getDay() + 6) % 7;
|
||||
const start = new Date(firstDay);
|
||||
start.setDate(start.getDate() - dayOfWeek);
|
||||
|
||||
const cells: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
cells.push(d);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function formatTime(isoString: string): string {
|
||||
const d = new Date(isoString);
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
function formatDateLong(isoString: string): string {
|
||||
const d = new Date(isoString);
|
||||
const days = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
|
||||
return `${days[d.getDay()]}, ${d.getDate()}. ${MONTH_LABELS[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RSVP indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RsvpDot({ status }: { status: TeilnahmeStatus | undefined }) {
|
||||
if (!status || status === 'unbekannt') return <UnknownIcon sx={{ fontSize: 14, color: 'text.disabled' }} />;
|
||||
if (status === 'zugesagt' || status === 'erschienen') return <CheckIcon sx={{ fontSize: 14, color: 'success.main' }} />;
|
||||
return <CancelIcon sx={{ fontSize: 14, color: 'error.main' }} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// iCal Subscribe Dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface IcalDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function IcalDialog({ open, onClose }: IcalDialogProps) {
|
||||
const [snackOpen, setSnackOpen] = useState(false);
|
||||
const [subscribeUrl, setSubscribeUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (subscribeUrl) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { subscribeUrl: url } = await trainingApi.getCalendarToken();
|
||||
setSubscribeUrl(url);
|
||||
} catch (_) {
|
||||
setSubscribeUrl(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!subscribeUrl) return;
|
||||
await navigator.clipboard.writeText(subscribeUrl);
|
||||
setSnackOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
TransitionProps={{ onEnter: handleOpen }}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Kalender abonnieren</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
Kopiere die URL und füge sie in deiner Kalender-App unter
|
||||
"Kalender abonnieren" ein. Der Kalender wird automatisch
|
||||
aktualisiert, sobald neue Dienste eingetragen werden.
|
||||
</DialogContentText>
|
||||
|
||||
{loading && <Skeleton variant="rectangular" height={48} sx={{ borderRadius: 1 }} />}
|
||||
|
||||
{!loading && subscribeUrl && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 1.5,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'action.hover',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1, userSelect: 'all' }}>{subscribeUrl}</Box>
|
||||
<Tooltip title="URL kopieren">
|
||||
<IconButton size="small" onClick={handleCopy}>
|
||||
<CopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
<strong>Apple Kalender:</strong> Ablage → Neues Kalenderabonnement<br />
|
||||
<strong>Google Kalender:</strong> Andere Kalender → Per URL<br />
|
||||
<strong>Thunderbird:</strong> Neu → Kalender → Im Netzwerk
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Schließen</Button>
|
||||
{subscribeUrl && (
|
||||
<Button variant="contained" onClick={handleCopy} startIcon={<CopyIcon />}>
|
||||
URL kopieren
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar
|
||||
open={snackOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="success" onClose={() => setSnackOpen(false)}>
|
||||
URL kopiert!
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Month Calendar Grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MonthCalendarProps {
|
||||
year: number;
|
||||
month: number;
|
||||
events: UebungListItem[];
|
||||
onDayClick: (day: Date, anchor: Element) => void;
|
||||
}
|
||||
|
||||
function MonthCalendar({ year, month, events, onDayClick }: MonthCalendarProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const today = startOfDay(new Date());
|
||||
const cells = useMemo(() => buildMonthGrid(year, month), [year, month]);
|
||||
|
||||
// Build a map: "YYYY-MM-DD" → events
|
||||
const eventsByDay = useMemo(() => {
|
||||
const map = new Map<string, UebungListItem[]>();
|
||||
for (const ev of events) {
|
||||
const d = startOfDay(new Date(ev.datum_von));
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const arr = map.get(key) ?? [];
|
||||
arr.push(ev);
|
||||
map.set(key, arr);
|
||||
}
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Weekday headers */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{WEEKDAY_LABELS.map((wd) => (
|
||||
<Typography
|
||||
key={wd}
|
||||
variant="caption"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontWeight: 600,
|
||||
color: wd === 'Sa' || wd === 'So' ? 'error.main' : 'text.secondary',
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
{wd}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Day cells — 6 rows × 7 cols */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: '2px',
|
||||
}}
|
||||
>
|
||||
{cells.map((cell, idx) => {
|
||||
const isCurrentMonth = cell.getMonth() === month;
|
||||
const isToday = sameDay(cell, today);
|
||||
const key = cell.toISOString().slice(0, 10);
|
||||
const dayEvents = eventsByDay.get(key) ?? [];
|
||||
const hasEvents = dayEvents.length > 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={idx}
|
||||
onClick={(e) => hasEvents && onDayClick(cell, e.currentTarget)}
|
||||
sx={{
|
||||
minHeight: isMobile ? 44 : 72,
|
||||
borderRadius: 1,
|
||||
p: '4px',
|
||||
cursor: hasEvents ? 'pointer' : 'default',
|
||||
bgcolor: isToday
|
||||
? 'primary.main'
|
||||
: isCurrentMonth
|
||||
? 'background.paper'
|
||||
: 'action.disabledBackground',
|
||||
border: '1px solid',
|
||||
borderColor: isToday ? 'primary.dark' : 'divider',
|
||||
transition: 'background 0.1s',
|
||||
'&:hover': hasEvents
|
||||
? { bgcolor: isToday ? 'primary.dark' : 'action.hover' }
|
||||
: {},
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: isToday ? 700 : 400,
|
||||
color: isToday
|
||||
? 'primary.contrastText'
|
||||
: isCurrentMonth
|
||||
? 'text.primary'
|
||||
: 'text.disabled',
|
||||
lineHeight: 1.4,
|
||||
fontSize: isMobile ? '0.7rem' : '0.75rem',
|
||||
}}
|
||||
>
|
||||
{cell.getDate()}
|
||||
</Typography>
|
||||
|
||||
{/* Event dots — max 3 visible on mobile */}
|
||||
{hasEvents && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '2px',
|
||||
justifyContent: 'center',
|
||||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
{dayEvents.slice(0, isMobile ? 3 : 5).map((ev, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: isMobile ? 5 : 7,
|
||||
height: isMobile ? 5 : 7,
|
||||
borderRadius: '50%',
|
||||
bgcolor: ev.abgesagt
|
||||
? 'text.disabled'
|
||||
: TYP_DOT_COLOR[ev.typ],
|
||||
border: ev.pflichtveranstaltung
|
||||
? '1.5px solid'
|
||||
: 'none',
|
||||
borderColor: 'warning.main',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{dayEvents.length > (isMobile ? 3 : 5) && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.55rem',
|
||||
color: isToday ? 'primary.contrastText' : 'text.secondary',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
+{dayEvents.length - (isMobile ? 3 : 5)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* On desktop: show short event titles */}
|
||||
{!isMobile && hasEvents && (
|
||||
<Box sx={{ width: '100%', mt: 0.25 }}>
|
||||
{dayEvents.slice(0, 2).map((ev, i) => (
|
||||
<Typography
|
||||
key={i}
|
||||
variant="caption"
|
||||
noWrap
|
||||
sx={{
|
||||
display: 'block',
|
||||
fontSize: '0.6rem',
|
||||
lineHeight: 1.3,
|
||||
color: ev.abgesagt ? 'text.disabled' : TYP_DOT_COLOR[ev.typ],
|
||||
textDecoration: ev.abgesagt ? 'line-through' : 'none',
|
||||
px: 0.25,
|
||||
}}
|
||||
>
|
||||
{ev.pflichtveranstaltung && '* '}{ev.titel}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Legend */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, mt: 2 }}>
|
||||
{Object.entries(TYP_DOT_COLOR).map(([typ, color]) => (
|
||||
<Box key={typ} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color }} />
|
||||
<Typography variant="caption" color="text.secondary">{typ}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'warning.main', border: '1.5px solid', borderColor: 'warning.dark' }} />
|
||||
<Typography variant="caption" color="text.secondary">Pflichtveranstaltung</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ListView({
|
||||
events,
|
||||
onEventClick,
|
||||
}: {
|
||||
events: UebungListItem[];
|
||||
onEventClick: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<List disablePadding>
|
||||
{events.map((ev, idx) => (
|
||||
<Box key={ev.id}>
|
||||
{idx > 0 && <Divider />}
|
||||
<ListItem
|
||||
onClick={() => onEventClick(ev.id)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
px: 1,
|
||||
py: 1,
|
||||
borderRadius: 1,
|
||||
opacity: ev.abgesagt ? 0.55 : 1,
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
{/* Date badge */}
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 52,
|
||||
textAlign: 'center',
|
||||
mr: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.disabled', fontSize: '0.65rem' }}>
|
||||
{new Date(ev.datum_von).getDate()}.
|
||||
{new Date(ev.datum_von).getMonth() + 1}.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||
{formatTime(ev.datum_von)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{ev.pflichtveranstaltung && (
|
||||
<StarIcon sx={{ fontSize: 14, color: 'warning.main' }} />
|
||||
)}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: ev.pflichtveranstaltung ? 700 : 400,
|
||||
textDecoration: ev.abgesagt ? 'line-through' : 'none',
|
||||
}}
|
||||
>
|
||||
{ev.titel}
|
||||
</Typography>
|
||||
{ev.abgesagt && (
|
||||
<Chip label="Abgesagt" size="small" color="error" variant="outlined" sx={{ fontSize: '0.6rem', height: 16 }} />
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={ev.typ}
|
||||
size="small"
|
||||
color={TYP_CHIP_COLOR[ev.typ]}
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.6rem', height: 16 }}
|
||||
/>
|
||||
{ev.ort && (
|
||||
<Typography variant="caption" color="text.disabled" noWrap>
|
||||
{ev.ort}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
sx={{ my: 0 }}
|
||||
/>
|
||||
|
||||
{/* RSVP badge */}
|
||||
<Box sx={{ ml: 1 }}>
|
||||
<RsvpDot status={ev.eigener_status} />
|
||||
</Box>
|
||||
</ListItem>
|
||||
</Box>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', py: 4 }}
|
||||
>
|
||||
Keine Veranstaltungen in diesem Monat.
|
||||
</Typography>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day Popover
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DayPopoverProps {
|
||||
anchorEl: Element | null;
|
||||
day: Date | null;
|
||||
events: UebungListItem[];
|
||||
onClose: () => void;
|
||||
onEventClick: (id: string) => void;
|
||||
}
|
||||
|
||||
function DayPopover({ anchorEl, day, events, onClose, onEventClick }: DayPopoverProps) {
|
||||
if (!day) return null;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
PaperProps={{ sx: { p: 1, maxWidth: 300, width: '90vw' } }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, px: 0.5 }}>
|
||||
{formatDateLong(day.toISOString())}
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
{events.map((ev) => (
|
||||
<ListItem
|
||||
key={ev.id}
|
||||
onClick={() => { onEventClick(ev.id); onClose(); }}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
px: 0.75,
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
opacity: ev.abgesagt ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: TYP_DOT_COLOR[ev.typ],
|
||||
mr: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: ev.pflichtveranstaltung ? 700 : 400,
|
||||
textDecoration: ev.abgesagt ? 'line-through' : 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{ev.pflichtveranstaltung && <StarIcon sx={{ fontSize: 12, color: 'warning.main' }} />}
|
||||
{ev.titel}
|
||||
</Typography>
|
||||
}
|
||||
secondary={`${formatTime(ev.datum_von)} – ${formatTime(ev.datum_bis)} Uhr`}
|
||||
/>
|
||||
<RsvpDot status={ev.eigener_status} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Kalender() {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const today = new Date();
|
||||
const [viewMonth, setViewMonth] = useState({ year: today.getFullYear(), month: today.getMonth() });
|
||||
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar');
|
||||
const [icalOpen, setIcalOpen] = useState(false);
|
||||
|
||||
// Popover state
|
||||
const [popoverAnchor, setPopoverAnchor] = useState<Element | null>(null);
|
||||
const [popoverDay, setPopoverDay] = useState<Date | null>(null);
|
||||
const [popoverEvents, setPopoverEvents] = useState<UebungListItem[]>([]);
|
||||
|
||||
// Compute fetch range: whole month ± 1 week buffer for grid
|
||||
const { from, to } = useMemo(() => {
|
||||
const firstCell = new Date(viewMonth.year, viewMonth.month, 1);
|
||||
const dayOfWeek = (firstCell.getDay() + 6) % 7;
|
||||
const f = new Date(firstCell);
|
||||
f.setDate(f.getDate() - dayOfWeek);
|
||||
|
||||
const lastCell = new Date(f);
|
||||
lastCell.setDate(lastCell.getDate() + 41);
|
||||
|
||||
return { from: f, to: lastCell };
|
||||
}, [viewMonth]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['training', 'calendar', from.toISOString(), to.toISOString()],
|
||||
queryFn: () => trainingApi.getCalendarRange(from, to),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const events = useMemo(() => data ?? [], [data]);
|
||||
|
||||
const handlePrev = () => {
|
||||
setViewMonth((prev) => {
|
||||
const m = prev.month === 0 ? 11 : prev.month - 1;
|
||||
const y = prev.month === 0 ? prev.year - 1 : prev.year;
|
||||
return { year: y, month: m };
|
||||
});
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setViewMonth((prev) => {
|
||||
const m = prev.month === 11 ? 0 : prev.month + 1;
|
||||
const y = prev.month === 11 ? prev.year + 1 : prev.year;
|
||||
return { year: y, month: m };
|
||||
});
|
||||
};
|
||||
|
||||
const handleToday = () => {
|
||||
setViewMonth({ year: today.getFullYear(), month: today.getMonth() });
|
||||
};
|
||||
|
||||
const handleDayClick = useCallback((day: Date, anchor: Element) => {
|
||||
const key = day.toISOString().slice(0, 10);
|
||||
const dayEvs = events.filter(
|
||||
(ev) => startOfDay(new Date(ev.datum_von)).toISOString().slice(0, 10) === key
|
||||
);
|
||||
setPopoverDay(day);
|
||||
setPopoverAnchor(anchor);
|
||||
setPopoverEvents(dayEvs);
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto' }}>
|
||||
{/* Page header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<CalendarIcon color="primary" />
|
||||
<Typography variant="h5" sx={{ flexGrow: 1, fontWeight: 700 }}>
|
||||
Dienstkalender
|
||||
</Typography>
|
||||
|
||||
{/* View toggle */}
|
||||
<ButtonGroup size="small" variant="outlined">
|
||||
<Tooltip title="Monatsansicht">
|
||||
<Button
|
||||
onClick={() => setViewMode('calendar')}
|
||||
variant={viewMode === 'calendar' ? 'contained' : 'outlined'}
|
||||
>
|
||||
<CalendarIcon fontSize="small" />
|
||||
{!isMobile && <Box sx={{ ml: 0.5 }}>Monat</Box>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Listenansicht">
|
||||
<Button
|
||||
onClick={() => setViewMode('list')}
|
||||
variant={viewMode === 'list' ? 'contained' : 'outlined'}
|
||||
>
|
||||
<ListViewIcon fontSize="small" />
|
||||
{!isMobile && <Box sx={{ ml: 0.5 }}>Liste</Box>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<CopyIcon fontSize="small" />}
|
||||
onClick={() => setIcalOpen(true)}
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{isMobile ? 'iCal' : 'Kalender abonnieren'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Month navigation */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={handlePrev} size="small" aria-label="Vorheriger Monat">
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ flexGrow: 1, textAlign: 'center', fontWeight: 600 }}
|
||||
>
|
||||
{MONTH_LABELS[viewMonth.month]} {viewMonth.year}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<TodayIcon fontSize="small" />}
|
||||
onClick={handleToday}
|
||||
sx={{ minWidth: 'auto' }}
|
||||
>
|
||||
{!isMobile && 'Heute'}
|
||||
</Button>
|
||||
|
||||
<IconButton onClick={handleNext} size="small" aria-label="Nächster Monat">
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Calendar / List body */}
|
||||
{isLoading ? (
|
||||
<Skeleton variant="rectangular" height={isMobile ? 320 : 480} sx={{ borderRadius: 2 }} />
|
||||
) : viewMode === 'calendar' ? (
|
||||
<MonthCalendar
|
||||
year={viewMonth.year}
|
||||
month={viewMonth.month}
|
||||
events={events}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
events={events.filter((ev) => {
|
||||
const d = new Date(ev.datum_von);
|
||||
return d.getMonth() === viewMonth.month && d.getFullYear() === viewMonth.year;
|
||||
})}
|
||||
onEventClick={(id) => navigate(`/training/${id}`)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Day Popover */}
|
||||
<DayPopover
|
||||
anchorEl={popoverAnchor}
|
||||
day={popoverDay}
|
||||
events={popoverEvents}
|
||||
onClose={() => setPopoverAnchor(null)}
|
||||
onEventClick={(id) => navigate(`/training/${id}`)}
|
||||
/>
|
||||
|
||||
{/* iCal Subscribe Dialog */}
|
||||
<IcalDialog open={icalOpen} onClose={() => setIcalOpen(false)} />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
792
frontend/src/pages/MitgliedDetail.tsx
Normal file
792
frontend/src/pages/MitgliedDetail.tsx
Normal file
@@ -0,0 +1,792 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Avatar,
|
||||
Button,
|
||||
Chip,
|
||||
Tabs,
|
||||
Tab,
|
||||
Grid,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Divider,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Stack,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Save as SaveIcon,
|
||||
Cancel as CancelIcon,
|
||||
Person as PersonIcon,
|
||||
Phone as PhoneIcon,
|
||||
Badge as BadgeIcon,
|
||||
Security as SecurityIcon,
|
||||
History as HistoryIcon,
|
||||
DriveEta as DriveEtaIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { membersService } from '../services/members';
|
||||
import {
|
||||
MemberWithProfile,
|
||||
StatusEnum,
|
||||
DienstgradEnum,
|
||||
FunktionEnum,
|
||||
TshirtGroesseEnum,
|
||||
DIENSTGRAD_VALUES,
|
||||
STATUS_VALUES,
|
||||
FUNKTION_VALUES,
|
||||
TSHIRT_GROESSE_VALUES,
|
||||
STATUS_LABELS,
|
||||
STATUS_COLORS,
|
||||
getMemberDisplayName,
|
||||
formatPhone,
|
||||
UpdateMemberProfileData,
|
||||
} from '../types/member.types';
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Role helpers
|
||||
// ----------------------------------------------------------------
|
||||
function useCanWrite(): boolean {
|
||||
const { user } = useAuth();
|
||||
const groups: string[] = (user as any)?.groups ?? [];
|
||||
return groups.includes('feuerwehr-admin') || groups.includes('feuerwehr-kommandant');
|
||||
}
|
||||
|
||||
function useCurrentUserId(): string | undefined {
|
||||
const { user } = useAuth();
|
||||
return (user as any)?.id;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Tab panel helper
|
||||
// ----------------------------------------------------------------
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
value: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function TabPanel({ children, value, index }: TabPanelProps) {
|
||||
return (
|
||||
<div role="tabpanel" hidden={value !== index} aria-labelledby={`tab-${index}`}>
|
||||
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Rank history timeline component
|
||||
// ----------------------------------------------------------------
|
||||
interface RankTimelineProps {
|
||||
entries: NonNullable<MemberWithProfile['dienstgrad_verlauf']>;
|
||||
}
|
||||
|
||||
function RankTimeline({ entries }: RankTimelineProps) {
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Keine Dienstgradänderungen eingetragen.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={0}>
|
||||
{entries.map((entry, idx) => (
|
||||
<Box
|
||||
key={entry.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
position: 'relative',
|
||||
pb: 2,
|
||||
'&::before': idx < entries.length - 1 ? {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 11,
|
||||
top: 24,
|
||||
bottom: 0,
|
||||
width: 2,
|
||||
bgcolor: 'divider',
|
||||
} : {},
|
||||
}}
|
||||
>
|
||||
{/* Timeline dot */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'primary.main',
|
||||
flexShrink: 0,
|
||||
mt: 0.25,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<HistoryIcon sx={{ fontSize: 14, color: 'white' }} />
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{entry.dienstgrad_neu}
|
||||
</Typography>
|
||||
{entry.dienstgrad_alt && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
vorher: {entry.dienstgrad_alt}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(entry.datum).toLocaleDateString('de-AT')}
|
||||
</Typography>
|
||||
{entry.durch_user_name && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
· durch {entry.durch_user_name}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{entry.bemerkung && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{entry.bemerkung}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Read-only field row
|
||||
// ----------------------------------------------------------------
|
||||
function FieldRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 1, py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 180, flexShrink: 0 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>
|
||||
{value ?? '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Main component
|
||||
// ----------------------------------------------------------------
|
||||
function MitgliedDetail() {
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const canWrite = useCanWrite();
|
||||
const currentUserId = useCurrentUserId();
|
||||
const isOwnProfile = currentUserId === userId;
|
||||
const canEdit = canWrite || isOwnProfile;
|
||||
|
||||
// --- state ---
|
||||
const [member, setMember] = useState<MemberWithProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// Edit form state — only the fields the user is allowed to change
|
||||
const [formData, setFormData] = useState<UpdateMemberProfileData>({});
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Data loading
|
||||
// ----------------------------------------------------------------
|
||||
const loadMember = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await membersService.getMember(userId);
|
||||
setMember(data);
|
||||
} catch {
|
||||
setError('Mitglied konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMember();
|
||||
}, [loadMember]);
|
||||
|
||||
// Populate form from current profile
|
||||
useEffect(() => {
|
||||
if (member?.profile) {
|
||||
setFormData({
|
||||
mitglieds_nr: member.profile.mitglieds_nr ?? undefined,
|
||||
dienstgrad: member.profile.dienstgrad ?? undefined,
|
||||
funktion: member.profile.funktion,
|
||||
status: member.profile.status,
|
||||
eintrittsdatum: member.profile.eintrittsdatum ?? undefined,
|
||||
geburtsdatum: member.profile.geburtsdatum ?? undefined,
|
||||
telefon_mobil: member.profile.telefon_mobil ?? undefined,
|
||||
telefon_privat: member.profile.telefon_privat ?? undefined,
|
||||
notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined,
|
||||
notfallkontakt_telefon: member.profile.notfallkontakt_telefon ?? undefined,
|
||||
fuehrerscheinklassen: member.profile.fuehrerscheinklassen,
|
||||
tshirt_groesse: member.profile.tshirt_groesse ?? undefined,
|
||||
schuhgroesse: member.profile.schuhgroesse ?? undefined,
|
||||
bemerkungen: member.profile.bemerkungen ?? undefined,
|
||||
});
|
||||
}
|
||||
}, [member]);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Save
|
||||
// ----------------------------------------------------------------
|
||||
const handleSave = async () => {
|
||||
if (!userId) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const updated = await membersService.updateMember(userId, formData);
|
||||
setMember(updated);
|
||||
setEditMode(false);
|
||||
} catch {
|
||||
setSaveError('Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditMode(false);
|
||||
setSaveError(null);
|
||||
// Reset form to current profile values
|
||||
if (member?.profile) {
|
||||
setFormData({
|
||||
telefon_mobil: member.profile.telefon_mobil ?? undefined,
|
||||
telefon_privat: member.profile.telefon_privat ?? undefined,
|
||||
notfallkontakt_name: member.profile.notfallkontakt_name ?? undefined,
|
||||
notfallkontakt_telefon: member.profile.notfallkontakt_telefon ?? undefined,
|
||||
tshirt_groesse: member.profile.tshirt_groesse ?? undefined,
|
||||
schuhgroesse: member.profile.schuhgroesse ?? undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = (field: keyof UpdateMemberProfileData, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Render helpers
|
||||
// ----------------------------------------------------------------
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !member) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="md">
|
||||
<Alert severity="error" sx={{ mt: 4 }}>
|
||||
{error ?? 'Mitglied nicht gefunden.'}
|
||||
</Alert>
|
||||
<Button sx={{ mt: 2 }} onClick={() => navigate('/mitglieder')}>
|
||||
Zurück zur Liste
|
||||
</Button>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = getMemberDisplayName(member);
|
||||
const profile = member.profile;
|
||||
const initials = [member.given_name?.[0], member.family_name?.[0]]
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
.toUpperCase() || member.email[0].toUpperCase();
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
{/* Back button */}
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => navigate('/mitglieder')}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
← Mitgliederliste
|
||||
</Button>
|
||||
|
||||
{/* Header card */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<Avatar
|
||||
src={profile?.bild_url ?? member.profile_picture_url ?? undefined}
|
||||
alt={displayName}
|
||||
sx={{ width: 80, height: 80, fontSize: '1.75rem' }}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h5" fontWeight={600}>
|
||||
{displayName}
|
||||
</Typography>
|
||||
{profile?.mitglieds_nr && (
|
||||
<Chip
|
||||
icon={<BadgeIcon />}
|
||||
label={`Nr. ${profile.mitglieds_nr}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{profile?.status && (
|
||||
<Chip
|
||||
label={STATUS_LABELS[profile.status]}
|
||||
size="small"
|
||||
color={STATUS_COLORS[profile.status]}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mt: 0.5 }}>
|
||||
{member.email}
|
||||
</Typography>
|
||||
|
||||
{profile?.dienstgrad && (
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||
<strong>Dienstgrad:</strong> {profile.dienstgrad}
|
||||
{profile.dienstgrad_seit
|
||||
? ` (seit ${new Date(profile.dienstgrad_seit).toLocaleDateString('de-AT')})`
|
||||
: ''}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{profile && profile.funktion.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, mt: 1, flexWrap: 'wrap' }}>
|
||||
{profile.funktion.map((f) => (
|
||||
<Chip key={f} label={f} size="small" color="secondary" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Edit controls */}
|
||||
{canEdit && (
|
||||
<Box>
|
||||
{editMode ? (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Tooltip title="Änderungen speichern">
|
||||
<span>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
aria-label="Speichern"
|
||||
>
|
||||
{saving ? <CircularProgress size={20} /> : <SaveIcon />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Abbrechen">
|
||||
<IconButton
|
||||
onClick={handleCancelEdit}
|
||||
disabled={saving}
|
||||
aria-label="Abbrechen"
|
||||
>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
) : (
|
||||
<Tooltip title="Bearbeiten">
|
||||
<IconButton onClick={() => setEditMode(true)} aria-label="Bearbeiten">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!profile && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Für dieses Mitglied wurde noch kein Profil angelegt.
|
||||
{canWrite && ' Ein Kommandant kann das Profil unter "Stammdaten" erstellen.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }} onClose={() => setSaveError(null)}>
|
||||
{saveError}
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_e, v) => setActiveTab(v)}
|
||||
aria-label="Mitglied Details"
|
||||
>
|
||||
<Tab label="Stammdaten" id="tab-0" aria-controls="tabpanel-0" />
|
||||
<Tab label="Qualifikationen" id="tab-1" aria-controls="tabpanel-1" />
|
||||
<Tab label="Einsätze" id="tab-2" aria-controls="tabpanel-2" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* ---- Tab 0: Stammdaten ---- */}
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<Grid container spacing={3}>
|
||||
{/* Personal data */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardHeader
|
||||
avatar={<PersonIcon color="primary" />}
|
||||
title="Persönliche Daten"
|
||||
/>
|
||||
<CardContent>
|
||||
{editMode && canWrite ? (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Dienstgrad"
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.dienstgrad ?? ''}
|
||||
onChange={(e) => handleFieldChange('dienstgrad', e.target.value as DienstgradEnum || undefined)}
|
||||
>
|
||||
<MenuItem value="">—</MenuItem>
|
||||
{DIENSTGRAD_VALUES.map((dg) => (
|
||||
<MenuItem key={dg} value={dg}>{dg}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label="Dienstgrad seit"
|
||||
type="date"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.dienstgrad_seit ?? ''}
|
||||
onChange={(e) => handleFieldChange('dienstgrad_seit', e.target.value || undefined)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Status"
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.status ?? 'aktiv'}
|
||||
onChange={(e) => handleFieldChange('status', e.target.value as StatusEnum)}
|
||||
>
|
||||
{STATUS_VALUES.map((s) => (
|
||||
<MenuItem key={s} value={s}>{STATUS_LABELS[s]}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label="Mitgliedsnummer"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.mitglieds_nr ?? ''}
|
||||
onChange={(e) => handleFieldChange('mitglieds_nr', e.target.value || undefined)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Eintrittsdatum"
|
||||
type="date"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.eintrittsdatum ?? ''}
|
||||
onChange={(e) => handleFieldChange('eintrittsdatum', e.target.value || undefined)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Geburtsdatum"
|
||||
type="date"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.geburtsdatum ?? ''}
|
||||
onChange={(e) => handleFieldChange('geburtsdatum', e.target.value || undefined)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<FieldRow label="Dienstgrad" value={profile?.dienstgrad ?? null} />
|
||||
<FieldRow
|
||||
label="Dienstgrad seit"
|
||||
value={profile?.dienstgrad_seit
|
||||
? new Date(profile.dienstgrad_seit).toLocaleDateString('de-AT')
|
||||
: null}
|
||||
/>
|
||||
<FieldRow label="Status" value={
|
||||
profile?.status
|
||||
? <Chip label={STATUS_LABELS[profile.status]} size="small" color={STATUS_COLORS[profile.status]} />
|
||||
: null
|
||||
} />
|
||||
<FieldRow label="Mitgliedsnummer" value={profile?.mitglieds_nr ?? null} />
|
||||
<FieldRow
|
||||
label="Eintrittsdatum"
|
||||
value={profile?.eintrittsdatum
|
||||
? new Date(profile.eintrittsdatum).toLocaleDateString('de-AT')
|
||||
: null}
|
||||
/>
|
||||
<FieldRow
|
||||
label="Geburtsdatum"
|
||||
value={
|
||||
profile?.geburtsdatum
|
||||
? new Date(profile.geburtsdatum).toLocaleDateString('de-AT')
|
||||
: profile?._age
|
||||
? `(${profile._age} Jahre)`
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Contact */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardHeader
|
||||
avatar={<PhoneIcon color="primary" />}
|
||||
title="Kontaktdaten"
|
||||
/>
|
||||
<CardContent>
|
||||
{editMode ? (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Mobil"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.telefon_mobil ?? ''}
|
||||
onChange={(e) => handleFieldChange('telefon_mobil', e.target.value || undefined)}
|
||||
placeholder="+436641234567"
|
||||
/>
|
||||
<TextField
|
||||
label="Privat"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.telefon_privat ?? ''}
|
||||
onChange={(e) => handleFieldChange('telefon_privat', e.target.value || undefined)}
|
||||
placeholder="+4371234567"
|
||||
/>
|
||||
<Divider />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Notfallkontakt
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Name"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.notfallkontakt_name ?? ''}
|
||||
onChange={(e) => handleFieldChange('notfallkontakt_name', e.target.value || undefined)}
|
||||
/>
|
||||
<TextField
|
||||
label="Telefon"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.notfallkontakt_telefon ?? ''}
|
||||
onChange={(e) => handleFieldChange('notfallkontakt_telefon', e.target.value || undefined)}
|
||||
placeholder="+436641234567"
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<FieldRow label="Mobil" value={formatPhone(profile?.telefon_mobil)} />
|
||||
<FieldRow label="Privat" value={formatPhone(profile?.telefon_privat)} />
|
||||
<FieldRow
|
||||
label="E-Mail"
|
||||
value={
|
||||
<a href={`mailto:${member.email}`} style={{ color: 'inherit' }}>
|
||||
{member.email}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
|
||||
Notfallkontakt
|
||||
</Typography>
|
||||
<FieldRow label="Name" value={profile?.notfallkontakt_name ?? null} />
|
||||
<FieldRow label="Telefon" value={formatPhone(profile?.notfallkontakt_telefon)} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Uniform sizing */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardHeader
|
||||
avatar={<SecurityIcon color="primary" />}
|
||||
title="Ausrüstung & Uniform"
|
||||
/>
|
||||
<CardContent>
|
||||
{editMode ? (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="T-Shirt Größe"
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.tshirt_groesse ?? ''}
|
||||
onChange={(e) => handleFieldChange('tshirt_groesse', e.target.value as TshirtGroesseEnum || undefined)}
|
||||
>
|
||||
<MenuItem value="">—</MenuItem>
|
||||
{TSHIRT_GROESSE_VALUES.map((g) => (
|
||||
<MenuItem key={g} value={g}>{g}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Schuhgröße"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formData.schuhgroesse ?? ''}
|
||||
onChange={(e) => handleFieldChange('schuhgroesse', e.target.value || undefined)}
|
||||
placeholder="z.B. 43"
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<FieldRow label="T-Shirt Größe" value={profile?.tshirt_groesse ?? null} />
|
||||
<FieldRow label="Schuhgröße" value={profile?.schuhgroesse ?? null} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Driving licenses */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardHeader
|
||||
avatar={<DriveEtaIcon color="primary" />}
|
||||
title="Führerscheinklassen"
|
||||
/>
|
||||
<CardContent>
|
||||
{profile?.fuehrerscheinklassen && profile.fuehrerscheinklassen.length > 0 ? (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{profile.fuehrerscheinklassen.map((k) => (
|
||||
<Chip key={k} label={k} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color="text.secondary" variant="body2">—</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Rank history */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardHeader
|
||||
avatar={<HistoryIcon color="primary" />}
|
||||
title="Dienstgrad-Verlauf"
|
||||
/>
|
||||
<CardContent>
|
||||
<RankTimeline entries={member.dienstgrad_verlauf ?? []} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Remarks — Kommandant/Admin only */}
|
||||
{canWrite && (
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardHeader title="Interne Bemerkungen" />
|
||||
<CardContent>
|
||||
{editMode ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
label="Bemerkungen"
|
||||
value={formData.bemerkungen ?? ''}
|
||||
onChange={(e) => handleFieldChange('bemerkungen', e.target.value || undefined)}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color={profile?.bemerkungen ? 'text.primary' : 'text.secondary'}>
|
||||
{profile?.bemerkungen ?? 'Keine Bemerkungen eingetragen.'}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* ---- Tab 1: Qualifikationen (placeholder) ---- */}
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6, gap: 2 }}>
|
||||
<SecurityIcon sx={{ fontSize: 64, color: 'text.disabled' }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Qualifikationen & Lehrgänge
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center" maxWidth={480}>
|
||||
Diese Funktion wird in einer zukünftigen Version verfügbar sein.
|
||||
Geplant: Atemschutz, G26-Untersuchungen, Absolvierte Kurse, Gültigkeitsdaten.
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
{/* ---- Tab 2: Einsätze (placeholder) ---- */}
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6, gap: 2 }}>
|
||||
<PersonIcon sx={{ fontSize: 64, color: 'text.disabled' }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Einsätze dieses Mitglieds
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center" maxWidth={480}>
|
||||
Diese Funktion wird verfügbar sobald das Einsatz-Modul implementiert ist.
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default MitgliedDetail;
|
||||
551
frontend/src/pages/UebungDetail.tsx
Normal file
551
frontend/src/pages/UebungDetail.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Button,
|
||||
Divider,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Checkbox,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Paper,
|
||||
Stack,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Cancel as CancelIcon,
|
||||
HelpOutline as UnknownIcon,
|
||||
Star as StarIcon,
|
||||
LocationOn as LocationIcon,
|
||||
AccessTime as TimeIcon,
|
||||
Group as GroupIcon,
|
||||
ArrowBack as BackIcon,
|
||||
Edit as EditIcon,
|
||||
Info as InfoIcon,
|
||||
HowToReg as AttendanceIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { trainingApi } from '../services/training';
|
||||
import type { TeilnahmeStatus, UebungTyp, Teilnahme } from '../types/training.types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TYP_CHIP_COLOR: Record<
|
||||
UebungTyp,
|
||||
'primary' | 'secondary' | 'warning' | 'default' | 'error' | 'info' | 'success'
|
||||
> = {
|
||||
'Übungsabend': 'primary',
|
||||
'Lehrgang': 'secondary',
|
||||
'Sonderdienst': 'warning',
|
||||
'Versammlung': 'default',
|
||||
'Gemeinschaftsübung': 'info',
|
||||
'Sonstiges': 'default',
|
||||
};
|
||||
|
||||
const WEEKDAY = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
|
||||
const MONTH = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||
|
||||
function formatDateFull(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${WEEKDAY[d.getDay()]}, ${d.getDate()}. ${MONTH[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')} Uhr`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Role helper — reads `role` from the user object (added by Tier 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROLE_ORDER: Record<string, number> = {
|
||||
mitglied: 0, gruppenfuehrer: 1, kommandant: 2, admin: 3,
|
||||
};
|
||||
|
||||
function hasRole(userRole: string | undefined, minRole: string): boolean {
|
||||
return (ROLE_ORDER[userRole ?? 'mitglied'] ?? 0) >= (ROLE_ORDER[minRole] ?? 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RSVP Status icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusIcon({ status }: { status: TeilnahmeStatus | undefined }) {
|
||||
if (!status || status === 'unbekannt') return <UnknownIcon sx={{ color: 'text.disabled' }} />;
|
||||
if (status === 'zugesagt') return <CheckIcon sx={{ color: 'success.main' }} />;
|
||||
if (status === 'erschienen') return <CheckIcon sx={{ color: 'success.dark' }} />;
|
||||
if (status === 'entschuldigt') return <CancelIcon sx={{ color: 'warning.main' }} />;
|
||||
return <CancelIcon sx={{ color: 'error.main' }} />;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<TeilnahmeStatus, string> = {
|
||||
zugesagt: 'Zugesagt',
|
||||
abgesagt: 'Abgesagt',
|
||||
erschienen: 'Erschienen',
|
||||
entschuldigt:'Entschuldigt',
|
||||
unbekannt: 'Ausstehend',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mark Attendance Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MarkAttendanceDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
uebungId: string;
|
||||
teilnahmen: Teilnahme[];
|
||||
}
|
||||
|
||||
function MarkAttendanceDialog({
|
||||
open, onClose, uebungId, teilnahmen,
|
||||
}: MarkAttendanceDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
// Pre-select anyone already marked zugesagt
|
||||
new Set(teilnahmen.filter((t) => t.status === 'zugesagt' || t.status === 'erschienen').map((t) => t.user_id))
|
||||
);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => trainingApi.markAttendance(uebungId, Array.from(selected)),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['training', 'event', uebungId] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = (userId: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AttendanceIcon color="primary" />
|
||||
Anwesenheit erfassen
|
||||
</DialogTitle>
|
||||
<DialogContent dividers sx={{ p: 0 }}>
|
||||
<List dense>
|
||||
{teilnahmen.map((t) => (
|
||||
<ListItem
|
||||
key={t.user_id}
|
||||
onClick={() => toggle(t.user_id)}
|
||||
sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<Checkbox
|
||||
checked={selected.has(t.user_id)}
|
||||
size="small"
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t.user_name ?? t.user_email ?? t.user_id}
|
||||
secondary={STATUS_LABEL[t.status]}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 2, pb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexGrow: 1 }}>
|
||||
{selected.size} von {teilnahmen.length} ausgewählt
|
||||
</Typography>
|
||||
<Button onClick={onClose} disabled={mutation.isPending}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
startIcon={mutation.isPending ? <CircularProgress size={16} /> : <CheckIcon />}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attendee Accordion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AttendeeAccordion({
|
||||
teilnahmen,
|
||||
counts,
|
||||
userRole,
|
||||
}: {
|
||||
teilnahmen?: Teilnahme[];
|
||||
counts: {
|
||||
anzahl_zugesagt: number;
|
||||
anzahl_abgesagt: number;
|
||||
anzahl_unbekannt: number;
|
||||
anzahl_entschuldigt: number;
|
||||
anzahl_erschienen: number;
|
||||
gesamt_eingeladen: number;
|
||||
};
|
||||
userRole?: string;
|
||||
}) {
|
||||
const canSeeList = hasRole(userRole, 'gruppenfuehrer');
|
||||
|
||||
return (
|
||||
<Accordion disableGutters elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<GroupIcon fontSize="small" color="action" />
|
||||
<Typography variant="subtitle2">Rückmeldungen</Typography>
|
||||
<Chip label={`${counts.anzahl_zugesagt} zugesagt`} size="small" color="success" variant="outlined" sx={{ fontSize: '0.65rem', height: 18 }} />
|
||||
<Chip label={`${counts.anzahl_abgesagt} abgesagt`} size="small" color="error" variant="outlined" sx={{ fontSize: '0.65rem', height: 18 }} />
|
||||
<Chip label={`${counts.anzahl_unbekannt} ausstehend`} size="small" color="default" variant="outlined" sx={{ fontSize: '0.65rem', height: 18 }} />
|
||||
{counts.anzahl_erschienen > 0 && (
|
||||
<Chip label={`${counts.anzahl_erschienen} erschienen`} size="small" color="primary" variant="outlined" sx={{ fontSize: '0.65rem', height: 18 }} />
|
||||
)}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
{!canSeeList && (
|
||||
<Alert severity="info" icon={<InfoIcon fontSize="small" />} sx={{ mb: 1 }}>
|
||||
Nur Gruppenführer und Kommandanten sehen die individuelle Rückmeldungsliste.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{canSeeList && teilnahmen && (
|
||||
<List dense disablePadding>
|
||||
{teilnahmen.map((t) => (
|
||||
<ListItem key={t.user_id} disablePadding sx={{ py: 0.25 }}>
|
||||
<ListItemIcon sx={{ minWidth: 32 }}>
|
||||
<StatusIcon status={t.status} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t.user_name ?? t.user_email ?? t.user_id}
|
||||
secondary={
|
||||
<Box component="span" sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<span>{STATUS_LABEL[t.status]}</span>
|
||||
{t.bemerkung && (
|
||||
<Typography component="span" variant="caption" color="text.disabled">
|
||||
— {t.bemerkung}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{canSeeList && !teilnahmen && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Teilnehmer gefunden.
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function UebungDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
// We cast user to include `role` (added by Tier 1)
|
||||
const userRole = (user as any)?.role as string | undefined;
|
||||
const canWrite = hasRole(userRole, 'gruppenfuehrer');
|
||||
|
||||
const [markAttendanceOpen, setMarkAttendanceOpen] = useState(false);
|
||||
const [rsvpLoading, setRsvpLoading] = useState<'zugesagt' | 'abgesagt' | null>(null);
|
||||
|
||||
const { data: event, isLoading, isError } = useQuery({
|
||||
queryKey: ['training', 'event', id],
|
||||
queryFn: () => trainingApi.getById(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const rsvpMutation = useMutation({
|
||||
mutationFn: (status: 'zugesagt' | 'abgesagt') =>
|
||||
trainingApi.updateRsvp(id!, status),
|
||||
onSuccess: (_data, status) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['training', 'event', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['training', 'upcoming'] });
|
||||
setRsvpLoading(null);
|
||||
},
|
||||
onError: () => setRsvpLoading(null),
|
||||
});
|
||||
|
||||
const handleRsvp = useCallback((status: 'zugesagt' | 'abgesagt') => {
|
||||
setRsvpLoading(status);
|
||||
rsvpMutation.mutate(status);
|
||||
}, [rsvpMutation]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Loading / error states
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
||||
<Skeleton variant="text" width={200} height={32} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="rectangular" height={160} sx={{ borderRadius: 2, mb: 2 }} />
|
||||
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2, mb: 2 }} />
|
||||
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2 }} />
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !event) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Alert severity="error" sx={{ maxWidth: 720, mx: 'auto' }}>
|
||||
Veranstaltung konnte nicht geladen werden.
|
||||
</Alert>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const isPast = new Date(event.datum_von) < new Date();
|
||||
const isAlreadyRsvp = event.eigener_status === 'zugesagt' || event.eigener_status === 'abgesagt';
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
|
||||
{/* Back button */}
|
||||
<Button
|
||||
startIcon={<BackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 2, textTransform: 'none' }}
|
||||
size="small"
|
||||
>
|
||||
Zurück zum Kalender
|
||||
</Button>
|
||||
|
||||
{/* Cancelled banner */}
|
||||
{event.abgesagt && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<strong>Abgesagt:</strong> {event.absage_grund ?? 'Kein Grund angegeben.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Header card */}
|
||||
<Paper elevation={1} sx={{ p: { xs: 2, sm: 3 }, mb: 2, borderRadius: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={event.typ}
|
||||
color={TYP_CHIP_COLOR[event.typ]}
|
||||
size="small"
|
||||
/>
|
||||
{event.pflichtveranstaltung && (
|
||||
<Chip
|
||||
icon={<StarIcon sx={{ fontSize: 14 }} />}
|
||||
label="Pflichtveranstaltung"
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{canWrite && (
|
||||
<Tooltip title="Bearbeiten">
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<EditIcon fontSize="small" />}
|
||||
sx={{ ml: 'auto', textTransform: 'none' }}
|
||||
onClick={() => navigate(`/training/${id}/bearbeiten`)}
|
||||
>
|
||||
{!isMobile && 'Bearbeiten'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
textDecoration: event.abgesagt ? 'line-through' : 'none',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
{event.titel}
|
||||
</Typography>
|
||||
|
||||
{/* Meta info */}
|
||||
<Stack spacing={0.75}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TimeIcon fontSize="small" color="action" />
|
||||
<Typography variant="body2">
|
||||
{formatDateFull(event.datum_von)},{' '}
|
||||
{formatTime(event.datum_von)} – {formatTime(event.datum_bis)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{event.ort && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LocationIcon fontSize="small" color="action" />
|
||||
<Typography variant="body2">{event.ort}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{event.treffpunkt && event.treffpunkt !== event.ort && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LocationIcon fontSize="small" color="action" sx={{ opacity: 0.5 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Treffpunkt: {event.treffpunkt}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{event.angelegt_von_name && (
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
Erstellt von {event.angelegt_von_name}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Description */}
|
||||
{event.beschreibung && (
|
||||
<Paper elevation={1} sx={{ p: { xs: 2, sm: 3 }, mb: 2, borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Beschreibung</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{event.beschreibung}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* RSVP section */}
|
||||
{!event.abgesagt && !isPast && (
|
||||
<Paper elevation={1} sx={{ p: { xs: 2, sm: 3 }, mb: 2, borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Meine Rückmeldung
|
||||
</Typography>
|
||||
|
||||
{event.eigener_status && event.eigener_status !== 'unbekannt' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<StatusIcon status={event.eigener_status} />
|
||||
<Typography variant="body2">
|
||||
Aktuelle Rückmeldung: <strong>{STATUS_LABEL[event.eigener_status]}</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
direction={isMobile ? 'column' : 'row'}
|
||||
spacing={1.5}
|
||||
>
|
||||
<Button
|
||||
variant={event.eigener_status === 'zugesagt' ? 'contained' : 'outlined'}
|
||||
color="success"
|
||||
size="large"
|
||||
startIcon={
|
||||
rsvpLoading === 'zugesagt'
|
||||
? <CircularProgress size={18} color="inherit" />
|
||||
: <CheckIcon />
|
||||
}
|
||||
onClick={() => handleRsvp('zugesagt')}
|
||||
disabled={rsvpMutation.isPending}
|
||||
fullWidth={isMobile}
|
||||
sx={{
|
||||
minHeight: 56, // Large tap target per mobile requirement
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
Zusagen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={event.eigener_status === 'abgesagt' ? 'contained' : 'outlined'}
|
||||
color="error"
|
||||
size="large"
|
||||
startIcon={
|
||||
rsvpLoading === 'abgesagt'
|
||||
? <CircularProgress size={18} color="inherit" />
|
||||
: <CancelIcon />
|
||||
}
|
||||
onClick={() => handleRsvp('abgesagt')}
|
||||
disabled={rsvpMutation.isPending}
|
||||
fullWidth={isMobile}
|
||||
sx={{
|
||||
minHeight: 56, // Large tap target per mobile requirement
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
Absagen
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Attendee summary + list */}
|
||||
<Paper elevation={1} sx={{ p: { xs: 2, sm: 3 }, mb: 2, borderRadius: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Typography variant="subtitle2">Teilnehmer</Typography>
|
||||
{canWrite && !event.abgesagt && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<AttendanceIcon fontSize="small" />}
|
||||
onClick={() => setMarkAttendanceOpen(true)}
|
||||
sx={{ textTransform: 'none' }}
|
||||
>
|
||||
Anwesenheit erfassen
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<AttendeeAccordion
|
||||
teilnahmen={event.teilnahmen}
|
||||
counts={event}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Mark Attendance Dialog */}
|
||||
{event.teilnahmen && (
|
||||
<MarkAttendanceDialog
|
||||
open={markAttendanceOpen}
|
||||
onClose={() => setMarkAttendanceOpen(false)}
|
||||
uebungId={id!}
|
||||
teilnahmen={event.teilnahmen}
|
||||
/>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
733
frontend/src/pages/admin/AuditLog.tsx
Normal file
733
frontend/src/pages/admin/AuditLog.tsx
Normal file
@@ -0,0 +1,733 @@
|
||||
/**
|
||||
* AuditLog — Admin page
|
||||
*
|
||||
* Displays the immutable audit trail with filtering, pagination, and CSV export.
|
||||
* Uses server-side pagination via the DataGrid's paginationMode="server" prop.
|
||||
*
|
||||
* Required packages (add to frontend/package.json dependencies):
|
||||
* "@mui/x-data-grid": "^6.x || ^7.x"
|
||||
* "@mui/x-date-pickers": "^6.x || ^7.x"
|
||||
* "date-fns": "^3.x"
|
||||
* "@date-io/date-fns": "^3.x" (adapter for MUI date pickers)
|
||||
*
|
||||
* Install:
|
||||
* npm install @mui/x-data-grid @mui/x-date-pickers date-fns @date-io/date-fns
|
||||
*
|
||||
* Route registration in App.tsx:
|
||||
* import AuditLog from './pages/admin/AuditLog';
|
||||
* // Inside <Routes>:
|
||||
* <Route
|
||||
* path="/admin/audit-log"
|
||||
* element={
|
||||
* <ProtectedRoute requireRole="admin">
|
||||
* <AuditLog />
|
||||
* </ProtectedRoute>
|
||||
* }
|
||||
* />
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
Skeleton,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridPaginationModel,
|
||||
GridRenderCellParams,
|
||||
GridRowParams,
|
||||
} from '@mui/x-data-grid';
|
||||
import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import FilterAltIcon from '@mui/icons-material/FilterAlt';
|
||||
import DashboardLayout from '../../components/dashboard/DashboardLayout';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — mirror the backend AuditLogEntry interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AuditAction =
|
||||
| 'CREATE' | 'UPDATE' | 'DELETE'
|
||||
| 'LOGIN' | 'LOGOUT' | 'EXPORT'
|
||||
| 'PERMISSION_DENIED' | 'PASSWORD_CHANGE' | 'ROLE_CHANGE';
|
||||
|
||||
type AuditResourceType =
|
||||
| 'MEMBER' | 'INCIDENT' | 'VEHICLE' | 'EQUIPMENT'
|
||||
| 'QUALIFICATION' | 'USER' | 'SYSTEM';
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
user_email: string | null;
|
||||
action: AuditAction;
|
||||
resource_type: AuditResourceType;
|
||||
resource_id: string | null;
|
||||
old_value: Record<string, unknown> | null;
|
||||
new_value: Record<string, unknown> | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string; // ISO string from JSON
|
||||
}
|
||||
|
||||
interface AuditLogPage {
|
||||
entries: AuditLogEntry[];
|
||||
total: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
interface AuditFilters {
|
||||
userId?: string;
|
||||
action?: AuditAction[];
|
||||
resourceType?: AuditResourceType[];
|
||||
dateFrom?: Date | null;
|
||||
dateTo?: Date | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALL_ACTIONS: AuditAction[] = [
|
||||
'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT',
|
||||
'EXPORT', 'PERMISSION_DENIED', 'PASSWORD_CHANGE', 'ROLE_CHANGE',
|
||||
];
|
||||
|
||||
const ALL_RESOURCE_TYPES: AuditResourceType[] = [
|
||||
'MEMBER', 'INCIDENT', 'VEHICLE', 'EQUIPMENT',
|
||||
'QUALIFICATION', 'USER', 'SYSTEM',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action chip colour map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACTION_COLORS: Record<AuditAction, 'success' | 'primary' | 'error' | 'warning' | 'default' | 'info'> = {
|
||||
CREATE: 'success',
|
||||
UPDATE: 'primary',
|
||||
DELETE: 'error',
|
||||
LOGIN: 'info',
|
||||
LOGOUT: 'default',
|
||||
EXPORT: 'warning',
|
||||
PERMISSION_DENIED:'error',
|
||||
PASSWORD_CHANGE: 'warning',
|
||||
ROLE_CHANGE: 'warning',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatTimestamp(isoString: string): string {
|
||||
try {
|
||||
return format(parseISO(isoString), 'dd.MM.yyyy HH:mm:ss', { locale: de });
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(value: string | null | undefined, maxLength = 24): string {
|
||||
if (!value) return '—';
|
||||
return value.length > maxLength ? value.substring(0, maxLength) + '…' : value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON diff viewer (simple before / after display)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JsonDiffViewerProps {
|
||||
oldValue: Record<string, unknown> | null;
|
||||
newValue: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const JsonDiffViewer: React.FC<JsonDiffViewerProps> = ({ oldValue, newValue }) => {
|
||||
if (!oldValue && !newValue) {
|
||||
return <Typography variant="body2" color="text.secondary">Keine Datenaenderung aufgezeichnet.</Typography>;
|
||||
}
|
||||
|
||||
const allKeys = Array.from(
|
||||
new Set([
|
||||
...Object.keys(oldValue ?? {}),
|
||||
...Object.keys(newValue ?? {}),
|
||||
])
|
||||
).sort();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
{oldValue && (
|
||||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||||
<Typography variant="overline" color="error.main">Vorher</Typography>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5, mt: 0.5,
|
||||
backgroundColor: 'error.50',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
overflowX: 'auto',
|
||||
maxHeight: 320,
|
||||
}}
|
||||
>
|
||||
<pre style={{ margin: 0 }}>
|
||||
{JSON.stringify(oldValue, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
{newValue && (
|
||||
<Box sx={{ flex: 1, minWidth: 240 }}>
|
||||
<Typography variant="overline" color="success.main">Nachher</Typography>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5, mt: 0.5,
|
||||
backgroundColor: 'success.50',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
overflowX: 'auto',
|
||||
maxHeight: 320,
|
||||
}}
|
||||
>
|
||||
<pre style={{ margin: 0 }}>
|
||||
{JSON.stringify(newValue, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
{/* Highlight changed fields */}
|
||||
{oldValue && newValue && allKeys.length > 0 && (
|
||||
<Box sx={{ width: '100%', mt: 1 }}>
|
||||
<Typography variant="overline">Geaenderte Felder</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
|
||||
{allKeys.map((key) => {
|
||||
const changed =
|
||||
JSON.stringify((oldValue as Record<string, unknown>)[key]) !==
|
||||
JSON.stringify((newValue as Record<string, unknown>)[key]);
|
||||
if (!changed) return null;
|
||||
return (
|
||||
<Chip
|
||||
key={key}
|
||||
label={key}
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry detail dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EntryDialogProps {
|
||||
entry: AuditLogEntry | null;
|
||||
onClose: () => void;
|
||||
showIp: boolean;
|
||||
}
|
||||
|
||||
const EntryDialog: React.FC<EntryDialogProps> = ({ entry, onClose, showIp }) => {
|
||||
if (!entry) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={!!entry} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="h6">
|
||||
Audit-Eintrag — {entry.action} / {entry.resource_type}
|
||||
</Typography>
|
||||
<IconButton onClick={onClose} size="small" aria-label="Schliessen">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="overline">Metadaten</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
gap: '4px 16px',
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
['Zeitpunkt', formatTimestamp(entry.created_at)],
|
||||
['Benutzer', entry.user_email ?? '—'],
|
||||
['Benutzer-ID', entry.user_id ?? '—'],
|
||||
['Aktion', entry.action],
|
||||
['Ressourcentyp', entry.resource_type],
|
||||
['Ressourcen-ID', entry.resource_id ?? '—'],
|
||||
...(showIp ? [['IP-Adresse', entry.ip_address ?? '—']] : []),
|
||||
].map(([label, value]) => (
|
||||
<React.Fragment key={label}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{label}:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ wordBreak: 'break-all' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{Object.keys(entry.metadata ?? {}).length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="overline">Zusatzdaten</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 1, mt: 0.5, fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
<pre style={{ margin: 0 }}>{JSON.stringify(entry.metadata, null, 2)}</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Typography variant="overline">Datenaenderung</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<JsonDiffViewer oldValue={entry.old_value} newValue={entry.new_value} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FilterPanelProps {
|
||||
filters: AuditFilters;
|
||||
onChange: (f: AuditFilters) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, onReset }) => {
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FilterAltIcon fontSize="small" />
|
||||
Filter
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||
{/* Date range */}
|
||||
<DatePicker
|
||||
label="Von"
|
||||
value={filters.dateFrom ?? null}
|
||||
onChange={(date) => onChange({ ...filters, dateFrom: date })}
|
||||
slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }}
|
||||
/>
|
||||
<DatePicker
|
||||
label="Bis"
|
||||
value={filters.dateTo ?? null}
|
||||
onChange={(date) => onChange({ ...filters, dateTo: date })}
|
||||
slotProps={{ textField: { size: 'small', sx: { minWidth: 160 } } }}
|
||||
/>
|
||||
|
||||
{/* Action multi-select */}
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={ALL_ACTIONS}
|
||||
value={filters.action ?? []}
|
||||
onChange={(_, value) => onChange({ ...filters, action: value as AuditAction[] })}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Aktionen" size="small" sx={{ minWidth: 200 }} />
|
||||
)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
key={option}
|
||||
label={option}
|
||||
size="small"
|
||||
color={ACTION_COLORS[option]}
|
||||
/>
|
||||
))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Resource type multi-select */}
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={ALL_RESOURCE_TYPES}
|
||||
value={filters.resourceType ?? []}
|
||||
onChange={(_, value) => onChange({ ...filters, resourceType: value as AuditResourceType[] })}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Ressourcentypen" size="small" sx={{ minWidth: 200 }} />
|
||||
)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip {...getTagProps({ index })} key={option} label={option} size="small" />
|
||||
))
|
||||
}
|
||||
/>
|
||||
|
||||
<Button variant="outlined" size="small" onClick={onReset} sx={{ alignSelf: 'flex-end' }}>
|
||||
Zuruecksetzen
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_FILTERS: AuditFilters = {
|
||||
action: [],
|
||||
resourceType: [],
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
};
|
||||
|
||||
const AuditLog: React.FC = () => {
|
||||
// Grid state
|
||||
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
|
||||
page: 0, // DataGrid is 0-based
|
||||
pageSize: 25,
|
||||
});
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [rows, setRows] = useState<AuditLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [filters, setFilters] = useState<AuditFilters>(DEFAULT_FILTERS);
|
||||
const [appliedFilters, setApplied]= useState<AuditFilters>(DEFAULT_FILTERS);
|
||||
|
||||
// Detail dialog
|
||||
const [selectedEntry, setSelected]= useState<AuditLogEntry | null>(null);
|
||||
|
||||
// Export state
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// The admin always sees IPs — toggle this based on role check if needed
|
||||
const showIp = true;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const fetchData = useCallback(async (
|
||||
pagination: GridPaginationModel,
|
||||
f: AuditFilters,
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
page: String(pagination.page + 1), // convert 0-based to 1-based
|
||||
pageSize: String(pagination.pageSize),
|
||||
};
|
||||
|
||||
if (f.dateFrom) params.dateFrom = f.dateFrom.toISOString();
|
||||
if (f.dateTo) params.dateTo = f.dateTo.toISOString();
|
||||
if (f.action && f.action.length > 0) {
|
||||
params.action = f.action.join(',');
|
||||
}
|
||||
if (f.resourceType && f.resourceType.length > 0) {
|
||||
params.resourceType = f.resourceType.join(',');
|
||||
}
|
||||
if (f.userId) params.userId = f.userId;
|
||||
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const response = await api.get<{ success: boolean; data: AuditLogPage }>(
|
||||
`/admin/audit-log?${queryString}`
|
||||
);
|
||||
|
||||
setRows(response.data.data.entries);
|
||||
setRowCount(response.data.data.total);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
setError(`Audit-Log konnte nicht geladen werden: ${msg}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch when pagination or applied filters change
|
||||
useEffect(() => {
|
||||
fetchData(paginationModel, appliedFilters);
|
||||
}, [paginationModel, appliedFilters, fetchData]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Filter handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setApplied(filters);
|
||||
setPaginationModel((prev) => ({ ...prev, page: 0 }));
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
setApplied(DEFAULT_FILTERS);
|
||||
setPaginationModel((prev) => ({ ...prev, page: 0 }));
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CSV export
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (appliedFilters.dateFrom) params.dateFrom = appliedFilters.dateFrom.toISOString();
|
||||
if (appliedFilters.dateTo) params.dateTo = appliedFilters.dateTo.toISOString();
|
||||
if (appliedFilters.action && appliedFilters.action.length > 0) {
|
||||
params.action = appliedFilters.action.join(',');
|
||||
}
|
||||
if (appliedFilters.resourceType && appliedFilters.resourceType.length > 0) {
|
||||
params.resourceType = appliedFilters.resourceType.join(',');
|
||||
}
|
||||
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const response = await api.get<Blob>(
|
||||
`/admin/audit-log/export?${queryString}`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
const url = URL.createObjectURL(response.data);
|
||||
const filename = `audit_log_${format(new Date(), 'yyyy-MM-dd_HH-mm')}.csv`;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
setError('CSV-Export fehlgeschlagen. Bitte versuchen Sie es erneut.');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Column definitions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const columns: GridColDef<AuditLogEntry>[] = useMemo(() => [
|
||||
{
|
||||
field: 'created_at',
|
||||
headerName: 'Zeitpunkt',
|
||||
width: 160,
|
||||
valueFormatter: (value: string) => formatTimestamp(value),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'user_email',
|
||||
headerName: 'Benutzer',
|
||||
flex: 1,
|
||||
minWidth: 160,
|
||||
renderCell: (params: GridRenderCellParams<AuditLogEntry>) =>
|
||||
params.value ? (
|
||||
<Tooltip title={params.row.user_id ?? ''}>
|
||||
<Typography variant="body2" noWrap>{params.value}</Typography>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">—</Typography>
|
||||
),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'action',
|
||||
headerName: 'Aktion',
|
||||
width: 160,
|
||||
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
|
||||
<Chip
|
||||
label={params.value as string}
|
||||
size="small"
|
||||
color={ACTION_COLORS[params.value as AuditAction] ?? 'default'}
|
||||
/>
|
||||
),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'resource_type',
|
||||
headerName: 'Ressourcentyp',
|
||||
width: 140,
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
field: 'resource_id',
|
||||
headerName: 'Ressourcen-ID',
|
||||
width: 130,
|
||||
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
|
||||
<Tooltip title={params.value ?? ''}>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
{truncate(params.value, 12)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
),
|
||||
sortable: false,
|
||||
},
|
||||
...(showIp ? [{
|
||||
field: 'ip_address',
|
||||
headerName: 'IP-Adresse',
|
||||
width: 140,
|
||||
renderCell: (params: GridRenderCellParams<AuditLogEntry>) => (
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
{params.value ?? '—'}
|
||||
</Typography>
|
||||
),
|
||||
sortable: false,
|
||||
} as GridColDef<AuditLogEntry>] : []),
|
||||
], [showIp]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Loading skeleton
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (loading && rows.length === 0) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="xl">
|
||||
<Skeleton variant="text" width={300} height={48} sx={{ mb: 2 }} />
|
||||
<Skeleton variant="rectangular" height={80} sx={{ mb: 2, borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" height={400} sx={{ borderRadius: 1 }} />
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Render
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={de}>
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="xl">
|
||||
{/* Header */}
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h4">Audit-Protokoll</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
DSGVO Art. 5(2) — Unveraenderliches Protokoll aller Datenzugriffe
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={exporting ? <CircularProgress size={16} color="inherit" /> : <DownloadIcon />}
|
||||
disabled={exporting}
|
||||
onClick={handleExport}
|
||||
>
|
||||
CSV-Export
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Filter panel */}
|
||||
<FilterPanel
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
onReset={handleResetFilters}
|
||||
/>
|
||||
|
||||
{/* Apply filters button */}
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="outlined" size="small" onClick={handleApplyFilters}>
|
||||
Filter anwenden
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Data grid */}
|
||||
<Paper variant="outlined" sx={{ width: '100%' }}>
|
||||
<DataGrid<AuditLogEntry>
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowCount={rowCount}
|
||||
loading={loading}
|
||||
paginationMode="server"
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
disableRowSelectionOnClick={false}
|
||||
onRowClick={(params: GridRowParams<AuditLogEntry>) =>
|
||||
setSelected(params.row)
|
||||
}
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-row': { cursor: 'pointer' },
|
||||
'& .MuiDataGrid-row:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
localeText={{
|
||||
noRowsLabel: 'Keine Eintraege gefunden',
|
||||
MuiTablePagination: {
|
||||
labelRowsPerPage: 'Eintraege pro Seite:',
|
||||
labelDisplayedRows: ({ from, to, count }) =>
|
||||
`${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`,
|
||||
},
|
||||
}}
|
||||
autoHeight
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Detail dialog */}
|
||||
<EntryDialog
|
||||
entry={selectedEntry}
|
||||
onClose={() => setSelected(null)}
|
||||
showIp={showIp}
|
||||
/>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditLog;
|
||||
299
frontend/src/services/incidents.ts
Normal file
299
frontend/src/services/incidents.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { api } from './api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SHARED TYPES (mirrors backend models, kept lean for frontend consumption)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const EINSATZ_ARTEN = [
|
||||
'Brand',
|
||||
'THL',
|
||||
'ABC',
|
||||
'BMA',
|
||||
'Hilfeleistung',
|
||||
'Fehlalarm',
|
||||
'Brandsicherheitswache',
|
||||
] as const;
|
||||
|
||||
export type EinsatzArt = (typeof EINSATZ_ARTEN)[number];
|
||||
|
||||
export const EINSATZ_ART_LABELS: Record<EinsatzArt, string> = {
|
||||
Brand: 'Brand',
|
||||
THL: 'Technische Hilfeleistung',
|
||||
ABC: 'ABC / Gefahrgut',
|
||||
BMA: 'Brandmeldeanlage',
|
||||
Hilfeleistung: 'Hilfeleistung',
|
||||
Fehlalarm: 'Fehlalarm',
|
||||
Brandsicherheitswache: 'Brandsicherheitswache',
|
||||
};
|
||||
|
||||
export const EINSATZ_STATUS = ['aktiv', 'abgeschlossen', 'archiviert'] as const;
|
||||
export type EinsatzStatus = (typeof EINSATZ_STATUS)[number];
|
||||
|
||||
export const EINSATZ_STATUS_LABELS: Record<EinsatzStatus, string> = {
|
||||
aktiv: 'Aktiv',
|
||||
abgeschlossen: 'Abgeschlossen',
|
||||
archiviert: 'Archiviert',
|
||||
};
|
||||
|
||||
export const EINSATZ_FUNKTIONEN = [
|
||||
'Einsatzleiter',
|
||||
'Gruppenführer',
|
||||
'Maschinist',
|
||||
'Atemschutz',
|
||||
'Sicherheitstrupp',
|
||||
'Melder',
|
||||
'Wassertrupp',
|
||||
'Angriffstrupp',
|
||||
'Mannschaft',
|
||||
'Sonstiges',
|
||||
] as const;
|
||||
export type EinsatzFunktion = (typeof EINSATZ_FUNKTIONEN)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API RESPONSE SHAPES
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EinsatzListItem {
|
||||
id: string;
|
||||
einsatz_nr: string;
|
||||
alarm_time: string; // ISO 8601 string from JSON
|
||||
einsatz_art: EinsatzArt;
|
||||
einsatz_stichwort: string | null;
|
||||
ort: string | null;
|
||||
strasse: string | null;
|
||||
status: EinsatzStatus;
|
||||
einsatzleiter_name: string | null;
|
||||
hilfsfrist_min: number | null;
|
||||
dauer_min: number | null;
|
||||
personal_count: number;
|
||||
}
|
||||
|
||||
export interface EinsatzPersonal {
|
||||
einsatz_id: string;
|
||||
user_id: string;
|
||||
funktion: EinsatzFunktion;
|
||||
alarm_time: string | null;
|
||||
ankunft_time: string | null;
|
||||
assigned_at: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
given_name: string | null;
|
||||
family_name: string | null;
|
||||
}
|
||||
|
||||
export interface EinsatzFahrzeug {
|
||||
einsatz_id: string;
|
||||
fahrzeug_id: string;
|
||||
ausrueck_time: string | null;
|
||||
einrueck_time: string | null;
|
||||
assigned_at: string;
|
||||
kennzeichen: string;
|
||||
bezeichnung: string;
|
||||
fahrzeug_typ: string | null;
|
||||
}
|
||||
|
||||
export interface EinsatzDetail {
|
||||
id: string;
|
||||
einsatz_nr: string;
|
||||
alarm_time: string;
|
||||
ausrueck_time: string | null;
|
||||
ankunft_time: string | null;
|
||||
einrueck_time: string | null;
|
||||
einsatz_art: EinsatzArt;
|
||||
einsatz_stichwort: string | null;
|
||||
strasse: string | null;
|
||||
hausnummer: string | null;
|
||||
ort: string | null;
|
||||
bericht_kurz: string | null;
|
||||
bericht_text: string | null; // undefined/null for roles below Kommandant
|
||||
einsatzleiter_id: string | null;
|
||||
einsatzleiter_name: string | null;
|
||||
alarmierung_art: string;
|
||||
status: EinsatzStatus;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
hilfsfrist_min: number | null;
|
||||
dauer_min: number | null;
|
||||
fahrzeuge: EinsatzFahrzeug[];
|
||||
personal: EinsatzPersonal[];
|
||||
}
|
||||
|
||||
export interface MonthlyStatRow {
|
||||
monat: number;
|
||||
anzahl: number;
|
||||
avg_hilfsfrist_min: number | null;
|
||||
avg_dauer_min: number | null;
|
||||
}
|
||||
|
||||
export interface EinsatzArtStatRow {
|
||||
einsatz_art: EinsatzArt;
|
||||
anzahl: number;
|
||||
avg_hilfsfrist_min: number | null;
|
||||
}
|
||||
|
||||
export interface EinsatzStats {
|
||||
jahr: number;
|
||||
gesamt: number;
|
||||
abgeschlossen: number;
|
||||
aktiv: number;
|
||||
avg_hilfsfrist_min: number | null;
|
||||
haeufigste_art: EinsatzArt | null;
|
||||
monthly: MonthlyStatRow[];
|
||||
by_art: EinsatzArtStatRow[];
|
||||
prev_year_monthly: MonthlyStatRow[];
|
||||
}
|
||||
|
||||
export interface IncidentListResponse {
|
||||
items: EinsatzListItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REQUEST SHAPES
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IncidentFilters {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
einsatzArt?: EinsatzArt;
|
||||
status?: EinsatzStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateEinsatzPayload {
|
||||
alarm_time: string;
|
||||
ausrueck_time?: string | null;
|
||||
ankunft_time?: string | null;
|
||||
einrueck_time?: string | null;
|
||||
einsatz_art: EinsatzArt;
|
||||
einsatz_stichwort?: string | null;
|
||||
strasse?: string | null;
|
||||
hausnummer?: string | null;
|
||||
ort?: string | null;
|
||||
bericht_kurz?: string | null;
|
||||
bericht_text?: string | null;
|
||||
einsatzleiter_id?: string | null;
|
||||
alarmierung_art?: string;
|
||||
status?: EinsatzStatus;
|
||||
}
|
||||
|
||||
export type UpdateEinsatzPayload = Partial<CreateEinsatzPayload>;
|
||||
|
||||
export interface AssignPersonnelPayload {
|
||||
user_id: string;
|
||||
funktion?: EinsatzFunktion;
|
||||
alarm_time?: string | null;
|
||||
ankunft_time?: string | null;
|
||||
}
|
||||
|
||||
export interface AssignVehiclePayload {
|
||||
fahrzeug_id: string;
|
||||
ausrueck_time?: string | null;
|
||||
einrueck_time?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API CALLS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const incidentsApi = {
|
||||
/**
|
||||
* Fetch paginated incident list with optional filters.
|
||||
*/
|
||||
async getAll(filters: IncidentFilters = {}): Promise<IncidentListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) params.set('dateTo', filters.dateTo);
|
||||
if (filters.einsatzArt) params.set('einsatzArt', filters.einsatzArt);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.limit !== undefined) params.set('limit', String(filters.limit));
|
||||
if (filters.offset !== undefined) params.set('offset', String(filters.offset));
|
||||
|
||||
const response = await api.get<{ success: boolean; data: IncidentListResponse }>(
|
||||
`/api/incidents?${params.toString()}`
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch aggregated statistics for a given year.
|
||||
*/
|
||||
async getStats(year?: number): Promise<EinsatzStats> {
|
||||
const params = year ? `?year=${year}` : '';
|
||||
const response = await api.get<{ success: boolean; data: EinsatzStats }>(
|
||||
`/api/incidents/stats${params}`
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a single incident with full detail.
|
||||
*/
|
||||
async getById(id: string): Promise<EinsatzDetail> {
|
||||
const response = await api.get<{ success: boolean; data: EinsatzDetail }>(
|
||||
`/api/incidents/${id}`
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new incident. Returns the created Einsatz row.
|
||||
*/
|
||||
async create(payload: CreateEinsatzPayload): Promise<EinsatzDetail> {
|
||||
const response = await api.post<{ success: boolean; data: EinsatzDetail }>(
|
||||
'/api/incidents',
|
||||
payload
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Partially update an incident.
|
||||
*/
|
||||
async update(id: string, payload: UpdateEinsatzPayload): Promise<EinsatzDetail> {
|
||||
const response = await api.patch<{ success: boolean; data: EinsatzDetail }>(
|
||||
`/api/incidents/${id}`,
|
||||
payload
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Soft-delete (archive) an incident.
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
await api.delete(`/api/incidents/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Assign a member to an incident.
|
||||
*/
|
||||
async assignPersonnel(einsatzId: string, payload: AssignPersonnelPayload): Promise<void> {
|
||||
await api.post(`/api/incidents/${einsatzId}/personnel`, payload);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a member from an incident.
|
||||
*/
|
||||
async removePersonnel(einsatzId: string, userId: string): Promise<void> {
|
||||
await api.delete(`/api/incidents/${einsatzId}/personnel/${userId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Assign a vehicle to an incident.
|
||||
*/
|
||||
async assignVehicle(einsatzId: string, payload: AssignVehiclePayload): Promise<void> {
|
||||
await api.post(`/api/incidents/${einsatzId}/vehicles`, payload);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a vehicle from an incident.
|
||||
*/
|
||||
async removeVehicle(einsatzId: string, fahrzeugId: string): Promise<void> {
|
||||
await api.delete(`/api/incidents/${einsatzId}/vehicles/${fahrzeugId}`);
|
||||
},
|
||||
};
|
||||
113
frontend/src/services/members.ts
Normal file
113
frontend/src/services/members.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { api } from './api';
|
||||
import {
|
||||
MemberListItem,
|
||||
MemberWithProfile,
|
||||
MemberFilters,
|
||||
MemberStats,
|
||||
CreateMemberProfileData,
|
||||
UpdateMemberProfileData,
|
||||
} from '../types/member.types';
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Response envelope shapes
|
||||
// ----------------------------------------------------------------
|
||||
interface ApiListResponse<T> {
|
||||
success: boolean;
|
||||
data: T[];
|
||||
meta: { total: number; page: number };
|
||||
}
|
||||
|
||||
interface ApiItemResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Service
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds a URLSearchParams object from the filter object so query
|
||||
* strings like ?status[]=aktiv&status[]=passiv are sent correctly.
|
||||
*/
|
||||
function buildParams(filters?: MemberFilters): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
if (!filters) return params;
|
||||
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.page) params.append('page', String(filters.page));
|
||||
if (filters.pageSize) params.append('pageSize', String(filters.pageSize));
|
||||
|
||||
filters.status?.forEach((s) => params.append('status[]', s));
|
||||
filters.dienstgrad?.forEach((d) => params.append('dienstgrad[]', d));
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export const membersService = {
|
||||
/**
|
||||
* Fetches a paginated, optionally filtered list of members.
|
||||
*/
|
||||
async getMembers(
|
||||
filters?: MemberFilters
|
||||
): Promise<{ items: MemberListItem[]; total: number; page: number }> {
|
||||
const params = buildParams(filters);
|
||||
const response = await api.get<ApiListResponse<MemberListItem>>(
|
||||
`/api/members?${params.toString()}`
|
||||
);
|
||||
return {
|
||||
items: response.data.data,
|
||||
total: response.data.meta.total,
|
||||
page: response.data.meta.page,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches a single member with their full profile and rank history.
|
||||
*/
|
||||
async getMember(userId: string): Promise<MemberWithProfile> {
|
||||
const response = await api.get<ApiItemResponse<MemberWithProfile>>(
|
||||
`/api/members/${userId}`
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a new member profile for an existing auth user.
|
||||
* Restricted to Kommandant/Admin (enforced server-side).
|
||||
*/
|
||||
async createMemberProfile(
|
||||
userId: string,
|
||||
data: CreateMemberProfileData
|
||||
): Promise<MemberWithProfile> {
|
||||
const response = await api.post<ApiItemResponse<MemberWithProfile>>(
|
||||
`/api/members/${userId}/profile`,
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Partially updates a member profile.
|
||||
* Kommandant/Admin: full update.
|
||||
* Own profile: limited fields only (enforced server-side).
|
||||
*/
|
||||
async updateMember(
|
||||
userId: string,
|
||||
data: UpdateMemberProfileData
|
||||
): Promise<MemberWithProfile> {
|
||||
const response = await api.patch<ApiItemResponse<MemberWithProfile>>(
|
||||
`/api/members/${userId}`,
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches aggregate counts for the dashboard KPI widget.
|
||||
*/
|
||||
async getMemberStats(): Promise<MemberStats> {
|
||||
const response = await api.get<ApiItemResponse<MemberStats>>('/api/members/stats');
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
131
frontend/src/services/training.ts
Normal file
131
frontend/src/services/training.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { api } from './api';
|
||||
import { API_URL } from '../utils/config';
|
||||
import type {
|
||||
Uebung,
|
||||
UebungWithAttendance,
|
||||
UebungListItem,
|
||||
MemberParticipationStats,
|
||||
CreateUebungData,
|
||||
UpdateUebungData,
|
||||
} from '../types/training.types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response shapes from the backend
|
||||
// ---------------------------------------------------------------------------
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: iCal subscribe URL
|
||||
// ---------------------------------------------------------------------------
|
||||
export function buildIcalUrl(token: string): string {
|
||||
const base = API_URL.replace(/\/$/, '');
|
||||
return `${base}/api/training/calendar.ics?token=${token}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Training API service
|
||||
// ---------------------------------------------------------------------------
|
||||
export const trainingApi = {
|
||||
// -------------------------------------------------------------------------
|
||||
// Event listing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Upcoming events (dashboard widget, list view) */
|
||||
getUpcoming(limit = 10): Promise<UebungListItem[]> {
|
||||
return api
|
||||
.get<ApiResponse<UebungListItem[]>>('/api/training', { params: { limit } })
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
/** Events in a date range for the month calendar view */
|
||||
getCalendarRange(from: Date, to: Date): Promise<UebungListItem[]> {
|
||||
return api
|
||||
.get<ApiResponse<UebungListItem[]>>('/api/training/calendar', {
|
||||
params: {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
/** Full event detail with attendance data */
|
||||
getById(id: string): Promise<UebungWithAttendance> {
|
||||
return api
|
||||
.get<ApiResponse<UebungWithAttendance>>(`/api/training/${id}`)
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
createEvent(data: CreateUebungData): Promise<Uebung> {
|
||||
return api
|
||||
.post<ApiResponse<Uebung>>('/api/training', data)
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
updateEvent(id: string, data: Partial<UpdateUebungData>): Promise<Uebung> {
|
||||
return api
|
||||
.patch<ApiResponse<Uebung>>(`/api/training/${id}`, data)
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
cancelEvent(id: string, absage_grund: string): Promise<void> {
|
||||
return api
|
||||
.delete(`/api/training/${id}`, { data: { absage_grund } })
|
||||
.then(() => undefined);
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Attendance / RSVP
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Member updates own RSVP */
|
||||
updateRsvp(
|
||||
uebungId: string,
|
||||
status: 'zugesagt' | 'abgesagt',
|
||||
bemerkung?: string
|
||||
): Promise<void> {
|
||||
return api
|
||||
.patch(`/api/training/${uebungId}/attendance`, { status, bemerkung })
|
||||
.then(() => undefined);
|
||||
},
|
||||
|
||||
/** Gruppenführer bulk-marks attendance */
|
||||
markAttendance(uebungId: string, userIds: string[]): Promise<void> {
|
||||
return api
|
||||
.post(`/api/training/${uebungId}/attendance/mark`, { userIds })
|
||||
.then(() => undefined);
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Stats
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getMemberStats(year?: number): Promise<MemberParticipationStats[]> {
|
||||
return api
|
||||
.get<ApiResponse<MemberParticipationStats[]>>('/api/training/stats', {
|
||||
params: { year: year ?? new Date().getFullYear() },
|
||||
})
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// iCal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Get the user's personal calendar subscribe URL */
|
||||
getCalendarToken(): Promise<{ token: string; subscribeUrl: string; instructions: string }> {
|
||||
return api
|
||||
.get<ApiResponse<{ token: string; subscribeUrl: string; instructions: string }>>(
|
||||
'/api/training/calendar-token'
|
||||
)
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
};
|
||||
115
frontend/src/services/vehicles.ts
Normal file
115
frontend/src/services/vehicles.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { api } from './api';
|
||||
import type {
|
||||
FahrzeugListItem,
|
||||
FahrzeugDetail,
|
||||
FahrzeugPruefung,
|
||||
FahrzeugWartungslog,
|
||||
VehicleStats,
|
||||
InspectionAlert,
|
||||
CreateFahrzeugPayload,
|
||||
UpdateFahrzeugPayload,
|
||||
UpdateStatusPayload,
|
||||
CreatePruefungPayload,
|
||||
CreateWartungslogPayload,
|
||||
} from '../types/vehicle.types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: unwrap the standard { success, data } envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
async function unwrap<T>(promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>): Promise<T> {
|
||||
const response = await promise;
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vehicle API Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const vehiclesApi = {
|
||||
|
||||
// ── Fleet overview ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch all vehicles with their next inspection badge data */
|
||||
async getAll(): Promise<FahrzeugListItem[]> {
|
||||
return unwrap(api.get<{ success: boolean; data: FahrzeugListItem[] }>('/api/vehicles'));
|
||||
},
|
||||
|
||||
/** Dashboard KPI stats */
|
||||
async getStats(): Promise<VehicleStats> {
|
||||
return unwrap(api.get<{ success: boolean; data: VehicleStats }>('/api/vehicles/stats'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Upcoming and overdue inspection alerts.
|
||||
* @param daysAhead How many days to look ahead (default 30, max 365).
|
||||
*/
|
||||
async getAlerts(daysAhead = 30): Promise<InspectionAlert[]> {
|
||||
return unwrap(
|
||||
api.get<{ success: boolean; data: InspectionAlert[] }>(
|
||||
`/api/vehicles/alerts?daysAhead=${daysAhead}`
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
// ── Vehicle detail ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Full vehicle detail including inspection history and maintenance log */
|
||||
async getById(id: string): Promise<FahrzeugDetail> {
|
||||
return unwrap(api.get<{ success: boolean; data: FahrzeugDetail }>(`/api/vehicles/${id}`));
|
||||
},
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async create(payload: CreateFahrzeugPayload): Promise<FahrzeugDetail> {
|
||||
const response = await api.post<{ success: boolean; data: FahrzeugDetail }>(
|
||||
'/api/vehicles',
|
||||
payload
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
async update(id: string, payload: UpdateFahrzeugPayload): Promise<FahrzeugDetail> {
|
||||
const response = await api.patch<{ success: boolean; data: FahrzeugDetail }>(
|
||||
`/api/vehicles/${id}`,
|
||||
payload
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/** Live status change — Socket.IO event is emitted server-side in Tier 3 */
|
||||
async updateStatus(id: string, payload: UpdateStatusPayload): Promise<void> {
|
||||
await api.patch(`/api/vehicles/${id}/status`, payload);
|
||||
},
|
||||
|
||||
// ── Inspections ─────────────────────────────────────────────────────────────
|
||||
|
||||
async getPruefungen(id: string): Promise<FahrzeugPruefung[]> {
|
||||
return unwrap(
|
||||
api.get<{ success: boolean; data: FahrzeugPruefung[] }>(`/api/vehicles/${id}/pruefungen`)
|
||||
);
|
||||
},
|
||||
|
||||
async addPruefung(id: string, payload: CreatePruefungPayload): Promise<FahrzeugPruefung> {
|
||||
const response = await api.post<{ success: boolean; data: FahrzeugPruefung }>(
|
||||
`/api/vehicles/${id}/pruefungen`,
|
||||
payload
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// ── Maintenance log ─────────────────────────────────────────────────────────
|
||||
|
||||
async getWartungslog(id: string): Promise<FahrzeugWartungslog[]> {
|
||||
return unwrap(
|
||||
api.get<{ success: boolean; data: FahrzeugWartungslog[] }>(`/api/vehicles/${id}/wartung`)
|
||||
);
|
||||
},
|
||||
|
||||
async addWartungslog(id: string, payload: CreateWartungslogPayload): Promise<FahrzeugWartungslog> {
|
||||
const response = await api.post<{ success: boolean; data: FahrzeugWartungslog }>(
|
||||
`/api/vehicles/${id}/wartung`,
|
||||
payload
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
194
frontend/src/types/member.types.ts
Normal file
194
frontend/src/types/member.types.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// ----------------------------------------------------------------
|
||||
// Frontend mirror of backend/src/models/member.model.ts
|
||||
// Keep in sync when the model changes.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
export const DIENSTGRAD_VALUES = [
|
||||
'Feuerwehranwärter',
|
||||
'Feuerwehrmann',
|
||||
'Feuerwehrfrau',
|
||||
'Oberfeuerwehrmann',
|
||||
'Oberfeuerwehrfrau',
|
||||
'Hauptfeuerwehrmann',
|
||||
'Hauptfeuerwehrfrau',
|
||||
'Löschmeister',
|
||||
'Oberlöschmeister',
|
||||
'Hauptlöschmeister',
|
||||
'Brandmeister',
|
||||
'Oberbrandmeister',
|
||||
'Hauptbrandmeister',
|
||||
'Brandinspektor',
|
||||
'Oberbrandinspektor',
|
||||
'Brandoberinspektor',
|
||||
'Brandamtmann',
|
||||
] as const;
|
||||
|
||||
export const STATUS_VALUES = [
|
||||
'aktiv',
|
||||
'passiv',
|
||||
'ehrenmitglied',
|
||||
'jugendfeuerwehr',
|
||||
'anwärter',
|
||||
'ausgetreten',
|
||||
] as const;
|
||||
|
||||
export const FUNKTION_VALUES = [
|
||||
'Kommandant',
|
||||
'Stellv. Kommandant',
|
||||
'Gruppenführer',
|
||||
'Truppführer',
|
||||
'Gerätewart',
|
||||
'Kassier',
|
||||
'Schriftführer',
|
||||
'Atemschutzwart',
|
||||
'Ausbildungsbeauftragter',
|
||||
] as const;
|
||||
|
||||
export const TSHIRT_GROESSE_VALUES = [
|
||||
'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL',
|
||||
] as const;
|
||||
|
||||
export type DienstgradEnum = typeof DIENSTGRAD_VALUES[number];
|
||||
export type StatusEnum = typeof STATUS_VALUES[number];
|
||||
export type FunktionEnum = typeof FUNKTION_VALUES[number];
|
||||
export type TshirtGroesseEnum = typeof TSHIRT_GROESSE_VALUES[number];
|
||||
|
||||
export interface MitgliederProfile {
|
||||
id: string;
|
||||
user_id: string;
|
||||
mitglieds_nr: string | null;
|
||||
dienstgrad: DienstgradEnum | null;
|
||||
dienstgrad_seit: string | null; // ISO date string from API
|
||||
funktion: FunktionEnum[];
|
||||
status: StatusEnum;
|
||||
eintrittsdatum: string | null;
|
||||
austrittsdatum: string | null;
|
||||
geburtsdatum: string | null; // null when redacted by server
|
||||
_age?: number; // synthesised when geburtsdatum is redacted
|
||||
telefon_mobil: string | null;
|
||||
telefon_privat: string | null;
|
||||
notfallkontakt_name: string | null;
|
||||
notfallkontakt_telefon: string | null;
|
||||
fuehrerscheinklassen: string[];
|
||||
tshirt_groesse: TshirtGroesseEnum | null;
|
||||
schuhgroesse: string | null;
|
||||
bemerkungen: string | null;
|
||||
bild_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DienstgradVerlaufEntry {
|
||||
id: string;
|
||||
user_id: string;
|
||||
dienstgrad_neu: string;
|
||||
dienstgrad_alt: string | null;
|
||||
datum: string;
|
||||
durch_user_id: string | null;
|
||||
durch_user_name?: string | null;
|
||||
bemerkung: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MemberWithProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
given_name: string | null;
|
||||
family_name: string | null;
|
||||
preferred_username: string | null;
|
||||
profile_picture_url: string | null;
|
||||
is_active: boolean;
|
||||
last_login_at: string | null;
|
||||
created_at: string;
|
||||
profile: MitgliederProfile | null;
|
||||
dienstgrad_verlauf?: DienstgradVerlaufEntry[];
|
||||
}
|
||||
|
||||
export interface MemberListItem {
|
||||
id: string;
|
||||
name: string | null;
|
||||
given_name: string | null;
|
||||
family_name: string | null;
|
||||
email: string;
|
||||
profile_picture_url: string | null;
|
||||
is_active: boolean;
|
||||
profile_id: string | null;
|
||||
mitglieds_nr: string | null;
|
||||
dienstgrad: DienstgradEnum | null;
|
||||
funktion: FunktionEnum[];
|
||||
status: StatusEnum | null;
|
||||
eintrittsdatum: string | null;
|
||||
telefon_mobil: string | null;
|
||||
}
|
||||
|
||||
export interface MemberFilters {
|
||||
search?: string;
|
||||
status?: StatusEnum[];
|
||||
dienstgrad?: DienstgradEnum[];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export type CreateMemberProfileData = Partial<Omit<MitgliederProfile, 'id' | 'user_id' | 'created_at' | 'updated_at'>>;
|
||||
export type UpdateMemberProfileData = CreateMemberProfileData;
|
||||
|
||||
export interface MemberStats {
|
||||
total: number;
|
||||
aktiv: number;
|
||||
passiv: number;
|
||||
ehrenmitglied: number;
|
||||
jugendfeuerwehr: number;
|
||||
'anwärter': number;
|
||||
ausgetreten: number;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Display helpers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/** Returns the display name built from given/family name or email fallback */
|
||||
export function getMemberDisplayName(
|
||||
member: Pick<MemberWithProfile | MemberListItem, 'given_name' | 'family_name' | 'name' | 'email'>
|
||||
): string {
|
||||
if (member.given_name || member.family_name) {
|
||||
return [member.given_name, member.family_name].filter(Boolean).join(' ');
|
||||
}
|
||||
return member.name || member.email;
|
||||
}
|
||||
|
||||
/** Format a German phone number for display.
|
||||
* Stored raw; displayed with spaces for readability.
|
||||
* e.g. "+436641234567" → "+43 664 123 4567"
|
||||
* Falls back to raw value for unrecognised formats. */
|
||||
export function formatPhone(raw: string | null | undefined): string {
|
||||
if (!raw) return '—';
|
||||
const digits = raw.replace(/\s/g, '');
|
||||
// Austrian mobile: +43 6xx xxx xxxx
|
||||
const atMobile = digits.match(/^(\+43)(6\d{2})(\d{3,4})(\d{4})$/);
|
||||
if (atMobile) return `${atMobile[1]} ${atMobile[2]} ${atMobile[3]} ${atMobile[4]}`;
|
||||
// German mobile: +49 1xx xxx xxxxx
|
||||
const deMobile = digits.match(/^(\+49)(1\d{2})(\d{3,4})(\d{4,5})$/);
|
||||
if (deMobile) return `${deMobile[1]} ${deMobile[2]} ${deMobile[3]} ${deMobile[4]}`;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Returns a human-readable status label */
|
||||
export const STATUS_LABELS: Record<StatusEnum, string> = {
|
||||
aktiv: 'Aktiv',
|
||||
passiv: 'Passiv',
|
||||
ehrenmitglied: 'Ehrenmitglied',
|
||||
jugendfeuerwehr: 'Jugendfeuerwehr',
|
||||
anwärter: 'Anwärter',
|
||||
ausgetreten: 'Ausgetreten',
|
||||
};
|
||||
|
||||
/** MUI Chip color for each status */
|
||||
export const STATUS_COLORS: Record<StatusEnum, 'success' | 'warning' | 'error' | 'info' | 'default'> = {
|
||||
aktiv: 'success',
|
||||
passiv: 'warning',
|
||||
ehrenmitglied: 'info',
|
||||
jugendfeuerwehr: 'info',
|
||||
anwärter: 'default',
|
||||
ausgetreten: 'error',
|
||||
};
|
||||
115
frontend/src/types/training.types.ts
Normal file
115
frontend/src/types/training.types.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontend training types — mirrors backend/src/models/training.model.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const UEBUNG_TYPEN = [
|
||||
'Übungsabend',
|
||||
'Lehrgang',
|
||||
'Sonderdienst',
|
||||
'Versammlung',
|
||||
'Gemeinschaftsübung',
|
||||
'Sonstiges',
|
||||
] as const;
|
||||
|
||||
export type UebungTyp = (typeof UEBUNG_TYPEN)[number];
|
||||
|
||||
export const TEILNAHME_STATUSES = [
|
||||
'zugesagt',
|
||||
'abgesagt',
|
||||
'erschienen',
|
||||
'entschuldigt',
|
||||
'unbekannt',
|
||||
] as const;
|
||||
|
||||
export type TeilnahmeStatus = (typeof TEILNAHME_STATUSES)[number];
|
||||
|
||||
export interface Uebung {
|
||||
id: string;
|
||||
titel: string;
|
||||
beschreibung?: string | null;
|
||||
typ: UebungTyp;
|
||||
datum_von: string; // ISO string from JSON
|
||||
datum_bis: string;
|
||||
ort?: string | null;
|
||||
treffpunkt?: string | null;
|
||||
pflichtveranstaltung: boolean;
|
||||
mindest_teilnehmer?: number | null;
|
||||
max_teilnehmer?: number | null;
|
||||
angelegt_von?: string | null;
|
||||
erstellt_am: string;
|
||||
aktualisiert_am: string;
|
||||
abgesagt: boolean;
|
||||
absage_grund?: string | null;
|
||||
}
|
||||
|
||||
export interface Teilnahme {
|
||||
uebung_id: string;
|
||||
user_id: string;
|
||||
status: TeilnahmeStatus;
|
||||
antwort_am?: string | null;
|
||||
erschienen_erfasst_am?: string | null;
|
||||
erschienen_erfasst_von?: string | null;
|
||||
bemerkung?: string | null;
|
||||
user_name?: string | null;
|
||||
user_email?: string | null;
|
||||
}
|
||||
|
||||
export interface AttendanceCounts {
|
||||
gesamt_eingeladen: number;
|
||||
anzahl_zugesagt: number;
|
||||
anzahl_abgesagt: number;
|
||||
anzahl_erschienen: number;
|
||||
anzahl_entschuldigt: number;
|
||||
anzahl_unbekannt: number;
|
||||
}
|
||||
|
||||
export interface UebungWithAttendance extends Uebung, AttendanceCounts {
|
||||
teilnahmen?: Teilnahme[];
|
||||
eigener_status?: TeilnahmeStatus;
|
||||
angelegt_von_name?: string | null;
|
||||
}
|
||||
|
||||
export interface UebungListItem {
|
||||
id: string;
|
||||
titel: string;
|
||||
typ: UebungTyp;
|
||||
datum_von: string;
|
||||
datum_bis: string;
|
||||
ort?: string | null;
|
||||
pflichtveranstaltung: boolean;
|
||||
abgesagt: boolean;
|
||||
anzahl_zugesagt: number;
|
||||
anzahl_erschienen: number;
|
||||
gesamt_eingeladen: number;
|
||||
eigener_status?: TeilnahmeStatus;
|
||||
}
|
||||
|
||||
export interface MemberParticipationStats {
|
||||
userId: string;
|
||||
name: string;
|
||||
totalUebungen: number;
|
||||
attended: number;
|
||||
attendancePercent: number;
|
||||
pflichtGesamt: number;
|
||||
pflichtErschienen: number;
|
||||
uebungsabendQuotePct: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form data types (sent to API)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateUebungData {
|
||||
titel: string;
|
||||
beschreibung?: string | null;
|
||||
typ: UebungTyp;
|
||||
datum_von: string; // ISO-8601 with offset
|
||||
datum_bis: string;
|
||||
ort?: string | null;
|
||||
treffpunkt?: string | null;
|
||||
pflichtveranstaltung: boolean;
|
||||
mindest_teilnehmer?: number | null;
|
||||
max_teilnehmer?: number | null;
|
||||
}
|
||||
|
||||
export type UpdateUebungData = Partial<CreateUebungData>;
|
||||
205
frontend/src/types/vehicle.types.ts
Normal file
205
frontend/src/types/vehicle.types.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// =============================================================================
|
||||
// Vehicle Fleet Management — Frontend Type Definitions
|
||||
// Mirror of backend/src/models/vehicle.model.ts (transport layer shapes)
|
||||
// =============================================================================
|
||||
|
||||
export enum FahrzeugStatus {
|
||||
Einsatzbereit = 'einsatzbereit',
|
||||
AusserDienstWartung = 'ausser_dienst_wartung',
|
||||
AusserDienstSchaden = 'ausser_dienst_schaden',
|
||||
InLehrgang = 'in_lehrgang',
|
||||
}
|
||||
|
||||
export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
|
||||
[FahrzeugStatus.Einsatzbereit]: 'Einsatzbereit',
|
||||
[FahrzeugStatus.AusserDienstWartung]: 'Außer Dienst (Wartung)',
|
||||
[FahrzeugStatus.AusserDienstSchaden]: 'Außer Dienst (Schaden)',
|
||||
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
|
||||
};
|
||||
|
||||
export enum PruefungArt {
|
||||
HU = 'HU',
|
||||
AU = 'AU',
|
||||
UVV = 'UVV',
|
||||
Leiter = 'Leiter',
|
||||
Kran = 'Kran',
|
||||
Seilwinde = 'Seilwinde',
|
||||
Sonstiges = 'Sonstiges',
|
||||
}
|
||||
|
||||
export const PruefungArtLabel: Record<PruefungArt, string> = {
|
||||
[PruefungArt.HU]: 'Hauptuntersuchung (TÜV)',
|
||||
[PruefungArt.AU]: 'Abgasuntersuchung',
|
||||
[PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)',
|
||||
[PruefungArt.Leiter]: 'Leiternprüfung (DLK)',
|
||||
[PruefungArt.Kran]: 'Kranprüfung',
|
||||
[PruefungArt.Seilwinde]: 'Seilwindenprüfung',
|
||||
[PruefungArt.Sonstiges]: 'Sonstige Prüfung',
|
||||
};
|
||||
|
||||
export type PruefungErgebnis =
|
||||
| 'bestanden'
|
||||
| 'bestanden_mit_maengeln'
|
||||
| 'nicht_bestanden'
|
||||
| 'ausstehend';
|
||||
|
||||
export type WartungslogArt =
|
||||
| 'Inspektion'
|
||||
| 'Reparatur'
|
||||
| 'Kraftstoff'
|
||||
| 'Reifenwechsel'
|
||||
| 'Hauptuntersuchung'
|
||||
| 'Reinigung'
|
||||
| 'Sonstiges';
|
||||
|
||||
// ── API Response Shapes ───────────────────────────────────────────────────────
|
||||
|
||||
export interface FahrzeugListItem {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
kurzname: string | null;
|
||||
amtliches_kennzeichen: string | null;
|
||||
baujahr: number | null;
|
||||
hersteller: string | null;
|
||||
besatzung_soll: string | null;
|
||||
status: FahrzeugStatus;
|
||||
status_bemerkung: string | null;
|
||||
bild_url: string | null;
|
||||
hu_faellig_am: string | null; // ISO date string from API
|
||||
hu_tage_bis_faelligkeit: number | null;
|
||||
au_faellig_am: string | null;
|
||||
au_tage_bis_faelligkeit: number | null;
|
||||
uvv_faellig_am: string | null;
|
||||
uvv_tage_bis_faelligkeit: number | null;
|
||||
leiter_faellig_am: string | null;
|
||||
leiter_tage_bis_faelligkeit: number | null;
|
||||
naechste_pruefung_tage: number | null;
|
||||
}
|
||||
|
||||
export interface PruefungStatus {
|
||||
pruefung_id: string | null;
|
||||
faellig_am: string | null;
|
||||
tage_bis_faelligkeit: number | null;
|
||||
ergebnis: PruefungErgebnis | null;
|
||||
}
|
||||
|
||||
export interface FahrzeugPruefung {
|
||||
id: string;
|
||||
fahrzeug_id: string;
|
||||
pruefung_art: PruefungArt;
|
||||
faellig_am: string;
|
||||
durchgefuehrt_am: string | null;
|
||||
ergebnis: PruefungErgebnis | null;
|
||||
naechste_faelligkeit: string | null;
|
||||
pruefende_stelle: string | null;
|
||||
kosten: number | null;
|
||||
dokument_url: string | null;
|
||||
bemerkung: string | null;
|
||||
erfasst_von: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FahrzeugWartungslog {
|
||||
id: string;
|
||||
fahrzeug_id: string;
|
||||
datum: string;
|
||||
art: WartungslogArt | null;
|
||||
beschreibung: string;
|
||||
km_stand: number | null;
|
||||
kraftstoff_liter: number | null;
|
||||
kosten: number | null;
|
||||
externe_werkstatt: string | null;
|
||||
erfasst_von: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FahrzeugDetail {
|
||||
id: string;
|
||||
bezeichnung: string;
|
||||
kurzname: string | null;
|
||||
amtliches_kennzeichen: string | null;
|
||||
fahrgestellnummer: string | null;
|
||||
baujahr: number | null;
|
||||
hersteller: string | null;
|
||||
typ_schluessel: string | null;
|
||||
besatzung_soll: string | null;
|
||||
status: FahrzeugStatus;
|
||||
status_bemerkung: string | null;
|
||||
standort: string;
|
||||
bild_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
pruefstatus: {
|
||||
hu: PruefungStatus;
|
||||
au: PruefungStatus;
|
||||
uvv: PruefungStatus;
|
||||
leiter: PruefungStatus;
|
||||
};
|
||||
naechste_pruefung_tage: number | null;
|
||||
pruefungen: FahrzeugPruefung[];
|
||||
wartungslog: FahrzeugWartungslog[];
|
||||
}
|
||||
|
||||
export interface VehicleStats {
|
||||
total: number;
|
||||
einsatzbereit: number;
|
||||
ausserDienst: number;
|
||||
inLehrgang: number;
|
||||
inspectionsDue: number;
|
||||
inspectionsOverdue: number;
|
||||
}
|
||||
|
||||
export interface InspectionAlert {
|
||||
fahrzeugId: string;
|
||||
bezeichnung: string;
|
||||
kurzname: string | null;
|
||||
pruefungId: string;
|
||||
pruefungArt: PruefungArt;
|
||||
faelligAm: string;
|
||||
tage: number;
|
||||
}
|
||||
|
||||
// ── Request Payload Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface CreateFahrzeugPayload {
|
||||
bezeichnung: string;
|
||||
kurzname?: string;
|
||||
amtliches_kennzeichen?: string;
|
||||
fahrgestellnummer?: string;
|
||||
baujahr?: number;
|
||||
hersteller?: string;
|
||||
typ_schluessel?: string;
|
||||
besatzung_soll?: string;
|
||||
status?: FahrzeugStatus;
|
||||
status_bemerkung?: string;
|
||||
standort?: string;
|
||||
bild_url?: string;
|
||||
}
|
||||
|
||||
export type UpdateFahrzeugPayload = Partial<CreateFahrzeugPayload>;
|
||||
|
||||
export interface UpdateStatusPayload {
|
||||
status: FahrzeugStatus;
|
||||
bemerkung?: string;
|
||||
}
|
||||
|
||||
export interface CreatePruefungPayload {
|
||||
pruefung_art: PruefungArt;
|
||||
faellig_am: string;
|
||||
durchgefuehrt_am?: string;
|
||||
ergebnis?: PruefungErgebnis;
|
||||
pruefende_stelle?: string;
|
||||
kosten?: number;
|
||||
dokument_url?: string;
|
||||
bemerkung?: string;
|
||||
}
|
||||
|
||||
export interface CreateWartungslogPayload {
|
||||
datum: string;
|
||||
art?: WartungslogArt;
|
||||
beschreibung: string;
|
||||
km_stand?: number;
|
||||
kraftstoff_liter?: number;
|
||||
kosten?: number;
|
||||
externe_werkstatt?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user