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;