359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Alert,
|
|
Autocomplete,
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
CircularProgress,
|
|
Container,
|
|
Grid,
|
|
Paper,
|
|
TextField,
|
|
Typography,
|
|
} from '@mui/material';
|
|
import { Save } from '@mui/icons-material';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|
import { PageHeader } from '../components/templates';
|
|
import GermanDateField from '../components/shared/GermanDateField';
|
|
import { vehiclesApi } from '../services/vehicles';
|
|
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
|
|
import type { FahrzeugTyp } from '../types/checklist.types';
|
|
import {
|
|
CreateFahrzeugPayload,
|
|
UpdateFahrzeugPayload,
|
|
} from '../types/vehicle.types';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
|
|
// ── Form state shape ──────────────────────────────────────────────────────────
|
|
|
|
interface FormState {
|
|
bezeichnung: string;
|
|
kurzname: string;
|
|
amtliches_kennzeichen: string;
|
|
fahrgestellnummer: string;
|
|
baujahr: string;
|
|
hersteller: string;
|
|
typ_schluessel: string;
|
|
besatzung_soll: string;
|
|
standort: string;
|
|
bild_url: string;
|
|
paragraph57a_faellig_am: string;
|
|
naechste_wartung_am: string;
|
|
}
|
|
|
|
const EMPTY_FORM: FormState = {
|
|
bezeichnung: '',
|
|
kurzname: '',
|
|
amtliches_kennzeichen: '',
|
|
fahrgestellnummer: '',
|
|
baujahr: '',
|
|
hersteller: '',
|
|
typ_schluessel: '',
|
|
besatzung_soll: '',
|
|
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' for type="date" inputs */
|
|
function toDateInput(iso: string | null | undefined): string {
|
|
if (!iso) return '';
|
|
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
|
return '';
|
|
}
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
|
|
function FahrzeugForm() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const { isAdmin } = usePermissions();
|
|
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 [allTypes, setAllTypes] = useState<FahrzeugTyp[]>([]);
|
|
const [selectedTypes, setSelectedTypes] = useState<FahrzeugTyp[]>([]);
|
|
|
|
useEffect(() => {
|
|
fahrzeugTypenApi.getAll().then(setAllTypes).catch(() => {});
|
|
}, []);
|
|
|
|
const fetchVehicle = useCallback(async () => {
|
|
if (!id) return;
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const [vehicle, types] = await Promise.all([
|
|
vehiclesApi.getById(id),
|
|
fahrzeugTypenApi.getTypesForVehicle(id).catch(() => [] as FahrzeugTyp[]),
|
|
]);
|
|
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 ?? '',
|
|
standort: vehicle.standort,
|
|
bild_url: vehicle.bild_url ?? '',
|
|
paragraph57a_faellig_am: toDateInput(vehicle.paragraph57a_faellig_am),
|
|
naechste_wartung_am: toDateInput(vehicle.naechste_wartung_am),
|
|
});
|
|
setSelectedTypes(types);
|
|
} catch {
|
|
setError('Fahrzeug konnte nicht geladen werden.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
if (isEditMode) fetchVehicle();
|
|
}, [isEditMode, fetchVehicle]);
|
|
|
|
// ── Permission guard: only admins may create or edit vehicles ──────────────
|
|
if (!isAdmin) {
|
|
return (
|
|
<DashboardLayout>
|
|
<Container maxWidth="lg">
|
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
|
<Typography variant="h5" gutterBottom>
|
|
Keine Berechtigung
|
|
</Typography>
|
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
|
Sie haben nicht die erforderlichen Rechte, um Fahrzeuge zu bearbeiten.
|
|
</Typography>
|
|
<Button variant="contained" onClick={() => navigate('/fahrzeuge')}>
|
|
Zurück zur Fahrzeugübersicht
|
|
</Button>
|
|
</Box>
|
|
</Container>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
const validate = (): boolean => {
|
|
const errors: Partial<Record<keyof FormState, string>> = {};
|
|
if (!form.bezeichnung.trim()) {
|
|
errors.bezeichnung = 'Bezeichnung ist erforderlich.';
|
|
}
|
|
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() || null,
|
|
amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || null,
|
|
fahrgestellnummer: form.fahrgestellnummer.trim() || null,
|
|
baujahr: form.baujahr ? Number(form.baujahr) : null,
|
|
hersteller: form.hersteller.trim() || null,
|
|
typ_schluessel: form.typ_schluessel.trim() || null,
|
|
besatzung_soll: form.besatzung_soll.trim() || null,
|
|
standort: form.standort.trim() || 'Feuerwehrhaus',
|
|
bild_url: form.bild_url.trim() || null,
|
|
paragraph57a_faellig_am: form.paragraph57a_faellig_am || null,
|
|
naechste_wartung_am: form.naechste_wartung_am || null,
|
|
};
|
|
await vehiclesApi.update(id, payload);
|
|
await fahrzeugTypenApi.setTypesForVehicle(id, selectedTypes.map((t) => t.id));
|
|
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,
|
|
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);
|
|
await fahrzeugTypenApi.setTypesForVehicle(newVehicle.id, selectedTypes.map((t) => t.id));
|
|
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 onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
|
|
Zurück
|
|
</Button>
|
|
</Container>
|
|
</DashboardLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<Container maxWidth="md">
|
|
<PageHeader
|
|
title={isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'}
|
|
backTo={isEditMode && id ? `/fahrzeuge/${id}` : '/fahrzeuge'}
|
|
/>
|
|
|
|
{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}>
|
|
<Autocomplete
|
|
multiple
|
|
options={allTypes}
|
|
getOptionLabel={(o) => o.name}
|
|
value={selectedTypes}
|
|
onChange={(_e, val) => setSelectedTypes(val)}
|
|
isOptionEqualToValue={(a, b) => a.id === b.id}
|
|
renderTags={(value, getTagProps) =>
|
|
value.map((option, index) => (
|
|
<Chip label={option.name} size="small" {...getTagProps({ index })} key={option.id} />
|
|
))
|
|
}
|
|
renderInput={(params) => (
|
|
<TextField {...params} label="Fahrzeugtypen" placeholder={selectedTypes.length === 0 ? 'Typen auswählen…' : ''} />
|
|
)}
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Prüf- und Wartungsfristen</Typography>
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={12} sm={6}>
|
|
<GermanDateField
|
|
label="§57a fällig am"
|
|
fullWidth
|
|
value={form.paragraph57a_faellig_am}
|
|
onChange={(iso) => setForm((prev) => ({ ...prev, paragraph57a_faellig_am: iso }))}
|
|
helperText="Periodische Begutachtung (§57a StVO)"
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={12} sm={6}>
|
|
<GermanDateField
|
|
label="Nächste Wartung am"
|
|
fullWidth
|
|
value={form.naechste_wartung_am}
|
|
onChange={(iso) => setForm((prev) => ({ ...prev, naechste_wartung_am: iso }))}
|
|
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;
|