Files
dashboard/frontend/src/pages/AusruestungForm.tsx

544 lines
21 KiB
TypeScript

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 { Save } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageHeader } from '../components/templates';
import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput';
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 'DD.MM.YYYY' */
function toDateInput(iso: string | null | undefined): string {
return toGermanDate(iso);
}
// -- Component ----------------------------------------------------------------
function AusruestungForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { canManageEquipment } = 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>>>({});
// -- 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]);
// -- Permission guard: only authorized users may create or edit equipment ----
if (!canManageEquipment) {
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>
);
}
// -- 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.';
}
}
if (form.letzte_pruefung_am && !isValidGermanDate(form.letzte_pruefung_am)) {
errors.letzte_pruefung_am = 'Ungültiges Datum. Format: TT.MM.JJJJ';
}
if (form.naechste_pruefung_am && !isValidGermanDate(form.naechste_pruefung_am)) {
errors.naechste_pruefung_am = 'Ungültiges Datum. Format: TT.MM.JJJJ';
}
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() || null,
inventarnummer: form.inventarnummer.trim() || null,
hersteller: form.hersteller.trim() || null,
baujahr: form.baujahr ? parseInt(form.baujahr, 10) : null,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || null,
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) : null,
letzte_pruefung_am: form.letzte_pruefung_am ? fromGermanDate(form.letzte_pruefung_am) || null : null,
naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || null : null,
bemerkung: form.bemerkung.trim() || null,
};
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 ? fromGermanDate(form.letzte_pruefung_am) || undefined : undefined,
naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || undefined : 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 onClick={() => navigate('/ausruestung')} sx={{ mt: 2 }}>
Zurück
</Button>
</Container>
</DashboardLayout>
);
}
// -- Render -----------------------------------------------------------------
return (
<DashboardLayout>
<Container maxWidth="md">
<PageHeader
title={isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'}
breadcrumbs={[
{ label: 'Ausrüstung', href: '/ausruestung' },
...(isEditMode && id ? [{ label: 'Detail', href: `/ausruestung/${id}` }] : []),
{ label: isEditMode ? 'Bearbeiten' : 'Neu' },
]}
backTo={isEditMode && id ? `/ausruestung/${id}` : '/ausruestung'}
/>
{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 &amp; 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 &amp; 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"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.letzte_pruefung_am}
onChange={(e) => setForm((prev) => ({ ...prev, letzte_pruefung_am: e.target.value }))}
onBlur={() => {
if (form.letzte_pruefung_am && !isValidGermanDate(form.letzte_pruefung_am)) {
setFieldErrors((prev) => ({ ...prev, letzte_pruefung_am: 'Ungültiges Datum. Format: TT.MM.JJJJ' }));
} else {
setFieldErrors((prev) => ({ ...prev, letzte_pruefung_am: undefined }));
}
}}
error={Boolean(fieldErrors.letzte_pruefung_am)}
helperText={fieldErrors.letzte_pruefung_am}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
label="Nächste Prüfung"
fullWidth
placeholder="TT.MM.JJJJ"
value={form.naechste_pruefung_am}
onChange={(e) => setForm((prev) => ({ ...prev, naechste_pruefung_am: e.target.value }))}
onBlur={() => {
if (form.naechste_pruefung_am && !isValidGermanDate(form.naechste_pruefung_am)) {
setFieldErrors((prev) => ({ ...prev, naechste_pruefung_am: 'Ungültiges Datum. Format: TT.MM.JJJJ' }));
} else {
setFieldErrors((prev) => ({ ...prev, naechste_pruefung_am: undefined }));
}
}}
error={Boolean(fieldErrors.naechste_pruefung_am)}
helperText={fieldErrors.naechste_pruefung_am}
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;