add features

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

View File

@@ -0,0 +1,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;

View 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;

View 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>
);
}

View 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;

View 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>
);
}

View 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;