643 lines
22 KiB
TypeScript
643 lines
22 KiB
TypeScript
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,
|
|
} 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 as EinsatzDetailType,
|
|
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: EinsatzDetailType['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<EinsatzDetailType | 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;
|