rework vehicle handling

This commit is contained in:
Matthias Hochmeister
2026-02-28 13:34:16 +01:00
parent 84cf505511
commit 41fc41bee4
13 changed files with 931 additions and 1228 deletions

View File

@@ -12,6 +12,7 @@ import Einsaetze from './pages/Einsaetze';
import EinsatzDetail from './pages/EinsatzDetail';
import Fahrzeuge from './pages/Fahrzeuge';
import FahrzeugDetail from './pages/FahrzeugDetail';
import FahrzeugForm from './pages/FahrzeugForm';
import Ausruestung from './pages/Ausruestung';
import Mitglieder from './pages/Mitglieder';
import MitgliedDetail from './pages/MitgliedDetail';
@@ -76,6 +77,22 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/fahrzeuge/neu"
element={
<ProtectedRoute>
<FahrzeugForm />
</ProtectedRoute>
}
/>
<Route
path="/fahrzeuge/:id/bearbeiten"
element={
<ProtectedRoute>
<FahrzeugForm />
</ProtectedRoute>
}
/>
<Route
path="/fahrzeuge/:id"
element={

View File

@@ -4,15 +4,12 @@ import {
AlertTitle,
Box,
CircularProgress,
Collapse,
Link,
Typography,
} from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import { vehiclesApi } from '../../services/vehicles';
import { InspectionAlert, PruefungArtLabel, PruefungArt } from '../../types/vehicle.types';
// ── Helpers ───────────────────────────────────────────────────────────────────
import { InspectionAlert, InspectionAlertType } from '../../types/vehicle.types';
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', {
@@ -22,6 +19,10 @@ function formatDate(iso: string): string {
});
}
function alertTypeLabel(type: InspectionAlertType): string {
return type === '57a' ? '§57a Periodische Prüfung' : 'Nächste Wartung / Service';
}
type Urgency = 'overdue' | 'urgent' | 'warning';
function getUrgency(tage: number): Urgency {
@@ -36,12 +37,8 @@ const URGENCY_CONFIG: Record<Urgency, { severity: 'error' | 'warning'; label: st
warning: { severity: 'warning', label: 'Fällig in Kürze (≤ 30 Tage)' },
};
// ── Component ─────────────────────────────────────────────────────────────────
interface InspectionAlertsProps {
/** How many days ahead to fetch — default 30 */
daysAhead?: number;
/** Collapse into a single banner if no alerts */
hideWhenEmpty?: boolean;
}
@@ -55,7 +52,6 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
useEffect(() => {
let mounted = true;
const fetchAlerts = async () => {
try {
setLoading(true);
@@ -68,7 +64,6 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
if (mounted) setLoading(false);
}
};
fetchAlerts();
return () => { mounted = false; };
}, [daysAhead]);
@@ -92,12 +87,11 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
if (hideWhenEmpty) return null;
return (
<Alert severity="success">
Alle Prüfungsfristen sind aktuell. Keine Fälligkeiten in den nächsten {daysAhead} Tagen.
Alle Fristen sind aktuell. Keine Fälligkeiten in den nächsten {daysAhead} Tagen.
</Alert>
);
}
// Group by urgency
const overdue = alerts.filter((a) => a.tage < 0);
const urgent = alerts.filter((a) => a.tage >= 0 && a.tage <= 14);
const warning = alerts.filter((a) => a.tage > 14);
@@ -117,35 +111,37 @@ const InspectionAlerts: React.FC<InspectionAlertsProps> = ({
<AlertTitle sx={{ fontWeight: 600 }}>{label}</AlertTitle>
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{items.map((alert) => {
const artLabel = PruefungArtLabel[alert.pruefungArt as PruefungArt] ?? alert.pruefungArt;
const dateStr = formatDate(alert.faelligAm);
const tageText = alert.tage < 0
const typeLabel = alertTypeLabel(alert.type);
const dateStr = formatDate(alert.faelligAm);
const tageText = alert.tage < 0
? `seit ${Math.abs(alert.tage)} Tag${Math.abs(alert.tage) === 1 ? '' : 'en'} überfällig`
: alert.tage === 0
? 'heute fällig'
: `fällig in ${alert.tage} Tag${alert.tage === 1 ? '' : 'en'}`;
return (
<Collapse key={alert.pruefungId} in timeout="auto">
<Box component="li" sx={{ mb: 0.5 }}>
<Link
component={RouterLink}
to={`/fahrzeuge/${alert.fahrzeugId}`}
color="inherit"
underline="hover"
sx={{ fontWeight: 500 }}
>
{alert.bezeichnung}
{alert.kurzname ? ` (${alert.kurzname})` : ''}
</Link>
{' — '}
<strong>{artLabel}</strong>
{' '}
<Typography component="span" variant="body2">
{tageText} ({dateStr})
</Typography>
</Box>
</Collapse>
<Box
key={`${alert.fahrzeugId}-${alert.type}`}
component="li"
sx={{ mb: 0.5 }}
>
<Link
component={RouterLink}
to={`/fahrzeuge/${alert.fahrzeugId}`}
color="inherit"
underline="hover"
sx={{ fontWeight: 500 }}
>
{alert.bezeichnung}
{alert.kurzname ? ` (${alert.kurzname})` : ''}
</Link>
{' — '}
<strong>{typeLabel}</strong>
{' '}
<Typography component="span" variant="body2">
{tageText} ({dateStr})
</Typography>
</Box>
);
})}
</Box>

View File

@@ -22,13 +22,6 @@ import {
Tab,
Tabs,
TextField,
Timeline,
TimelineConnector,
TimelineContent,
TimelineDot,
TimelineItem,
TimelineOppositeContent,
TimelineSeparator,
Tooltip,
Typography,
} from '@mui/material';
@@ -39,6 +32,7 @@ import {
Build,
CheckCircle,
DirectionsCar,
Edit,
Error as ErrorIcon,
LocalFireDepartment,
PauseCircle,
@@ -51,16 +45,12 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles';
import {
FahrzeugDetail,
FahrzeugPruefung,
FahrzeugWartungslog,
FahrzeugStatus,
FahrzeugStatusLabel,
PruefungArt,
PruefungArtLabel,
CreatePruefungPayload,
CreateWartungslogPayload,
UpdateStatusPayload,
WartungslogArt,
PruefungErgebnis,
} from '../types/vehicle.types';
import { usePermissions } from '../hooks/usePermissions';
@@ -125,11 +115,24 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const openDialog = () => {
setNewStatus(vehicle.status);
setBemerkung(vehicle.status_bemerkung ?? '');
setSaveError(null);
setStatusDialogOpen(true);
};
const closeDialog = () => {
setSaveError(null);
setStatusDialogOpen(false);
};
const handleSaveStatus = async () => {
try {
setSaving(true);
setSaveError(null);
await vehiclesApi.updateStatus(vehicle.id, { status: newStatus, bemerkung });
const payload: UpdateStatusPayload = { status: newStatus, bemerkung };
await vehiclesApi.updateStatus(vehicle.id, payload);
setStatusDialogOpen(false);
onStatusUpdated();
} catch {
@@ -141,6 +144,12 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
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 && (
@@ -156,9 +165,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{STATUS_ICONS[vehicle.status]}
<Box>
<Typography variant="subtitle1" fontWeight={600}>
Aktueller Status
</Typography>
<Typography variant="subtitle1" fontWeight={600}>Aktueller Status</Typography>
<Chip
label={FahrzeugStatusLabel[vehicle.status]}
color={STATUS_CHIP_COLOR[vehicle.status]}
@@ -171,18 +178,11 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
)}
</Box>
</Box>
<Button
variant="outlined"
size="small"
onClick={() => {
setNewStatus(vehicle.status);
setBemerkung(vehicle.status_bemerkung ?? '');
setStatusDialogOpen(true);
}}
sx={{ display: canChangeStatus ? undefined : 'none' }}
>
Status ändern
</Button>
{canChangeStatus && (
<Button variant="outlined" size="small" onClick={openDialog}>
Status ändern
</Button>
)}
</Box>
</Paper>
@@ -198,8 +198,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
{ label: 'Typ (DIN 14502)', value: vehicle.typ_schluessel },
{ label: 'Besatzung (Soll)', value: vehicle.besatzung_soll },
{ label: 'Standort', value: vehicle.standort },
{ label: '§57a fällig am', value: fmtDate(vehicle.paragraph57a_faellig_am) !== '—' ? fmtDate(vehicle.paragraph57a_faellig_am) : null },
{ label: 'Nächste Wartung', value: fmtDate(vehicle.naechste_wartung_am) !== '—' ? fmtDate(vehicle.naechste_wartung_am) : null },
].map(({ label, value }) => (
<Grid item xs={12} sm={6} md={4} key={label}>
<Typography variant="caption" color="text.secondary" textTransform="uppercase">
@@ -210,48 +208,40 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
))}
</Grid>
{/* Inspection status quick view */}
{/* Inspection deadline quick view */}
<Typography variant="h6" sx={{ mt: 3, mb: 1.5 }}>
Prüffristen Übersicht
Prüf- und Wartungsfristen
</Typography>
<Grid container spacing={1.5}>
{Object.entries(vehicle.pruefstatus).map(([key, ps]) => {
const art = key.toUpperCase() as PruefungArt;
const label = PruefungArtLabel[art] ?? key;
const color = inspectionBadgeColor(ps.tage_bis_faelligkeit);
{inspItems.map(({ label, faelligAm, tage }) => {
const color = inspectionBadgeColor(tage);
return (
<Grid item xs={12} sm={6} md={3} key={key}>
<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>
{ps.faellig_am ? (
{faelligAm ? (
<>
<Chip
size="small"
color={color}
label={
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
? `ÜBERFÄLLIG (${fmtDate(ps.faellig_am)})`
: `Fällig: ${fmtDate(ps.faellig_am)}`
}
icon={
ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit < 0
? <Warning fontSize="small" />
: undefined
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 }}
/>
{ps.tage_bis_faelligkeit !== null && ps.tage_bis_faelligkeit >= 0 && (
{tage !== null && tage >= 0 && (
<Typography variant="caption" display="block" color="text.secondary">
in {ps.tage_bis_faelligkeit} Tagen
in {tage} Tagen
</Typography>
)}
</>
) : (
<Typography variant="body2" color="text.disabled">
Keine Daten
</Typography>
<Typography variant="body2" color="text.disabled">Kein Datum erfasst</Typography>
)}
</Paper>
</Grid>
@@ -260,12 +250,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
</Grid>
{/* Status change dialog */}
<Dialog
open={statusDialogOpen}
onClose={() => setStatusDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<Dialog open={statusDialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>Fahrzeugstatus ändern</DialogTitle>
<DialogContent>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
@@ -278,9 +263,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
onChange={(e) => setNewStatus(e.target.value as FahrzeugStatus)}
>
{Object.values(FahrzeugStatus).map((s) => (
<MenuItem key={s} value={s}>
{FahrzeugStatusLabel[s]}
</MenuItem>
<MenuItem key={s} value={s}>{FahrzeugStatusLabel[s]}</MenuItem>
))}
</Select>
</FormControl>
@@ -295,7 +278,7 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setStatusDialogOpen(false)}>Abbrechen</Button>
<Button onClick={closeDialog}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSaveStatus}
@@ -310,247 +293,6 @@ const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated,
);
};
// ── Prüfungen Tab ─────────────────────────────────────────────────────────────
interface PruefungenTabProps {
fahrzeugId: string;
pruefungen: FahrzeugPruefung[];
onAdded: () => void;
canWrite: boolean;
}
const ERGEBNIS_LABELS: Record<PruefungErgebnis, string> = {
bestanden: 'Bestanden',
bestanden_mit_maengeln: 'Bestanden mit Mängeln',
nicht_bestanden: 'Nicht bestanden',
ausstehend: 'Ausstehend',
};
const ERGEBNIS_COLORS: Record<PruefungErgebnis, 'success' | 'warning' | 'error' | 'default'> = {
bestanden: 'success',
bestanden_mit_maengeln: 'warning',
nicht_bestanden: 'error',
ausstehend: 'default',
};
const PruefungenTab: React.FC<PruefungenTabProps> = ({ fahrzeugId, pruefungen, onAdded, canWrite }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const emptyForm: CreatePruefungPayload = {
pruefung_art: PruefungArt.HU,
faellig_am: '',
durchgefuehrt_am: '',
ergebnis: 'ausstehend',
pruefende_stelle: '',
kosten: undefined,
bemerkung: '',
};
const [form, setForm] = useState<CreatePruefungPayload>(emptyForm);
const handleSubmit = async () => {
if (!form.faellig_am) {
setSaveError('Fälligkeitsdatum ist erforderlich.');
return;
}
try {
setSaving(true);
setSaveError(null);
const payload: CreatePruefungPayload = {
...form,
durchgefuehrt_am: form.durchgefuehrt_am || undefined,
kosten: form.kosten !== undefined && form.kosten !== null ? Number(form.kosten) : undefined,
};
await vehiclesApi.addPruefung(fahrzeugId, payload);
setDialogOpen(false);
setForm(emptyForm);
onAdded();
} catch {
setSaveError('Prüfung konnte nicht gespeichert werden.');
} finally {
setSaving(false);
}
};
return (
<Box>
{pruefungen.length === 0 ? (
<Typography color="text.secondary">Noch keine Prüfungen erfasst.</Typography>
) : (
<Stack divider={<Divider />} spacing={0}>
{pruefungen.map((p) => {
const ergebnis = (p.ergebnis ?? 'ausstehend') as PruefungErgebnis;
const isFaellig = !p.durchgefuehrt_am && new Date(p.faellig_am) < new Date();
return (
<Box key={p.id} sx={{ py: 2, display: 'flex', gap: 2, alignItems: 'flex-start' }}>
<Box sx={{ minWidth: 140 }}>
<Typography variant="caption" color="text.secondary">
{PruefungArtLabel[p.pruefung_art] ?? p.pruefung_art}
</Typography>
<Typography variant="body2" fontWeight={600}>
Fällig: {fmtDate(p.faellig_am)}
</Typography>
{isFaellig && !p.durchgefuehrt_am && (
<Chip label="ÜBERFÄLLIG" color="error" size="small" sx={{ mt: 0.5 }} />
)}
</Box>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 0.5 }}>
<Chip
label={ERGEBNIS_LABELS[ergebnis]}
color={ERGEBNIS_COLORS[ergebnis]}
size="small"
/>
{p.durchgefuehrt_am && (
<Chip
label={`Durchgeführt: ${fmtDate(p.durchgefuehrt_am)}`}
variant="outlined"
size="small"
/>
)}
{p.naechste_faelligkeit && (
<Chip
label={`Nächste: ${fmtDate(p.naechste_faelligkeit)}`}
variant="outlined"
size="small"
/>
)}
</Box>
{p.pruefende_stelle && (
<Typography variant="body2" color="text.secondary">
{p.pruefende_stelle}
{p.kosten != null && ` · ${p.kosten.toFixed(2)}`}
</Typography>
)}
{p.bemerkung && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{p.bemerkung}
</Typography>
)}
</Box>
</Box>
);
})}
</Stack>
)}
{/* FAB */}
{canWrite && (
<Fab
color="primary"
size="small"
aria-label="Prüfung hinzufügen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => { setForm(emptyForm); setDialogOpen(true); }}
>
<Add />
</Fab>
)}
{/* Add inspection dialog */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Prüfung erfassen</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}>
<FormControl fullWidth>
<InputLabel>Prüfungsart</InputLabel>
<Select
label="Prüfungsart"
value={form.pruefung_art}
onChange={(e) => setForm((f) => ({ ...f, pruefung_art: e.target.value as PruefungArt }))}
>
{Object.values(PruefungArt).map((art) => (
<MenuItem key={art} value={art}>{PruefungArtLabel[art]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Ergebnis</InputLabel>
<Select
label="Ergebnis"
value={form.ergebnis ?? 'ausstehend'}
onChange={(e) => setForm((f) => ({ ...f, ergebnis: e.target.value as PruefungErgebnis }))}
>
{(Object.keys(ERGEBNIS_LABELS) as PruefungErgebnis[]).map((e) => (
<MenuItem key={e} value={e}>{ERGEBNIS_LABELS[e]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fällig am *"
type="date"
fullWidth
value={form.faellig_am}
onChange={(e) => setForm((f) => ({ ...f, faellig_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Durchgeführt am"
type="date"
fullWidth
value={form.durchgefuehrt_am ?? ''}
onChange={(e) => setForm((f) => ({ ...f, durchgefuehrt_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
label="Prüfende Stelle"
fullWidth
value={form.pruefende_stelle ?? ''}
onChange={(e) => setForm((f) => ({ ...f, pruefende_stelle: e.target.value }))}
placeholder="z.B. TÜV Süd Stuttgart"
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Kosten (€)"
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="Bemerkung"
fullWidth
multiline
rows={2}
value={form.bemerkung ?? ''}
onChange={(e) => setForm((f) => ({ ...f, bemerkung: e.target.value }))}
/>
</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>
);
};
// ── Wartung Tab ───────────────────────────────────────────────────────────────
interface WartungTabProps {
@@ -561,11 +303,11 @@ interface WartungTabProps {
}
const WARTUNG_ART_ICONS: Record<string, React.ReactElement> = {
Kraftstoff: <LocalFireDepartment color="action" />,
Reparatur: <Build color="warning" />,
Inspektion: <Assignment color="primary" />,
Hauptuntersuchung:<CheckCircle color="success" />,
default: <Build color="action" />,
Kraftstoff: <LocalFireDepartment color="action" />,
Reparatur: <Build color="warning" />,
Inspektion: <Assignment color="primary" />,
Hauptuntersuchung: <CheckCircle color="success" />,
default: <Build color="action" />,
};
const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdded, canWrite }) => {
@@ -612,8 +354,6 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
{wartungslog.length === 0 ? (
<Typography color="text.secondary">Noch keine Wartungseinträge erfasst.</Typography>
) : (
// MUI Timeline is available via @mui/lab — using Paper list as fallback
// since @mui/lab is not in current package.json
<Stack divider={<Divider />} spacing={0}>
{wartungslog.map((entry) => {
const artIcon = WARTUNG_ART_ICONS[entry.art ?? ''] ?? WARTUNG_ART_ICONS.default;
@@ -623,9 +363,7 @@ const WartungTab: React.FC<WartungTabProps> = ({ fahrzeugId, wartungslog, onAdde
<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.art && <Chip label={entry.art} size="small" variant="outlined" />}
</Box>
<Typography variant="body2">{entry.beschreibung}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
@@ -795,11 +533,7 @@ function FahrzeugDetail() {
<DashboardLayout>
<Container maxWidth="lg">
<Alert severity="error">{error ?? 'Fahrzeug nicht gefunden.'}</Alert>
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/fahrzeuge')}
sx={{ mt: 2 }}
>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
Zurück zur Übersicht
</Button>
</Container>
@@ -807,10 +541,13 @@ function FahrzeugDetail() {
);
}
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">
{/* Breadcrumb / back */}
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/fahrzeuge')}
@@ -820,7 +557,6 @@ function FahrzeugDetail() {
Fahrzeugübersicht
</Button>
{/* Page title */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} />
<Box>
@@ -839,16 +575,26 @@ function FahrzeugDetail() {
</Typography>
)}
</Box>
<Box sx={{ ml: 'auto' }}>
<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>
)}
</Box>
</Box>
{/* Tabs */}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
<Tabs
value={activeTab}
@@ -858,42 +604,35 @@ function FahrzeugDetail() {
<Tab label="Übersicht" />
<Tab
label={
vehicle.naechste_pruefung_tage !== null && vehicle.naechste_pruefung_tage < 0
hasOverdue
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Prüfungen <Warning color="error" fontSize="small" />
Wartung <Warning color="error" fontSize="small" />
</Box>
: 'Prüfungen'
: 'Wartung'
}
/>
<Tab label="Wartung" />
<Tab label="Einsätze" />
</Tabs>
</Box>
{/* Tab content */}
<TabPanel value={activeTab} index={0}>
<UebersichtTab vehicle={vehicle} onStatusUpdated={fetchVehicle} canChangeStatus={canChangeStatus} />
</TabPanel>
<TabPanel value={activeTab} index={1}>
<PruefungenTab
fahrzeugId={vehicle.id}
pruefungen={vehicle.pruefungen}
onAdded={fetchVehicle}
canWrite={isAdmin}
<UebersichtTab
vehicle={vehicle}
onStatusUpdated={fetchVehicle}
canChangeStatus={canChangeStatus}
/>
</TabPanel>
<TabPanel value={activeTab} index={2}>
<TabPanel value={activeTab} index={1}>
<WartungTab
fahrzeugId={vehicle.id}
wartungslog={vehicle.wartungslog}
onAdded={fetchVehicle}
canWrite={isAdmin}
canWrite={canChangeStatus}
/>
</TabPanel>
<TabPanel value={activeTab} index={3}>
<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">

View File

@@ -0,0 +1,400 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
Container,
FormControl,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
TextField,
Typography,
} from '@mui/material';
import { ArrowBack, Save } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { vehiclesApi } from '../services/vehicles';
import {
FahrzeugStatus,
FahrzeugStatusLabel,
CreateFahrzeugPayload,
UpdateFahrzeugPayload,
} from '../types/vehicle.types';
// ── Form state shape ──────────────────────────────────────────────────────────
interface FormState {
bezeichnung: string;
kurzname: string;
amtliches_kennzeichen: string;
fahrgestellnummer: string;
baujahr: string; // kept as string for input, parsed on submit
hersteller: string;
typ_schluessel: string;
besatzung_soll: string;
status: FahrzeugStatus;
status_bemerkung: string;
standort: string;
bild_url: string;
paragraph57a_faellig_am: string; // ISO date 'YYYY-MM-DD' or ''
naechste_wartung_am: string; // ISO date 'YYYY-MM-DD' or ''
}
const EMPTY_FORM: FormState = {
bezeichnung: '',
kurzname: '',
amtliches_kennzeichen: '',
fahrgestellnummer: '',
baujahr: '',
hersteller: '',
typ_schluessel: '',
besatzung_soll: '',
status: FahrzeugStatus.Einsatzbereit,
status_bemerkung: '',
standort: 'Feuerwehrhaus',
bild_url: '',
paragraph57a_faellig_am: '',
naechste_wartung_am: '',
};
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert a Date ISO string like '2026-03-15T00:00:00.000Z' to 'YYYY-MM-DD' */
function toDateInput(iso: string | null | undefined): string {
if (!iso) return '';
return iso.slice(0, 10);
}
// ── Component ─────────────────────────────────────────────────────────────────
function FahrzeugForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isEditMode = Boolean(id);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditMode);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const fetchVehicle = useCallback(async () => {
if (!id) return;
try {
setLoading(true);
setError(null);
const vehicle = await vehiclesApi.getById(id);
setForm({
bezeichnung: vehicle.bezeichnung,
kurzname: vehicle.kurzname ?? '',
amtliches_kennzeichen: vehicle.amtliches_kennzeichen ?? '',
fahrgestellnummer: vehicle.fahrgestellnummer ?? '',
baujahr: vehicle.baujahr?.toString() ?? '',
hersteller: vehicle.hersteller ?? '',
typ_schluessel: vehicle.typ_schluessel ?? '',
besatzung_soll: vehicle.besatzung_soll ?? '',
status: vehicle.status,
status_bemerkung: vehicle.status_bemerkung ?? '',
standort: vehicle.standort,
bild_url: vehicle.bild_url ?? '',
paragraph57a_faellig_am: toDateInput(vehicle.paragraph57a_faellig_am),
naechste_wartung_am: toDateInput(vehicle.naechste_wartung_am),
});
} catch {
setError('Fahrzeug konnte nicht geladen werden.');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
if (isEditMode) fetchVehicle();
}, [isEditMode, fetchVehicle]);
const validate = (): boolean => {
const errors: Partial<Record<keyof FormState, string>> = {};
if (!form.bezeichnung.trim()) {
errors.bezeichnung = 'Bezeichnung ist erforderlich.';
}
if (form.baujahr && (isNaN(Number(form.baujahr)) || Number(form.baujahr) < 1950 || Number(form.baujahr) > 2100)) {
errors.baujahr = 'Baujahr muss zwischen 1950 und 2100 liegen.';
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
try {
setSaving(true);
setSaveError(null);
if (isEditMode && id) {
const payload: UpdateFahrzeugPayload = {
bezeichnung: form.bezeichnung.trim() || undefined,
kurzname: form.kurzname.trim() || undefined,
amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || undefined,
fahrgestellnummer: form.fahrgestellnummer.trim() || undefined,
baujahr: form.baujahr ? Number(form.baujahr) : undefined,
hersteller: form.hersteller.trim() || undefined,
typ_schluessel: form.typ_schluessel.trim() || undefined,
besatzung_soll: form.besatzung_soll.trim() || undefined,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
naechste_wartung_am: form.naechste_wartung_am || undefined,
};
await vehiclesApi.update(id, payload);
navigate(`/fahrzeuge/${id}`);
} else {
const payload: CreateFahrzeugPayload = {
bezeichnung: form.bezeichnung.trim(),
kurzname: form.kurzname.trim() || undefined,
amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || undefined,
fahrgestellnummer: form.fahrgestellnummer.trim() || undefined,
baujahr: form.baujahr ? Number(form.baujahr) : undefined,
hersteller: form.hersteller.trim() || undefined,
typ_schluessel: form.typ_schluessel.trim() || undefined,
besatzung_soll: form.besatzung_soll.trim() || undefined,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
naechste_wartung_am: form.naechste_wartung_am || undefined,
};
const newVehicle = await vehiclesApi.create(payload);
navigate(`/fahrzeuge/${newVehicle.id}`);
}
} catch {
setSaveError(
isEditMode
? 'Fahrzeug konnte nicht gespeichert werden.'
: 'Fahrzeug konnte nicht erstellt werden.'
);
} finally {
setSaving(false);
}
};
const f = (field: keyof FormState) => ({
value: form[field] as string,
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((prev) => ({ ...prev, [field]: e.target.value })),
error: Boolean(fieldErrors[field]),
helperText: fieldErrors[field],
});
if (loading) {
return (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
if (error) {
return (
<DashboardLayout>
<Container maxWidth="md">
<Alert severity="error">{error}</Alert>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
Zurück
</Button>
</Container>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Container maxWidth="md">
<Button
startIcon={<ArrowBack />}
onClick={() => (isEditMode && id ? navigate(`/fahrzeuge/${id}`) : navigate('/fahrzeuge'))}
sx={{ mb: 2 }}
size="small"
>
{isEditMode ? 'Zurück zur Detailansicht' : 'Fahrzeugübersicht'}
</Button>
<Typography variant="h4" gutterBottom>
{isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'}
</Typography>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Stammdaten</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={8}>
<TextField
label="Bezeichnung *"
fullWidth
{...f('bezeichnung')}
placeholder="z.B. HLF 20/16"
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Kurzname"
fullWidth
{...f('kurzname')}
placeholder="z.B. HLF 1"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Amtl. Kennzeichen"
fullWidth
{...f('amtliches_kennzeichen')}
placeholder="z.B. WN-FW 1"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fahrgestellnummer (VIN)"
fullWidth
{...f('fahrgestellnummer')}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Baujahr"
type="number"
fullWidth
{...f('baujahr')}
inputProps={{ min: 1950, max: 2100 }}
/>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
label="Hersteller"
fullWidth
{...f('hersteller')}
placeholder="z.B. MAN TGM / Rosenbauer"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Typ-Schlüssel (DIN 14502)"
fullWidth
{...f('typ_schluessel')}
placeholder="z.B. LF 10"
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
label="Besatzung (Soll)"
fullWidth
{...f('besatzung_soll')}
placeholder="z.B. 1/8"
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
label="Standort"
fullWidth
{...f('standort')}
/>
</Grid>
</Grid>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Status</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={form.status}
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value as FahrzeugStatus }))}
>
{Object.values(FahrzeugStatus).map((s) => (
<MenuItem key={s} value={s}>{FahrzeugStatusLabel[s]}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
label="Status-Bemerkung"
fullWidth
{...f('status_bemerkung')}
placeholder="z.B. Fahrzeug in Werkstatt bis 01.03."
/>
</Grid>
</Grid>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Prüf- und Wartungsfristen</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
label="§57a fällig am"
type="date"
fullWidth
value={form.paragraph57a_faellig_am}
onChange={(e) => setForm((prev) => ({ ...prev, paragraph57a_faellig_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
helperText="Periodische Begutachtung (§57a StVO)"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Nächste Wartung am"
type="date"
fullWidth
value={form.naechste_wartung_am}
onChange={(e) => setForm((prev) => ({ ...prev, naechste_wartung_am: e.target.value }))}
InputLabelProps={{ shrink: true }}
helperText="Nächster geplanter Servicetermin"
/>
</Grid>
</Grid>
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Bild</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
label="Bild-URL"
fullWidth
{...f('bild_url')}
placeholder="https://..."
helperText="Direktlink zu einem Fahrzeugfoto (https://)"
/>
</Grid>
</Grid>
</Paper>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 3 }}>
<Button
variant="outlined"
onClick={() => (isEditMode && id ? navigate(`/fahrzeuge/${id}`) : navigate('/fahrzeuge'))}
>
Abbrechen
</Button>
<Button
variant="contained"
startIcon={saving ? <CircularProgress size={16} /> : <Save />}
onClick={handleSubmit}
disabled={saving}
>
{isEditMode ? 'Änderungen speichern' : 'Fahrzeug erstellen'}
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
export default FahrzeugForm;

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Alert,
Box,
Card,
CardActionArea,
@@ -15,7 +16,6 @@ import {
TextField,
Tooltip,
Typography,
Alert,
} from '@mui/material';
import {
Add,
@@ -35,8 +35,6 @@ import {
FahrzeugListItem,
FahrzeugStatus,
FahrzeugStatusLabel,
PruefungArt,
PruefungArtLabel,
} from '../types/vehicle.types';
import { usePermissions } from '../hooks/usePermissions';
@@ -64,13 +62,23 @@ function inspBadgeColor(tage: number | null): InspBadgeColor {
}
function inspBadgeLabel(art: string, tage: number | null, faelligAm: string | null): string {
const artShort = art; // 'HU', 'AU', etc.
if (faelligAm === null) return '';
const date = new Date(faelligAm).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
if (tage === null) return `${artShort}: ${date}`;
if (tage < 0) return `${artShort}: ÜBERFÄLLIG (${date})`;
if (tage === 0) return `${artShort}: heute (${date})`;
return `${artShort}: ${date}`;
const date = new Date(faelligAm).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: '2-digit',
});
if (tage === null) return `${art}: ${date}`;
if (tage < 0) return `${art}: ÜBERFÄLLIG (${date})`;
if (tage === 0) return `${art}: heute (${date})`;
return `${art}: ${date}`;
}
function inspTooltipTitle(fullLabel: string, tage: number | null, faelligAm: string | null): string {
if (!faelligAm) return fullLabel;
const date = new Date(faelligAm).toLocaleDateString('de-DE');
if (tage !== null && tage < 0) {
return `${fullLabel}: Seit ${Math.abs(tage)} Tagen überfällig!`;
}
return `${fullLabel}: Fällig am ${date}`;
}
// ── Vehicle Card ──────────────────────────────────────────────────────────────
@@ -81,15 +89,23 @@ interface VehicleCardProps {
}
const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
const status = vehicle.status as FahrzeugStatus;
const status = vehicle.status as FahrzeugStatus;
const statusCfg = STATUS_CONFIG[status] ?? STATUS_CONFIG[FahrzeugStatus.Einsatzbereit];
const isSchaden = status === FahrzeugStatus.AusserDienstSchaden;
// Collect inspection badges (only for types where a faellig_am exists)
const inspBadges: { art: string; tage: number | null; faelligAm: string | null }[] = [
{ art: '§57a', tage: vehicle.paragraph57a_tage_bis_faelligkeit, faelligAm: vehicle.paragraph57a_faellig_am },
{ art: 'Wartung', tage: vehicle.wartung_tage_bis_faelligkeit, faelligAm: vehicle.naechste_wartung_am },
const inspBadges = [
{
art: '§57a',
fullLabel: '§57a Periodische Prüfung',
tage: vehicle.paragraph57a_tage_bis_faelligkeit,
faelligAm: vehicle.paragraph57a_faellig_am,
},
{
art: 'Wartung',
fullLabel: 'Nächste Wartung / Service',
tage: vehicle.wartung_tage_bis_faelligkeit,
faelligAm: vehicle.naechste_wartung_am,
},
].filter((b) => b.faelligAm !== null);
return (
@@ -116,7 +132,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
onClick={() => onClick(vehicle.id)}
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
>
{/* Vehicle image / placeholder */}
{vehicle.bild_url ? (
<CardMedia
component="img"
@@ -140,7 +155,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
)}
<CardContent sx={{ flexGrow: 1, pb: '8px !important' }}>
{/* Title row */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 0.5 }}>
<Box>
<Typography variant="h6" component="div" lineHeight={1.2}>
@@ -159,7 +173,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
</Box>
</Box>
{/* Status badge */}
<Box sx={{ mb: 1 }}>
<Chip
icon={statusCfg.icon}
@@ -170,7 +183,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
/>
</Box>
{/* Crew config */}
{vehicle.besatzung_soll && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Besatzung: {vehicle.besatzung_soll}
@@ -178,7 +190,6 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
</Typography>
)}
{/* Inspection badges */}
{inspBadges.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{inspBadges.map((b) => {
@@ -188,11 +199,7 @@ const VehicleCard: React.FC<VehicleCardProps> = ({ vehicle, onClick }) => {
return (
<Tooltip
key={b.art}
title={`${PruefungArtLabel[b.art as PruefungArt] ?? b.art}: ${
b.tage !== null && b.tage < 0
? `Seit ${Math.abs(b.tage)} Tagen überfällig!`
: `Fällig am ${new Date(b.faelligAm!).toLocaleDateString('de-DE')}`
}`}
title={inspTooltipTitle(b.fullLabel, b.tage, b.faelligAm)}
>
<Chip
size="small"
@@ -249,16 +256,18 @@ function Fahrzeuge() {
);
});
// Summary counts
const einsatzbereit = vehicles.filter((v) => v.status === FahrzeugStatus.Einsatzbereit).length;
// An overdue inspection exists if §57a OR Wartung is past due
const hasOverdue = vehicles.some(
(v) => v.naechste_pruefung_tage !== null && v.naechste_pruefung_tage < 0
(v) =>
(v.paragraph57a_tage_bis_faelligkeit !== null && v.paragraph57a_tage_bis_faelligkeit < 0) ||
(v.wartung_tage_bis_faelligkeit !== null && v.wartung_tage_bis_faelligkeit < 0)
);
return (
<DashboardLayout>
<Container maxWidth="xl">
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box>
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
@@ -268,12 +277,7 @@ function Fahrzeuge() {
<Typography variant="body2" color="text.secondary">
{vehicles.length} Fahrzeug{vehicles.length !== 1 ? 'e' : ''} gesamt
{' · '}
<Typography
component="span"
variant="body2"
color="success.main"
fontWeight={600}
>
<Typography component="span" variant="body2" color="success.main" fontWeight={600}>
{einsatzbereit} einsatzbereit
</Typography>
</Typography>
@@ -281,15 +285,12 @@ function Fahrzeuge() {
</Box>
</Box>
{/* Overdue inspection global warning */}
{hasOverdue && (
<Alert severity="error" sx={{ mb: 2 }} icon={<Warning />}>
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungsfrist.
Betroffene Fahrzeuge dürfen bis zur Durchführung der Prüfung ggf. nicht eingesetzt werden.
<strong>Achtung:</strong> Mindestens ein Fahrzeug hat eine überfällige Prüfungs- oder Wartungsfrist.
</Alert>
)}
{/* Search bar */}
<TextField
placeholder="Fahrzeug suchen (Bezeichnung, Kennzeichen, Hersteller…)"
value={search}
@@ -306,21 +307,18 @@ function Fahrzeuge() {
}}
/>
{/* Loading state */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{/* Error state */}
{!loading && error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* Empty state */}
{!loading && !error && filtered.length === 0 && (
<Box sx={{ textAlign: 'center', py: 8 }}>
<DirectionsCar sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
@@ -332,7 +330,6 @@ function Fahrzeuge() {
</Box>
)}
{/* Vehicle grid */}
{!loading && !error && filtered.length > 0 && (
<Grid container spacing={3}>
{filtered.map((vehicle) => (
@@ -346,7 +343,6 @@ function Fahrzeuge() {
</Grid>
)}
{/* FAB — add vehicle (shown to write-role users only; role check done server-side) */}
{isAdmin && (
<Fab
color="primary"

View File

@@ -2,47 +2,31 @@ import { api } from './api';
import type {
FahrzeugListItem,
FahrzeugDetail,
FahrzeugPruefung,
FahrzeugWartungslog,
VehicleStats,
InspectionAlert,
CreateFahrzeugPayload,
UpdateFahrzeugPayload,
UpdateStatusPayload,
CreatePruefungPayload,
CreateWartungslogPayload,
} from '../types/vehicle.types';
// ---------------------------------------------------------------------------
// Internal: unwrap the standard { success, data } envelope
// ---------------------------------------------------------------------------
async function unwrap<T>(promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>): Promise<T> {
async function unwrap<T>(
promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>
): Promise<T> {
const response = await promise;
return response.data.data;
}
// ---------------------------------------------------------------------------
// Vehicle API Service
// ---------------------------------------------------------------------------
export const vehiclesApi = {
// ── Fleet overview ──────────────────────────────────────────────────────────
/** Fetch all vehicles with their next inspection badge data */
async getAll(): Promise<FahrzeugListItem[]> {
return unwrap(api.get<{ success: boolean; data: FahrzeugListItem[] }>('/api/vehicles'));
},
/** Dashboard KPI stats */
async getStats(): Promise<VehicleStats> {
return unwrap(api.get<{ success: boolean; data: VehicleStats }>('/api/vehicles/stats'));
},
/**
* Upcoming and overdue inspection alerts.
* @param daysAhead How many days to look ahead (default 30, max 365).
*/
async getAlerts(daysAhead = 30): Promise<InspectionAlert[]> {
return unwrap(
api.get<{ success: boolean; data: InspectionAlert[] }>(
@@ -51,15 +35,10 @@ export const vehiclesApi = {
);
},
// ── Vehicle detail ──────────────────────────────────────────────────────────
/** Full vehicle detail including inspection history and maintenance log */
async getById(id: string): Promise<FahrzeugDetail> {
return unwrap(api.get<{ success: boolean; data: FahrzeugDetail }>(`/api/vehicles/${id}`));
},
// ── CRUD ────────────────────────────────────────────────────────────────────
async create(payload: CreateFahrzeugPayload): Promise<FahrzeugDetail> {
const response = await api.post<{ success: boolean; data: FahrzeugDetail }>(
'/api/vehicles',
@@ -80,29 +59,10 @@ export const vehiclesApi = {
await api.delete(`/api/vehicles/${id}`);
},
/** Live status change — Socket.IO event is emitted server-side in Tier 3 */
async updateStatus(id: string, payload: UpdateStatusPayload): Promise<void> {
await api.patch(`/api/vehicles/${id}/status`, payload);
},
// ── Inspections ─────────────────────────────────────────────────────────────
async getPruefungen(id: string): Promise<FahrzeugPruefung[]> {
return unwrap(
api.get<{ success: boolean; data: FahrzeugPruefung[] }>(`/api/vehicles/${id}/pruefungen`)
);
},
async addPruefung(id: string, payload: CreatePruefungPayload): Promise<FahrzeugPruefung> {
const response = await api.post<{ success: boolean; data: FahrzeugPruefung }>(
`/api/vehicles/${id}/pruefungen`,
payload
);
return response.data.data;
},
// ── Maintenance log ─────────────────────────────────────────────────────────
async getWartungslog(id: string): Promise<FahrzeugWartungslog[]> {
return unwrap(
api.get<{ success: boolean; data: FahrzeugWartungslog[] }>(`/api/vehicles/${id}/wartung`)

View File

@@ -1,6 +1,5 @@
// =============================================================================
// Vehicle Fleet Management — Frontend Type Definitions
// Mirror of backend/src/models/vehicle.model.ts (transport layer shapes)
// =============================================================================
export enum FahrzeugStatus {
@@ -17,32 +16,6 @@ export const FahrzeugStatusLabel: Record<FahrzeugStatus, string> = {
[FahrzeugStatus.InLehrgang]: 'In Lehrgang',
};
export enum PruefungArt {
HU = 'HU',
AU = 'AU',
UVV = 'UVV',
Leiter = 'Leiter',
Kran = 'Kran',
Seilwinde = 'Seilwinde',
Sonstiges = 'Sonstiges',
}
export const PruefungArtLabel: Record<PruefungArt, string> = {
[PruefungArt.HU]: 'Hauptuntersuchung (TÜV)',
[PruefungArt.AU]: 'Abgasuntersuchung',
[PruefungArt.UVV]: 'UVV-Prüfung (BGV D29)',
[PruefungArt.Leiter]: 'Leiternprüfung (DLK)',
[PruefungArt.Kran]: 'Kranprüfung',
[PruefungArt.Seilwinde]: 'Seilwindenprüfung',
[PruefungArt.Sonstiges]: 'Sonstige Prüfung',
};
export type PruefungErgebnis =
| 'bestanden'
| 'bestanden_mit_maengeln'
| 'nicht_bestanden'
| 'ausstehend';
export type WartungslogArt =
| 'Inspektion'
| 'Reparatur'
@@ -65,14 +38,6 @@ export interface FahrzeugListItem {
status: FahrzeugStatus;
status_bemerkung: string | null;
bild_url: string | null;
hu_faellig_am: string | null; // ISO date string from API
hu_tage_bis_faelligkeit: number | null;
au_faellig_am: string | null;
au_tage_bis_faelligkeit: number | null;
uvv_faellig_am: string | null;
uvv_tage_bis_faelligkeit: number | null;
leiter_faellig_am: string | null;
leiter_tage_bis_faelligkeit: number | null;
paragraph57a_faellig_am: string | null;
paragraph57a_tage_bis_faelligkeit: number | null;
naechste_wartung_am: string | null;
@@ -80,29 +45,6 @@ export interface FahrzeugListItem {
naechste_pruefung_tage: number | null;
}
export interface PruefungStatus {
pruefung_id: string | null;
faellig_am: string | null;
tage_bis_faelligkeit: number | null;
ergebnis: PruefungErgebnis | null;
}
export interface FahrzeugPruefung {
id: string;
fahrzeug_id: string;
pruefung_art: PruefungArt;
faellig_am: string;
durchgefuehrt_am: string | null;
ergebnis: PruefungErgebnis | null;
naechste_faelligkeit: string | null;
pruefende_stelle: string | null;
kosten: number | null;
dokument_url: string | null;
bemerkung: string | null;
erfasst_von: string | null;
created_at: string;
}
export interface FahrzeugWartungslog {
id: string;
fahrzeug_id: string;
@@ -137,14 +79,7 @@ export interface FahrzeugDetail {
paragraph57a_tage_bis_faelligkeit: number | null;
naechste_wartung_am: string | null;
wartung_tage_bis_faelligkeit: number | null;
pruefstatus: {
hu: PruefungStatus;
au: PruefungStatus;
uvv: PruefungStatus;
leiter: PruefungStatus;
};
naechste_pruefung_tage: number | null;
pruefungen: FahrzeugPruefung[];
naechste_pruefung_tage: number | null;
wartungslog: FahrzeugWartungslog[];
}
@@ -157,12 +92,13 @@ export interface VehicleStats {
inspectionsOverdue: number;
}
export type InspectionAlertType = '57a' | 'wartung';
export interface InspectionAlert {
fahrzeugId: string;
bezeichnung: string;
kurzname: string | null;
pruefungId: string;
pruefungArt: PruefungArt;
type: InspectionAlertType;
faelligAm: string;
tage: number;
}
@@ -186,24 +122,15 @@ export interface CreateFahrzeugPayload {
naechste_wartung_am?: string;
}
export type UpdateFahrzeugPayload = Partial<CreateFahrzeugPayload>;
export type UpdateFahrzeugPayload = {
[K in keyof CreateFahrzeugPayload]?: CreateFahrzeugPayload[K] | null;
};
export interface UpdateStatusPayload {
status: FahrzeugStatus;
bemerkung?: string;
}
export interface CreatePruefungPayload {
pruefung_art: PruefungArt;
faellig_am: string;
durchgefuehrt_am?: string;
ergebnis?: PruefungErgebnis;
pruefende_stelle?: string;
kosten?: number;
dokument_url?: string;
bemerkung?: string;
}
export interface CreateWartungslogPayload {
datum: string;
art?: WartungslogArt;