1117 lines
41 KiB
TypeScript
1117 lines
41 KiB
TypeScript
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,
|
||
Tab,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableContainer,
|
||
TableHead,
|
||
TableRow,
|
||
Tabs,
|
||
TextField,
|
||
Tooltip,
|
||
Typography,
|
||
} from '@mui/material';
|
||
import {
|
||
Add,
|
||
ArrowBack,
|
||
Assignment,
|
||
Build,
|
||
CheckCircle,
|
||
DeleteOutline,
|
||
DirectionsCar,
|
||
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 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';
|
||
|
||
// ── 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" />,
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus }) => {
|
||
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 [activeTab, setActiveTab] = useState(0);
|
||
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);
|
||
|
||
return (
|
||
<DashboardLayout>
|
||
<Container maxWidth="lg">
|
||
<Button
|
||
startIcon={<ArrowBack />}
|
||
onClick={() => navigate('/fahrzeuge')}
|
||
sx={{ mb: 2 }}
|
||
size="small"
|
||
>
|
||
Fahrzeugübersicht
|
||
</Button>
|
||
|
||
<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}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
<Box sx={{ ml: 'auto', 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>
|
||
</Box>
|
||
|
||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
|
||
<Tabs
|
||
value={activeTab}
|
||
onChange={(_, v) => setActiveTab(v)}
|
||
aria-label="Fahrzeug Detailansicht"
|
||
variant="scrollable"
|
||
scrollButtons="auto"
|
||
>
|
||
<Tab label="Übersicht" />
|
||
<Tab
|
||
label={
|
||
hasOverdue
|
||
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||
Wartung <Warning color="error" fontSize="small" />
|
||
</Box>
|
||
: 'Wartung'
|
||
}
|
||
/>
|
||
<Tab label="Einsätze" />
|
||
<Tab label={`Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`} />
|
||
{hasPermission('checklisten:view') && <Tab label="Checklisten" />}
|
||
</Tabs>
|
||
</Box>
|
||
|
||
<TabPanel value={activeTab} index={0}>
|
||
<UebersichtTab
|
||
vehicle={vehicle}
|
||
onStatusUpdated={fetchVehicle}
|
||
canChangeStatus={canChangeStatus}
|
||
/>
|
||
</TabPanel>
|
||
|
||
<TabPanel value={activeTab} index={1}>
|
||
<WartungTab
|
||
fahrzeugId={vehicle.id}
|
||
wartungslog={vehicle.wartungslog ?? []}
|
||
onAdded={fetchVehicle}
|
||
canWrite={canManageMaintenance}
|
||
/>
|
||
</TabPanel>
|
||
|
||
<TabPanel value={activeTab} index={2}>
|
||
<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>
|
||
|
||
<TabPanel value={activeTab} index={3}>
|
||
<AusruestungTab equipment={vehicleEquipment} vehicleId={vehicle.id} />
|
||
</TabPanel>
|
||
|
||
{hasPermission('checklisten:view') && (
|
||
<TabPanel value={activeTab} index={4}>
|
||
<FahrzeugChecklistTab fahrzeugId={vehicle.id} />
|
||
</TabPanel>
|
||
)}
|
||
|
||
{/* Delete confirmation dialog */}
|
||
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
|
||
<DialogTitle>Fahrzeug löschen</DialogTitle>
|
||
<DialogContent>
|
||
<DialogContentText>
|
||
Möchten Sie das Fahrzeug '{vehicle.bezeichnung}' 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;
|