refine vehicle freatures

This commit is contained in:
Matthias Hochmeister
2026-02-28 17:19:18 +01:00
parent 0e81eabda6
commit e2be29c712
17 changed files with 4071 additions and 117 deletions

View File

@@ -9,6 +9,7 @@ import {
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Fab,
@@ -16,11 +17,18 @@ import {
Grid,
IconButton,
InputLabel,
Link,
MenuItem,
Paper,
Select,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Tooltip,
@@ -32,6 +40,7 @@ import {
Assignment,
Build,
CheckCircle,
DeleteOutline,
DirectionsCar,
Edit,
Error as ErrorIcon,
@@ -40,12 +49,14 @@ import {
PauseCircle,
ReportProblem,
School,
Star,
Verified,
Warning,
} from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles';
import { equipmentApi } from '../services/equipment';
import {
FahrzeugDetail,
FahrzeugWartungslog,
@@ -55,7 +66,10 @@ import {
UpdateStatusPayload,
WartungslogArt,
} from '../types/vehicle.types';
import type { AusruestungListItem } from '../types/equipment.types';
import { AusruestungStatus, AusruestungStatusLabel } from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext';
// ── Tab Panel ─────────────────────────────────────────────────────────────────
@@ -195,12 +209,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
{ label: 'Bezeichnung', value: vehicle.bezeichnung },
{ label: 'Kurzname', value: vehicle.kurzname },
{ label: 'Kennzeichen', value: vehicle.amtliches_kennzeichen },
{ label: 'Fahrgestellnr.', value: vehicle.fahrgestellnummer },
{ label: 'Baujahr', value: vehicle.baujahr?.toString() },
{ label: 'Hersteller', value: vehicle.hersteller },
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
{ label: 'Standort', value: vehicle.standort },
].map(({ label, value }) => (
<Grid item xs={12} sm={6} md={4} key={label}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
@@ -492,17 +500,153 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
);
};
// ── 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 }) => {
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 } = usePermissions();
const notification = useNotification();
const [vehicle, setVehicle] = useState<FahrzeugDetail | 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;
@@ -511,6 +655,13 @@ function FahrzeugDetail() {
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 {
@@ -520,6 +671,20 @@ function FahrzeugDetail() {
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>
@@ -573,7 +738,6 @@ function FahrzeugDetail() {
{vehicle.amtliches_kennzeichen && (
<Typography variant="subtitle1" color="text.secondary">
{vehicle.amtliches_kennzeichen}
{vehicle.hersteller && ` · ${vehicle.hersteller}`}
</Typography>
)}
</Box>
@@ -594,6 +758,18 @@ function FahrzeugDetail() {
</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>
@@ -614,6 +790,7 @@ function FahrzeugDetail() {
}
/>
<Tab label="Einsätze" />
<Tab label={`Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`} />
</Tabs>
</Box>
@@ -645,6 +822,38 @@ function FahrzeugDetail() {
</Typography>
</Box>
</TabPanel>
<TabPanel value={activeTab} index={3}>
<AusruestungTab equipment={vehicleEquipment} vehicleId={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 &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>
);