refine vehicle freatures
This commit is contained in:
521
frontend/src/pages/AusruestungForm.tsx
Normal file
521
frontend/src/pages/AusruestungForm.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Switch,
|
||||
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 { equipmentApi } from '../services/equipment';
|
||||
import { vehiclesApi } from '../services/vehicles';
|
||||
import {
|
||||
AusruestungStatus,
|
||||
AusruestungStatusLabel,
|
||||
CreateAusruestungPayload,
|
||||
UpdateAusruestungPayload,
|
||||
AusruestungKategorie,
|
||||
} from '../types/equipment.types';
|
||||
import type { FahrzeugListItem } from '../types/vehicle.types';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
// -- Form state shape ---------------------------------------------------------
|
||||
|
||||
interface FormState {
|
||||
bezeichnung: string;
|
||||
kategorie_id: string;
|
||||
seriennummer: string;
|
||||
inventarnummer: string;
|
||||
hersteller: string;
|
||||
baujahr: string; // stored as string for input, converted to number on submit
|
||||
status: AusruestungStatus;
|
||||
status_bemerkung: string;
|
||||
ist_wichtig: boolean;
|
||||
fahrzeug_id: string;
|
||||
standort: string;
|
||||
pruef_intervall_monate: string;
|
||||
letzte_pruefung_am: string;
|
||||
naechste_pruefung_am: string;
|
||||
bemerkung: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
bezeichnung: '',
|
||||
kategorie_id: '',
|
||||
seriennummer: '',
|
||||
inventarnummer: '',
|
||||
hersteller: '',
|
||||
baujahr: '',
|
||||
status: AusruestungStatus.Einsatzbereit,
|
||||
status_bemerkung: '',
|
||||
ist_wichtig: false,
|
||||
fahrzeug_id: '',
|
||||
standort: 'Lager',
|
||||
pruef_intervall_monate: '',
|
||||
letzte_pruefung_am: '',
|
||||
naechste_pruefung_am: '',
|
||||
bemerkung: '',
|
||||
};
|
||||
|
||||
// -- 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 AusruestungForm() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { canChangeStatus } = usePermissions();
|
||||
const isEditMode = Boolean(id);
|
||||
|
||||
// -- Permission guard: only authorized users may create or edit equipment ----
|
||||
if (!canChangeStatus) {
|
||||
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 Ausrüstung zu bearbeiten.
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => navigate('/ausruestung')}>
|
||||
Zurück zur Ausrüstungsübersicht
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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>>>({});
|
||||
|
||||
// -- Lookup data ------------------------------------------------------------
|
||||
const [categories, setCategories] = useState<AusruestungKategorie[]>([]);
|
||||
const [vehicles, setVehicles] = useState<FahrzeugListItem[]>([]);
|
||||
|
||||
const fetchLookups = useCallback(async () => {
|
||||
try {
|
||||
const [cats, vehs] = await Promise.all([
|
||||
equipmentApi.getCategories(),
|
||||
vehiclesApi.getAll(),
|
||||
]);
|
||||
setCategories(cats);
|
||||
setVehicles(vehs);
|
||||
} catch {
|
||||
// Non-critical: dropdowns will be empty but form still usable
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchEquipment = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const equipment = await equipmentApi.getById(id);
|
||||
setForm({
|
||||
bezeichnung: equipment.bezeichnung,
|
||||
kategorie_id: equipment.kategorie_id,
|
||||
seriennummer: equipment.seriennummer ?? '',
|
||||
inventarnummer: equipment.inventarnummer ?? '',
|
||||
hersteller: equipment.hersteller ?? '',
|
||||
baujahr: equipment.baujahr?.toString() ?? '',
|
||||
status: equipment.status,
|
||||
status_bemerkung: equipment.status_bemerkung ?? '',
|
||||
ist_wichtig: equipment.ist_wichtig,
|
||||
fahrzeug_id: equipment.fahrzeug_id ?? '',
|
||||
standort: equipment.standort ?? 'Lager',
|
||||
pruef_intervall_monate: equipment.pruef_intervall_monate?.toString() ?? '',
|
||||
letzte_pruefung_am: toDateInput(equipment.letzte_pruefung_am),
|
||||
naechste_pruefung_am: toDateInput(equipment.naechste_pruefung_am),
|
||||
bemerkung: equipment.bemerkung ?? '',
|
||||
});
|
||||
} catch {
|
||||
setError('Ausrüstung konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLookups();
|
||||
}, [fetchLookups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode) fetchEquipment();
|
||||
}, [isEditMode, fetchEquipment]);
|
||||
|
||||
// -- Validation -------------------------------------------------------------
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: Partial<Record<keyof FormState, string>> = {};
|
||||
if (!form.bezeichnung.trim()) {
|
||||
errors.bezeichnung = 'Bezeichnung ist erforderlich.';
|
||||
}
|
||||
if (!form.kategorie_id) {
|
||||
errors.kategorie_id = 'Kategorie ist erforderlich.';
|
||||
}
|
||||
if (form.baujahr) {
|
||||
const year = parseInt(form.baujahr, 10);
|
||||
if (isNaN(year) || year < 1950 || year > 2100) {
|
||||
errors.baujahr = 'Baujahr muss zwischen 1950 und 2100 liegen.';
|
||||
}
|
||||
}
|
||||
if (form.pruef_intervall_monate) {
|
||||
const months = parseInt(form.pruef_intervall_monate, 10);
|
||||
if (isNaN(months) || months < 1 || months > 120) {
|
||||
errors.pruef_intervall_monate = 'Prüfintervall muss zwischen 1 und 120 Monaten liegen.';
|
||||
}
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// -- Submit -----------------------------------------------------------------
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
if (isEditMode && id) {
|
||||
const payload: UpdateAusruestungPayload = {
|
||||
bezeichnung: form.bezeichnung.trim() || undefined,
|
||||
kategorie_id: form.kategorie_id || undefined,
|
||||
seriennummer: form.seriennummer.trim() || undefined,
|
||||
inventarnummer: form.inventarnummer.trim() || undefined,
|
||||
hersteller: form.hersteller.trim() || undefined,
|
||||
baujahr: form.baujahr ? parseInt(form.baujahr, 10) : undefined,
|
||||
status: form.status,
|
||||
status_bemerkung: form.status_bemerkung.trim() || undefined,
|
||||
ist_wichtig: form.ist_wichtig,
|
||||
fahrzeug_id: form.fahrzeug_id || null,
|
||||
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
|
||||
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined,
|
||||
letzte_pruefung_am: form.letzte_pruefung_am || undefined,
|
||||
naechste_pruefung_am: form.naechste_pruefung_am || undefined,
|
||||
bemerkung: form.bemerkung.trim() || undefined,
|
||||
};
|
||||
await equipmentApi.update(id, payload);
|
||||
navigate(`/ausruestung/${id}`);
|
||||
} else {
|
||||
const payload: CreateAusruestungPayload = {
|
||||
bezeichnung: form.bezeichnung.trim(),
|
||||
kategorie_id: form.kategorie_id,
|
||||
seriennummer: form.seriennummer.trim() || undefined,
|
||||
inventarnummer: form.inventarnummer.trim() || undefined,
|
||||
hersteller: form.hersteller.trim() || undefined,
|
||||
baujahr: form.baujahr ? parseInt(form.baujahr, 10) : undefined,
|
||||
status: form.status,
|
||||
status_bemerkung: form.status_bemerkung.trim() || undefined,
|
||||
ist_wichtig: form.ist_wichtig,
|
||||
fahrzeug_id: form.fahrzeug_id || undefined,
|
||||
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
|
||||
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined,
|
||||
letzte_pruefung_am: form.letzte_pruefung_am || undefined,
|
||||
naechste_pruefung_am: form.naechste_pruefung_am || undefined,
|
||||
bemerkung: form.bemerkung.trim() || undefined,
|
||||
};
|
||||
const created = await equipmentApi.create(payload);
|
||||
navigate(`/ausruestung/${created.id}`);
|
||||
}
|
||||
} catch {
|
||||
setSaveError(
|
||||
isEditMode
|
||||
? 'Ausrüstung konnte nicht gespeichert werden.'
|
||||
: 'Ausrüstung konnte nicht erstellt werden.'
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// -- Field helper -----------------------------------------------------------
|
||||
|
||||
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],
|
||||
});
|
||||
|
||||
// -- Loading / Error early returns ------------------------------------------
|
||||
|
||||
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('/ausruestung')} sx={{ mt: 2 }}>
|
||||
Zurück
|
||||
</Button>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Render -----------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="md">
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => (isEditMode && id ? navigate(`/ausruestung/${id}`) : navigate('/ausruestung'))}
|
||||
sx={{ mb: 2 }}
|
||||
size="small"
|
||||
>
|
||||
{isEditMode ? 'Zurück zur Detailansicht' : 'Ausrüstungsübersicht'}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'}
|
||||
</Typography>
|
||||
|
||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
{/* ── Section: Grunddaten ──────────────────────────────────────────── */}
|
||||
<Typography variant="h6" gutterBottom>Grunddaten</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={8}>
|
||||
<TextField
|
||||
label="Bezeichnung *"
|
||||
fullWidth
|
||||
{...f('bezeichnung')}
|
||||
inputProps={{ maxLength: 200 }}
|
||||
placeholder="z.B. Atemschutzgerät Dräger PSS 5000"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<FormControl fullWidth error={Boolean(fieldErrors.kategorie_id)}>
|
||||
<InputLabel>Kategorie *</InputLabel>
|
||||
<Select
|
||||
label="Kategorie *"
|
||||
value={form.kategorie_id}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, kategorie_id: e.target.value as string }))}
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<MenuItem key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{fieldErrors.kategorie_id && (
|
||||
<Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.5 }}>
|
||||
{fieldErrors.kategorie_id}
|
||||
</Typography>
|
||||
)}
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Seriennummer"
|
||||
fullWidth
|
||||
{...f('seriennummer')}
|
||||
inputProps={{ maxLength: 100 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Inventarnummer"
|
||||
fullWidth
|
||||
{...f('inventarnummer')}
|
||||
inputProps={{ maxLength: 50 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Hersteller"
|
||||
fullWidth
|
||||
{...f('hersteller')}
|
||||
inputProps={{ maxLength: 150 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Baujahr"
|
||||
type="number"
|
||||
fullWidth
|
||||
{...f('baujahr')}
|
||||
inputProps={{ min: 1950, max: 2100 }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* ── Section: Status & Zuordnung ──────────────────────────────────── */}
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Status & Zuordnung</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 AusruestungStatus }))}
|
||||
>
|
||||
{Object.values(AusruestungStatus).map((s) => (
|
||||
<MenuItem key={s} value={s}>{AusruestungStatusLabel[s]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
{form.status !== AusruestungStatus.Einsatzbereit && (
|
||||
<Grid item xs={12} sm={8}>
|
||||
<TextField
|
||||
label="Status-Bemerkung"
|
||||
fullWidth
|
||||
multiline
|
||||
{...f('status_bemerkung')}
|
||||
placeholder="z.B. Defektes Ventil, Reparatur beauftragt"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.ist_wichtig}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, ist_wichtig: e.target.checked }))}
|
||||
/>
|
||||
}
|
||||
label="Wichtiges Gerät (Warnung auf Fahrzeugkarte wenn nicht einsatzbereit)"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Fahrzeug</InputLabel>
|
||||
<Select
|
||||
label="Fahrzeug"
|
||||
value={form.fahrzeug_id}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, fahrzeug_id: e.target.value as string }))}
|
||||
>
|
||||
<MenuItem value="">Kein Fahrzeug (Lager)</MenuItem>
|
||||
{vehicles.map((v) => (
|
||||
<MenuItem key={v.id} value={v.id}>
|
||||
{v.bezeichnung}{v.kurzname ? ` (${v.kurzname})` : ''}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
{!form.fahrzeug_id && (
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Standort"
|
||||
fullWidth
|
||||
{...f('standort')}
|
||||
placeholder="z.B. Lager, Regal A3"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* ── Section: Pruefung & Wartung ───────────────────────────────────── */}
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Prüfung & Wartung</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Prüfintervall (Monate)"
|
||||
type="number"
|
||||
fullWidth
|
||||
{...f('pruef_intervall_monate')}
|
||||
inputProps={{ min: 1, max: 120 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Letzte Prüfung"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={form.letzte_pruefung_am}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<TextField
|
||||
label="Nächste Prüfung"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={form.naechste_pruefung_am}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* ── Section: Bemerkungen ──────────────────────────────────────────── */}
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>Bemerkungen</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Bemerkung"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
{...f('bemerkung')}
|
||||
placeholder="Zusätzliche Informationen zum Gerät"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 3 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => (isEditMode && id ? navigate(`/ausruestung/${id}`) : navigate('/ausruestung'))}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={saving ? <CircularProgress size={16} /> : <Save />}
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
>
|
||||
{isEditMode ? 'Änderungen speichern' : 'Gerät erstellen'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default AusruestungForm;
|
||||
Reference in New Issue
Block a user