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