rework vehicle handling
This commit is contained in:
400
frontend/src/pages/FahrzeugForm.tsx
Normal file
400
frontend/src/pages/FahrzeugForm.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user