Files
dashboard/frontend/src/pages/FahrzeugDetail.tsx
Matthias Hochmeister b477e5dbe0 feat: user data purge, breadcrumbs, first-login dialog, widget consolidation, bookkeeping cascade
- Admin can purge all personal data for a user (POST /api/admin/users/:userId/purge-data)
  while keeping the account; clears profile, notifications, bookings, ical tokens, preferences
- Add isNewUser flag to auth callback response; first-login dialog prompts for Standesbuchnummer
- Add PageBreadcrumbs component and apply to 18 sub-pages across the app
- Cascade budget_typ changes from parent pot to all children recursively, converting amounts
  (detailliert→einfach: sum into budget_gesamt; einfach→detailliert: zero all for redistribution)
- Migrate NextcloudTalkWidget to use shared WidgetCard template for consistent header styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:15:28 +02:00

1081 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
FormControl,
Grid,
IconButton,
InputLabel,
Link,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import {
Add,
ArrowBack,
Assignment,
Build,
CheckCircle,
DeleteOutline,
Edit,
Error as ErrorIcon,
History,
LocalFireDepartment,
MoreHoriz,
PauseCircle,
ReportProblem,
School,
Star,
Verified,
Warning,
} from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { DetailLayout } from '../components/templates';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { vehiclesApi } from '../services/vehicles';
import GermanDateField from '../components/shared/GermanDateField';
import { fromGermanDate } from '../utils/dateInput';
import { equipmentApi } from '../services/equipment';
import {
FahrzeugDetail as FahrzeugDetailType,
FahrzeugWartungslog,
FahrzeugStatus,
FahrzeugStatusLabel,
CreateWartungslogPayload,
UpdateWartungslogPayload,
UpdateStatusPayload,
WartungslogArt,
WartungslogErgebnis,
WartungslogErgebnisLabel,
WartungslogErgebnisColor,
OverlappingBooking,
} from '../types/vehicle.types';
import type { AusruestungListItem } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import FahrzeugChecklistTab from '../components/fahrzeuge/FahrzeugChecklistTab';
// ── Status config ─────────────────────────────────────────────────────────────
const STATUS_ICONS: Record<FahrzeugStatus, React.ReactElement> = {
[FahrzeugStatus.Einsatzbereit]: <CheckCircle color="success" />,
[FahrzeugStatus.AusserDienstWartung]: <PauseCircle color="warning" />,
[FahrzeugStatus.AusserDienstSchaden]: <ErrorIcon color="error" />,
};
const STATUS_CHIP_COLOR: Record<FahrzeugStatus, 'success' | 'warning' | 'error' | 'info'> = {
[FahrzeugStatus.Einsatzbereit]: 'success',
[FahrzeugStatus.AusserDienstWartung]: 'warning',
[FahrzeugStatus.AusserDienstSchaden]: 'error',
};
// ── 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';
}
function fmtDatetime(iso: string | Date | null | undefined): string {
return fmtDate(iso ? new Date(iso).toISOString() : null);
}
// ── Status History Section ────────────────────────────────────────────────────
const StatusHistorySection: React.FC<{ vehicleId: string }> = ({ vehicleId }) => {
const [history, setHistory] = useState<{ alter_status: string; neuer_status: string; bemerkung?: string; geaendert_von_name?: string; erstellt_am: string }[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
vehiclesApi.getStatusHistory(vehicleId)
.then(setHistory)
.catch(() => setHistory([]))
.finally(() => setLoading(false));
}, [vehicleId]);
if (loading || history.length === 0) return null;
return (
<>
<Typography variant="h6" sx={{ mt: 3, mb: 1.5, display: 'flex', alignItems: 'center', gap: 1 }}>
<History fontSize="small" /> Status-Historie
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Datum</TableCell>
<TableCell>Von</TableCell>
<TableCell>Nach</TableCell>
<TableCell>Bemerkung</TableCell>
<TableCell>Geändert von</TableCell>
</TableRow>
</TableHead>
<TableBody>
{history.map((h, idx) => (
<TableRow key={idx}>
<TableCell>{fmtDatetime(h.erstellt_am)}</TableCell>
<TableCell>
<Chip size="small" label={FahrzeugStatusLabel[h.alter_status as FahrzeugStatus] || h.alter_status} color={STATUS_CHIP_COLOR[h.alter_status as FahrzeugStatus] || 'default'} />
</TableCell>
<TableCell>
<Chip size="small" label={FahrzeugStatusLabel[h.neuer_status as FahrzeugStatus] || h.neuer_status} color={STATUS_CHIP_COLOR[h.neuer_status as FahrzeugStatus] || 'default'} />
</TableCell>
<TableCell>{h.bemerkung || '—'}</TableCell>
<TableCell>{h.geaendert_von_name || '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
};
// ── Übersicht Tab ─────────────────────────────────────────────────────────────
interface UebersichtTabProps {
vehicle: FahrzeugDetailType;
onStatusUpdated: () => void;
canChangeStatus: boolean;
canEdit: boolean;
}
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus, canEdit: _canEdit }) => {
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
const [ausserDienstVon, setAusserDienstVon] = useState(
vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : ''
);
const [ausserDienstBis, setAusserDienstBis] = useState(
vehicle.ausser_dienst_bis ? new Date(vehicle.ausser_dienst_bis).toISOString().slice(0, 16) : ''
);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [overlappingBookings, setOverlappingBookings] = useState<OverlappingBooking[]>([]);
const isAusserDienst = (s: FahrzeugStatus) =>
s === FahrzeugStatus.AusserDienstWartung || s === FahrzeugStatus.AusserDienstSchaden;
const openDialog = () => {
setNewStatus(vehicle.status);
setBemerkung('');
setAusserDienstVon(
vehicle.ausser_dienst_von ? new Date(vehicle.ausser_dienst_von).toISOString().slice(0, 16) : ''
);
setAusserDienstBis(
vehicle.ausser_dienst_bis ? new Date(vehicle.ausser_dienst_bis).toISOString().slice(0, 16) : ''
);
setSaveError(null);
setOverlappingBookings([]);
setStatusDialogOpen(true);
};
const closeDialog = () => {
setSaveError(null);
setStatusDialogOpen(false);
};
const handleSaveStatus = async () => {
try {
setSaving(true);
setSaveError(null);
const payload: UpdateStatusPayload = {
status: newStatus,
bemerkung,
...(isAusserDienst(newStatus) && ausserDienstVon && ausserDienstBis
? {
ausserDienstVon: new Date(ausserDienstVon).toISOString(),
ausserDienstBis: new Date(ausserDienstBis).toISOString(),
}
: {}),
};
const result = await vehiclesApi.updateStatus(vehicle.id, payload);
setStatusDialogOpen(false);
setOverlappingBookings(result.overlappingBookings ?? []);
onStatusUpdated();
} catch (err: any) {
setSaveError(err?.response?.data?.message || 'Status konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
const canSave = !isAusserDienst(newStatus) || (!!ausserDienstVon && !!ausserDienstBis);
const isSchaden = vehicle.status === FahrzeugStatus.AusserDienstSchaden;
// Inspection deadline badges
const inspItems: { label: string; faelligAm: string | null; tage: number | null }[] = [
{ label: '§57a Periodische Prüfung', faelligAm: vehicle.paragraph57a_faellig_am, tage: vehicle.paragraph57a_tage_bis_faelligkeit },
{ label: 'Nächste Wartung / Service', faelligAm: vehicle.naechste_wartung_am, tage: vehicle.wartung_tage_bis_faelligkeit },
];
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>
)}
{overlappingBookings.length > 0 && (
<Alert severity="warning" sx={{ mb: 2 }}>
<strong>Folgende Buchungen überschneiden sich mit dem Außer-Dienst-Zeitraum:</strong>
<Box component="ul" sx={{ mt: 1, mb: 0, pl: 2 }}>
{overlappingBookings.map((b) => (
<li key={b.id}>
<strong>{b.titel}</strong> · {fmtDate(b.beginn as unknown as string)} {fmtDate(b.ende as unknown as string)} · {b.gebucht_von_name}
</li>
))}
</Box>
Bitte prüfe, ob diese storniert werden müssen.
</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>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={FahrzeugStatusLabel[vehicle.status]}
color={STATUS_CHIP_COLOR[vehicle.status]}
size="small"
/>
{vehicle.aktiver_lehrgang && (
<Chip
icon={<School fontSize="small" />}
label="In Lehrgang"
color="info"
size="small"
/>
)}
</Box>
{(vehicle.status === FahrzeugStatus.AusserDienstWartung || vehicle.status === FahrzeugStatus.AusserDienstSchaden) &&
vehicle.ausser_dienst_von && vehicle.ausser_dienst_bis && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Außer Dienst: {fmtDatetime(vehicle.ausser_dienst_von)} {fmtDatetime(vehicle.ausser_dienst_bis)} (geschätzt)
</Typography>
)}
{vehicle.status_bemerkung && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{vehicle.status_bemerkung}
</Typography>
)}
</Box>
</Box>
{canChangeStatus && (
<Button variant="outlined" size="small" onClick={openDialog}>
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 },
].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 deadline quick view */}
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
Prüf- und Wartungsfristen
</Typography>
<Grid container spacing={1.5}>
{inspItems.map(({ label, faelligAm, tage }) => {
const color = inspectionBadgeColor(tage);
return (
<Grid item xs={12} sm={6} key={label}>
<Paper variant="outlined" sx={{ p: 1.5 }}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
{label}
</Typography>
{faelligAm ? (
<>
<Chip
size="small"
color={color}
label={
tage !== null && tage < 0
? `ÜBERFÄLLIG (${fmtDate(faelligAm)})`
: `Fällig: ${fmtDate(faelligAm)}`
}
icon={tage !== null && tage < 0 ? <Warning fontSize="small" /> : undefined}
sx={{ mt: 0.5 }}
/>
{tage !== null && tage >= 0 && (
<Typography variant="caption" display="block" color="text.secondary">
in {tage} Tagen
</Typography>
)}
</>
) : (
<Typography variant="body2" color="text.disabled">Kein Datum erfasst</Typography>
)}
</Paper>
</Grid>
);
})}
</Grid>
{/* Status history */}
<StatusHistorySection vehicleId={vehicle.id} />
{/* Status change dialog */}
<Dialog open={statusDialogOpen} onClose={closeDialog} 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>
{isAusserDienst(newStatus) && (
<>
<GermanDateField
label="Außer Dienst von *"
mode="datetime"
fullWidth
sx={{ mb: 2 }}
value={ausserDienstVon}
onChange={(iso) => setAusserDienstVon(iso)}
/>
<GermanDateField
label="Geschätztes Ende *"
mode="datetime"
fullWidth
sx={{ mb: 1 }}
value={ausserDienstBis}
onChange={(iso) => setAusserDienstBis(iso)}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
Zeitangabe ist eine Schätzung
</Typography>
</>
)}
<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={closeDialog}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSaveStatus}
disabled={saving || !canSave}
startIcon={saving ? <CircularProgress size={16} /> : undefined}
>
Speichern
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
// ── Wartung Tab ───────────────────────────────────────────────────────────────
interface WartungTabProps {
fahrzeugId: string;
wartungslog: FahrzeugWartungslog[];
onAdded: () => void;
canWrite: boolean;
}
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
'§57a Prüfung': <Verified color="success" />,
'Service': <Build color="warning" />,
'Sonstiges': <MoreHoriz color="action" />,
default: <Build color="action" />,
};
const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [editingWartungId, setEditingWartungId] = useState<string | null>(null);
const emptyForm: CreateWartungslogPayload = {
datum: '',
art: undefined,
beschreibung: '',
km_stand: undefined,
kraftstoff_liter: undefined,
kosten: undefined,
externe_werkstatt: '',
ergebnis: undefined,
naechste_faelligkeit: '',
};
const [form, setForm] = useState<CreateWartungslogPayload>(emptyForm);
const openCreateDialog = () => {
setEditingWartungId(null);
setForm(emptyForm);
setSaveError(null);
setDialogOpen(true);
};
const openEditDialog = (entry: FahrzeugWartungslog) => {
setEditingWartungId(entry.id);
setForm({
datum: entry.datum ? new Date(entry.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '',
art: entry.art ?? undefined,
beschreibung: entry.beschreibung,
km_stand: entry.km_stand ?? undefined,
kraftstoff_liter: entry.kraftstoff_liter ?? undefined,
kosten: entry.kosten ?? undefined,
externe_werkstatt: entry.externe_werkstatt ?? '',
ergebnis: entry.ergebnis ?? undefined,
naechste_faelligkeit: entry.naechste_faelligkeit ? entry.naechste_faelligkeit.slice(0, 10) : '',
});
setSaveError(null);
setDialogOpen(true);
};
const handleSubmit = async () => {
if (!form.datum || !form.beschreibung.trim()) {
setSaveError('Datum und Beschreibung sind erforderlich.');
return;
}
try {
setSaving(true);
setSaveError(null);
const isoDate = fromGermanDate(form.datum) || form.datum;
if (editingWartungId) {
const payload: UpdateWartungslogPayload = {
datum: isoDate,
art: form.art,
beschreibung: form.beschreibung,
km_stand: form.km_stand,
externe_werkstatt: form.externe_werkstatt || undefined,
ergebnis: form.ergebnis,
naechste_faelligkeit: form.naechste_faelligkeit || undefined,
};
await vehiclesApi.updateWartungslog(fahrzeugId, editingWartungId, payload);
} else {
await vehiclesApi.addWartungslog(fahrzeugId, {
...form,
datum: isoDate,
externe_werkstatt: form.externe_werkstatt || undefined,
naechste_faelligkeit: form.naechste_faelligkeit || undefined,
});
}
setDialogOpen(false);
setForm(emptyForm);
setEditingWartungId(null);
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>
) : (
<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" />}
{entry.ergebnis && (
<Chip
label={WartungslogErgebnisLabel[entry.ergebnis]}
size="small"
color={WartungslogErgebnisColor[entry.ergebnis]}
/>
)}
</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.externe_werkstatt && entry.externe_werkstatt,
entry.naechste_faelligkeit && `Nächste Fälligkeit: ${fmtDate(entry.naechste_faelligkeit)}`,
].filter(Boolean).join(' · ')}
</Typography>
{entry.dokument_url ? (
<Chip
label="Dokument"
size="small"
color="info"
variant="outlined"
component="a"
href={`/api/uploads/${entry.dokument_url.split('/uploads/')[1] || entry.dokument_url}`}
target="_blank"
clickable
sx={{ mt: 0.5 }}
/>
) : canWrite ? (
<Button
size="small"
component="label"
sx={{ mt: 0.5, textTransform: 'none', fontSize: '0.75rem' }}
>
Dokument hochladen
<input
type="file"
hidden
accept=".pdf,.doc,.docx,.jpg,.png"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
await vehiclesApi.uploadWartungFile(Number(entry.id), file);
onAdded();
} catch {
// silent fail — user can retry
}
}}
/>
</Button>
) : null}
</Box>
{canWrite && (
<IconButton size="small" onClick={() => openEditDialog(entry)} aria-label="Bearbeiten">
<Edit fontSize="small" />
</IconButton>
)}
</Box>
);
})}
</Stack>
)}
{canWrite && (
<ChatAwareFab
size="small"
aria-label="Wartung eintragen"
onClick={openCreateDialog}
>
<Add />
</ChatAwareFab>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editingWartungId ? 'Wartungseintrag bearbeiten' : '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 *"
fullWidth
placeholder="TT.MM.JJJJ"
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>
{(['§57a Prüfung', 'Service', '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={6}>
<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={6}>
<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 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) as WartungslogErgebnis | 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}>
<GermanDateField
label="Nächste Fälligkeit"
mode="date"
fullWidth
value={form.naechste_faelligkeit || null}
onChange={(iso) => setForm((f) => ({ ...f, naechste_faelligkeit: iso }))}
/>
</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>
);
};
// ── Ausrüstung Tab ───────────────────────────────────────────────────────────
const EQUIPMENT_STATUS_COLOR: Record<AusruestungStatus, 'success' | 'error' | 'warning' | 'default'> = {
[AusruestungStatus.Einsatzbereit]: 'success',
[AusruestungStatus.Beschaedigt]: 'error',
[AusruestungStatus.InWartung]: 'warning',
[AusruestungStatus.AusserDienst]: 'default',
};
function pruefungBadgeColor(tage: number | null): 'success' | 'warning' | 'error' | 'default' {
if (tage === null) return 'default';
if (tage < 0) return 'error';
if (tage <= 30) return 'warning';
return 'success';
}
interface AusruestungTabProps {
equipment: AusruestungListItem[];
vehicleId: string;
}
const AusruestungTab: React.FC<AusruestungTabProps> = ({ equipment, vehicleId: _vehicleId }) => {
const navigate = useNavigate();
const hasProblems = equipment.some(
(e) => e.status === AusruestungStatus.Beschaedigt || e.status === AusruestungStatus.InWartung
);
if (equipment.length === 0) {
return (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Assignment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
Keine Ausrüstung zugewiesen
</Typography>
<Typography variant="body2" color="text.disabled" sx={{ mb: 2 }}>
Diesem Fahrzeug ist derzeit keine Ausrüstung zugeordnet.
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => navigate('/ausruestung')}
>
Zur Ausrüstungsverwaltung
</Button>
</Box>
);
}
return (
<Box>
{hasProblems && (
<Alert severity="warning" icon={<Warning />} sx={{ mb: 2 }}>
<strong>Achtung:</strong> Eine oder mehrere Ausrüstungen dieses Fahrzeugs sind beschädigt oder in Wartung.
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Kategorie</TableCell>
<TableCell>Status</TableCell>
<TableCell align="center">Wichtig</TableCell>
<TableCell>Nächste Prüfung</TableCell>
</TableRow>
</TableHead>
<TableBody>
{equipment.map((item) => {
const statusColor = EQUIPMENT_STATUS_COLOR[item.status] ?? 'default';
const pruefTage = item.pruefung_tage_bis_faelligkeit;
const pruefColor = pruefungBadgeColor(pruefTage);
return (
<TableRow key={item.id} hover>
<TableCell>
<Link
component="button"
variant="body2"
fontWeight={600}
underline="hover"
onClick={() => navigate(`/ausruestung/${item.id}`)}
sx={{ textAlign: 'left' }}
>
{item.bezeichnung}
</Link>
</TableCell>
<TableCell>
<Chip label={item.kategorie_kurzname} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Chip
label={AusruestungStatusLabel[item.status]}
size="small"
color={statusColor}
/>
</TableCell>
<TableCell align="center">
{item.ist_wichtig && (
<Tooltip title="Wichtige Ausrüstung">
<Star fontSize="small" color="warning" />
</Tooltip>
)}
</TableCell>
<TableCell>
{item.naechste_pruefung_am ? (
<Chip
size="small"
color={pruefColor}
variant={pruefColor === 'default' ? 'outlined' : 'filled'}
label={
pruefTage !== null && pruefTage < 0
? `ÜBERFÄLLIG (${fmtDate(item.naechste_pruefung_am)})`
: fmtDate(item.naechste_pruefung_am)
}
icon={pruefTage !== null && pruefTage < 0 ? <Warning fontSize="small" /> : undefined}
/>
) : (
<Typography variant="body2" color="text.disabled"></Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
// ── Main Page ─────────────────────────────────────────────────────────────────
function FahrzeugDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAdmin, canChangeStatus, canManageMaintenance } = usePermissions();
const { hasPermission } = usePermissionContext();
const notification = useNotification();
const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [vehicleEquipment, setVehicleEquipment] = useState<AusruestungListItem[]>([]);
const fetchVehicle = useCallback(async () => {
if (!id) return;
try {
setLoading(true);
setError(null);
const data = await vehiclesApi.getById(id);
setVehicle(data);
// Fetch equipment separately — failure must not break the page
try {
const eq = await equipmentApi.getByVehicle(id);
setVehicleEquipment(eq);
} catch {
setVehicleEquipment([]);
}
} catch {
setError('Fahrzeug konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { fetchVehicle(); }, [fetchVehicle]);
const handleDeleteVehicle = async () => {
if (!id) return;
try {
setDeleteLoading(true);
await vehiclesApi.delete(id);
notification.showSuccess('Fahrzeug wurde erfolgreich gelöscht.');
navigate('/fahrzeuge');
} catch {
notification.showError('Fahrzeug 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 || !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>
);
}
const hasOverdue =
(vehicle.paragraph57a_tage_bis_faelligkeit !== null && vehicle.paragraph57a_tage_bis_faelligkeit < 0) ||
(vehicle.wartung_tage_bis_faelligkeit !== null && vehicle.wartung_tage_bis_faelligkeit < 0);
const titleText = vehicle.kurzname
? `${vehicle.bezeichnung} ${vehicle.kurzname}`
: vehicle.bezeichnung;
const tabs = [
{
label: 'Übersicht',
content: (
<UebersichtTab
vehicle={vehicle}
onStatusUpdated={fetchVehicle}
canChangeStatus={canChangeStatus}
canEdit={hasPermission('fahrzeuge:edit')}
/>
),
},
{
label: hasOverdue
? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Wartung <Warning color="error" fontSize="small" />
</Box>
)
: 'Wartung',
content: (
<WartungTab
fahrzeugId={vehicle.id}
wartungslog={vehicle.wartungslog ?? []}
onAdded={fetchVehicle}
canWrite={canManageMaintenance}
/>
),
},
{
label: 'Einsätze',
content: (
<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>
),
},
{
label: `Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`,
content: <AusruestungTab equipment={vehicleEquipment} vehicleId={vehicle.id} />,
},
...(hasPermission('checklisten:view')
? [{
label: 'Checklisten',
content: <FahrzeugChecklistTab fahrzeugId={vehicle.id} />,
}]
: []),
];
return (
<DashboardLayout>
<Container maxWidth="lg">
<DetailLayout
title={titleText}
backTo="/fahrzeuge"
breadcrumbs={[
{ label: 'Fahrzeuge', href: '/fahrzeuge' },
{ label: titleText },
]}
tabs={tabs}
actions={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
icon={STATUS_ICONS[vehicle.status]}
label={FahrzeugStatusLabel[vehicle.status]}
color={STATUS_CHIP_COLOR[vehicle.status]}
/>
{isAdmin && (
<Tooltip title="Fahrzeug bearbeiten">
<IconButton
size="small"
onClick={() => navigate(`/fahrzeuge/${vehicle.id}/bearbeiten`)}
aria-label="Fahrzeug bearbeiten"
>
<Edit />
</IconButton>
</Tooltip>
)}
{isAdmin && (
<Tooltip title="Fahrzeug löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Fahrzeug löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</Box>
}
/>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
<DialogTitle>Fahrzeug löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Möchten Sie das Fahrzeug &apos;{vehicle.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={handleDeleteVehicle}
disabled={deleteLoading}
startIcon={deleteLoading ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</Container>
</DashboardLayout>
);
}
export default FahrzeugDetail;