Files
dashboard/frontend/src/pages/AusruestungDetail.tsx
Matthias Hochmeister e2be29c712 refine vehicle freatures
2026-02-28 17:19:18 +01:00

763 lines
25 KiB
TypeScript

import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Fab,
FormControl,
Grid,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Tab,
Tabs,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
ArrowBack,
Build,
CheckCircle,
DeleteOutline,
Edit,
Error as ErrorIcon,
MoreHoriz,
PauseCircle,
RemoveCircle,
Star,
Verified,
Warning,
} from '@mui/icons-material';
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { equipmentApi } from '../services/equipment';
import {
AusruestungDetail,
AusruestungWartungslog,
AusruestungWartungslogArt,
AusruestungStatus,
AusruestungStatusLabel,
UpdateAusruestungStatusPayload,
CreateAusruestungWartungslogPayload,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
// -- 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<AusruestungStatus, React.ReactElement> = {
[AusruestungStatus.Einsatzbereit]: <CheckCircle color="success" />,
[AusruestungStatus.Beschaedigt]: <ErrorIcon color="error" />,
[AusruestungStatus.InWartung]: <PauseCircle color="warning" />,
[AusruestungStatus.AusserDienst]: <RemoveCircle color="action" />,
};
const STATUS_CHIP_COLOR: Record<AusruestungStatus, 'success' | 'error' | 'warning' | 'default'> = {
[AusruestungStatus.Einsatzbereit]: 'success',
[AusruestungStatus.Beschaedigt]: 'error',
[AusruestungStatus.InWartung]: 'warning',
[AusruestungStatus.AusserDienst]: 'default',
};
// -- 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',
});
}
// -- Wartungslog Art config ---------------------------------------------------
const WARTUNG_ART_CHIP_COLOR: Record<AusruestungWartungslogArt, 'info' | 'warning' | 'default'> = {
'Prüfung': 'info',
'Reparatur': 'warning',
'Sonstiges': 'default',
};
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
'Prüfung': <Verified color="info" />,
'Reparatur': <Build color="warning" />,
'Sonstiges': <MoreHoriz color="action" />,
default: <Build color="action" />,
};
const ERGEBNIS_CHIP_COLOR: Record<string, 'success' | 'warning' | 'error'> = {
bestanden: 'success',
bestanden_mit_maengeln: 'warning',
nicht_bestanden: 'error',
};
const ERGEBNIS_LABEL: Record<string, string> = {
bestanden: 'Bestanden',
bestanden_mit_maengeln: 'Bestanden (mit Mängeln)',
nicht_bestanden: 'Nicht bestanden',
};
// -- Uebersicht Tab -----------------------------------------------------------
interface UebersichtTabProps {
equipment: AusruestungDetail;
onStatusUpdated: () => void;
canChangeStatus: boolean;
}
const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdated, canChangeStatus }) => {
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<AusruestungStatus>(equipment.status);
const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? '');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const openDialog = () => {
setNewStatus(equipment.status);
setBemerkung(equipment.status_bemerkung ?? '');
setSaveError(null);
setStatusDialogOpen(true);
};
const closeDialog = () => {
setSaveError(null);
setStatusDialogOpen(false);
};
const handleSaveStatus = async () => {
try {
setSaving(true);
setSaveError(null);
const payload: UpdateAusruestungStatusPayload = { status: newStatus, bemerkung };
await equipmentApi.updateStatus(equipment.id, payload);
setStatusDialogOpen(false);
onStatusUpdated();
} catch {
setSaveError('Status konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
const isBeschaedigt = equipment.status === AusruestungStatus.Beschaedigt;
// Inspection deadline
const pruefTage = equipment.pruefung_tage_bis_faelligkeit;
// Data grid fields
const dataFields: { label: string; value: React.ReactNode }[] = [
{ label: 'Kategorie', value: equipment.kategorie_name },
{ label: 'Seriennummer', value: equipment.seriennummer ?? '---' },
{ label: 'Inventarnummer', value: equipment.inventarnummer ?? '---' },
{ label: 'Hersteller', value: equipment.hersteller ?? '---' },
{ label: 'Baujahr', value: equipment.baujahr ?? '---' },
{
label: 'Fahrzeug',
value: equipment.fahrzeug_id ? (
<Typography
component={RouterLink}
to={`/fahrzeuge/${equipment.fahrzeug_id}`}
variant="body1"
sx={{ color: 'primary.main', textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
>
{equipment.fahrzeug_bezeichnung}
</Typography>
) : '---',
},
...(!equipment.fahrzeug_id ? [{ label: 'Standort', value: equipment.standort || '---' }] : []),
{
label: 'Wichtig',
value: equipment.ist_wichtig ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Star fontSize="small" color="warning" /> Ja
</Box>
) : 'Nein',
},
{
label: 'Prüfintervall',
value: equipment.pruef_intervall_monate
? `${equipment.pruef_intervall_monate} Monate`
: '---',
},
{ label: 'Letzte Prüfung', value: fmtDate(equipment.letzte_pruefung_am) },
{
label: 'Nächste Prüfung',
value: equipment.naechste_pruefung_am ? (
<Box>
<Typography variant="body1">{fmtDate(equipment.naechste_pruefung_am)}</Typography>
{pruefTage !== null && pruefTage < 0 && (
<Chip
size="small"
color="error"
icon={<Warning fontSize="small" />}
label={`${Math.abs(pruefTage)} Tage überfällig`}
sx={{ mt: 0.5 }}
/>
)}
{pruefTage !== null && pruefTage >= 0 && pruefTage <= 30 && (
<Chip
size="small"
color="warning"
label={`in ${pruefTage} Tagen`}
sx={{ mt: 0.5 }}
/>
)}
{pruefTage !== null && pruefTage > 30 && (
<Chip
size="small"
color="success"
label={`in ${pruefTage} Tagen`}
sx={{ mt: 0.5 }}
/>
)}
</Box>
) : (
<Chip size="small" color="default" label="Nicht festgelegt" />
),
},
{ label: 'Bemerkung', value: equipment.bemerkung ?? '---' },
];
return (
<Box>
{isBeschaedigt && (
<Alert severity="error" icon={<ErrorIcon />} sx={{ mb: 2 }}>
<strong>Beschädigt</strong> --- dieses Gerät ist nicht einsatzbereit.
{equipment.status_bemerkung && ` Bemerkung: ${equipment.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[equipment.status]}
<Box>
<Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography>
<Chip
label={AusruestungStatusLabel[equipment.status]}
color={STATUS_CHIP_COLOR[equipment.status]}
size="small"
/>
{equipment.status_bemerkung && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{equipment.status_bemerkung}
</Typography>
)}
</Box>
</Box>
{canChangeStatus && (
<Button variant="outlined" size="small" onClick={openDialog}>
Status ändern
</Button>
)}
</Box>
</Paper>
{/* Equipment data grid */}
<Grid container spacing={2}>
{dataFields.map(({ label, value }) => (
<Grid item xs={12} sm={6} md={4} key={label}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
{label}
</Typography>
{typeof value === 'string' || typeof value === 'number' ? (
<Typography variant="body1">{value}</Typography>
) : (
<Box>{value}</Box>
)}
</Grid>
))}
</Grid>
{/* Status change dialog */}
<Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>Gerätestatus ä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 AusruestungStatus)}
>
{Object.values(AusruestungStatus).map((s) => (
<MenuItem key={s} value={s}>{AusruestungStatusLabel[s]}</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Bemerkung (optional)"
fullWidth
multiline
rows={3}
value={bemerkung}
onChange={(e) => setBemerkung(e.target.value)}
placeholder="z.B. Gerät zur Reparatur eingeschickt, voraussichtlich ab 01.03. wieder einsatzbereit"
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSaveStatus}
disabled={saving}
startIcon={saving ? <CircularProgress size={16} /> : undefined}
>
Speichern
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// -- Wartung Tab --------------------------------------------------------------
interface WartungTabProps {
equipmentId: string;
wartungslog: AusruestungWartungslog[];
onAdded: () => void;
canWrite: boolean;
}
const WartungTab: React.FC<WartungTabProps> = ({ equipmentId, wartungslog, onAdded, canWrite }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const emptyForm: CreateAusruestungWartungslogPayload = {
datum: '',
art: 'Prüfung' as AusruestungWartungslogArt,
beschreibung: '',
ergebnis: undefined,
kosten: undefined,
pruefende_stelle: undefined,
};
const [form, setForm] = useState<CreateAusruestungWartungslogPayload>(emptyForm);
const handleSubmit = async () => {
if (!form.datum || !form.art || !form.beschreibung.trim()) {
setSaveError('Datum, Art und Beschreibung sind erforderlich.');
return;
}
try {
setSaving(true);
setSaveError(null);
await equipmentApi.addWartungslog(equipmentId, {
...form,
pruefende_stelle: form.pruefende_stelle || undefined,
ergebnis: form.ergebnis || undefined,
});
setDialogOpen(false);
setForm(emptyForm);
onAdded();
} catch {
setSaveError('Wartungseintrag konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
// Sort wartungslog by datum DESC
const sorted = [...wartungslog].sort(
(a, b) => new Date(b.datum).getTime() - new Date(a.datum).getTime()
);
return (
<Box>
{sorted.length === 0 ? (
<Typography color="text.secondary">Keine Wartungseinträge vorhanden.</Typography>
) : (
<Stack divider={<Divider />} spacing={0}>
{sorted.map((entry) => {
const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default;
const ergebnisColor = entry.ergebnis ? ERGEBNIS_CHIP_COLOR[entry.ergebnis] : undefined;
const ergebnisLabel = entry.ergebnis ? ERGEBNIS_LABEL[entry.ergebnis] : undefined;
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, flexWrap: 'wrap' }}>
<Typography variant="subtitle2">{fmtDate(entry.datum)}</Typography>
{entry.art && (
<Chip
label={entry.art}
size="small"
color={WARTUNG_ART_CHIP_COLOR[entry.art] ?? 'default'}
variant="outlined"
/>
)}
{ergebnisLabel && ergebnisColor && (
<Chip
label={ergebnisLabel}
size="small"
color={ergebnisColor}
/>
)}
</Box>
<Typography variant="body2">{entry.beschreibung}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{[
entry.kosten != null && `${entry.kosten.toFixed(2)} EUR`,
entry.pruefende_stelle && entry.pruefende_stelle,
].filter(Boolean).join(' · ')}
</Typography>
</Box>
</Box>
);
})}
</Stack>
)}
{canWrite && (
<Fab
color="primary"
size="small"
aria-label="Wartung eintragen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => { setForm(emptyForm); setSaveError(null); setDialogOpen(true); }}
>
<Add />
</Fab>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Wartung / Prüfung 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 AusruestungWartungslogArt,
}))
}
>
<MenuItem value="">--- Bitte wählen ---</MenuItem>
{(['Prüfung', 'Reparatur', 'Sonstiges'] as AusruestungWartungslogArt[]).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={6}>
<FormControl fullWidth>
<InputLabel>Ergebnis</InputLabel>
<Select
label="Ergebnis"
value={form.ergebnis ?? ''}
onChange={(e) =>
setForm((f) => ({
...f,
ergebnis: e.target.value || undefined,
}))
}
>
<MenuItem value="">--- Kein Ergebnis ---</MenuItem>
<MenuItem value="bestanden">Bestanden</MenuItem>
<MenuItem value="bestanden_mit_maengeln">Bestanden (mit Mängeln)</MenuItem>
<MenuItem value="nicht_bestanden">Nicht bestanden</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Kosten (EUR)"
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="Prüfende Stelle"
fullWidth
value={form.pruefende_stelle ?? ''}
onChange={(e) => setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))}
placeholder="Name der prüfenden Stelle oder Person"
/>
</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 AusruestungDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAdmin, canChangeStatus } = usePermissions();
const notification = useNotification();
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const fetchEquipment = useCallback(async () => {
if (!id) return;
try {
setLoading(true);
setError(null);
const data = await equipmentApi.getById(id);
setEquipment(data);
} catch {
setError('Gerät konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { fetchEquipment(); }, [fetchEquipment]);
const handleDelete = async () => {
if (!id) return;
try {
setDeleteLoading(true);
await equipmentApi.delete(id);
notification.showSuccess('Gerät wurde erfolgreich gelöscht.');
navigate('/ausruestung');
} catch {
notification.showError('Gerät konnte nicht gelöscht werden.');
setDeleteDialogOpen(false);
setDeleteLoading(false);
}
};
if (loading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
if (error || !equipment) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Alert severity="error">{error ?? 'Gerät nicht gefunden.'}</Alert>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/ausruestung')} sx={{ mt: 2 }}>
Zurück zur Übersicht
</Button>
</Container>
</DashboardLayout>
);
}
const hasOverdue =
equipment.pruefung_tage_bis_faelligkeit !== null &&
equipment.pruefung_tage_bis_faelligkeit < 0;
const subtitle = [
equipment.kategorie_name,
equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null,
].filter(Boolean).join(' · ');
return (
<DashboardLayout>
<Container maxWidth="lg">
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/ausruestung')}
sx={{ mb: 2 }}
size="small"
>
Ausrüstungsübersicht
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Build sx={{ fontSize: 36, color: 'text.secondary' }} />
<Box>
<Typography variant="h4" component="h1">
{equipment.bezeichnung}
</Typography>
{subtitle && (
<Typography variant="subtitle1" color="text.secondary">
{subtitle}
</Typography>
)}
</Box>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
icon={STATUS_ICONS[equipment.status]}
label={AusruestungStatusLabel[equipment.status]}
color={STATUS_CHIP_COLOR[equipment.status]}
/>
{canChangeStatus && (
<Tooltip title="Gerät bearbeiten">
<IconButton
size="small"
onClick={() => navigate(`/ausruestung/${equipment.id}/bearbeiten`)}
aria-label="Gerät bearbeiten"
>
<Edit />
</IconButton>
</Tooltip>
)}
{isAdmin && (
<Tooltip title="Gerät löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Gerät löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
aria-label="Ausrüstung Detailansicht"
>
<Tab label="Übersicht" />
<Tab
label={
hasOverdue
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Wartung <Warning color="error" fontSize="small" />
</Box>
: 'Wartung'
}
/>
</Tabs>
</Box>
<TabPanel value={activeTab} index={0}>
<UebersichtTab
equipment={equipment}
onStatusUpdated={fetchEquipment}
canChangeStatus={canChangeStatus}
/>
</TabPanel>
<TabPanel value={activeTab} index={1}>
<WartungTab
equipmentId={equipment.id}
wartungslog={equipment.wartungslog ?? []}
onAdded={fetchEquipment}
canWrite={canChangeStatus}
/>
</TabPanel>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
<DialogTitle>Gerät löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Möchten Sie &apos;{equipment.bezeichnung}&apos; wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDeleteDialogOpen(false)}
disabled={deleteLoading}
autoFocus
>
Abbrechen
</Button>
<Button
color="error"
variant="contained"
onClick={handleDelete}
disabled={deleteLoading}
startIcon={deleteLoading ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</Container>
</DashboardLayout>
);
}
export default AusruestungDetailPage;