522 lines
19 KiB
TypeScript
522 lines
19 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 { 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;
|