feat(ausruestungsanfrage): add personal item tracking, catalog enforcement, and detail pages

This commit is contained in:
Matthias Hochmeister
2026-04-14 16:49:20 +02:00
parent e6b6639fe9
commit 633a75cb0b
15 changed files with 1031 additions and 26 deletions

View File

@@ -0,0 +1,302 @@
import { useState, useEffect, useMemo } from 'react';
import {
Autocomplete,
Box,
Button,
Container,
IconButton,
LinearProgress,
MenuItem,
Stack,
TextField,
Typography,
} from '@mui/material';
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { personalEquipmentApi } from '../services/personalEquipment';
import { membersService } from '../services/members';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { PageHeader } from '../components/templates';
import { ZUSTAND_LABELS } from '../types/personalEquipment.types';
import type {
PersoenlicheAusruestungZustand,
UpdatePersoenlicheAusruestungPayload,
} from '../types/personalEquipment.types';
const ZUSTAND_OPTIONS = Object.entries(ZUSTAND_LABELS) as [PersoenlicheAusruestungZustand, string][];
interface EigenschaftRow {
id?: number;
eigenschaft_id?: number | null;
name: string;
wert: string;
}
export default function PersoenlicheAusruestungEdit() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
const { data: item, isLoading, isError } = useQuery({
queryKey: ['persoenliche-ausruestung', 'detail', id],
queryFn: () => personalEquipmentApi.getById(id!),
enabled: !!id,
});
const { data: membersList } = useQuery({
queryKey: ['members-list-compact'],
queryFn: () => membersService.getMembers({ pageSize: 500 }),
staleTime: 5 * 60 * 1000,
enabled: canViewAll,
});
const memberOptions = useMemo(() => {
return (membersList?.items ?? []).map((m) => ({
id: m.id,
name: [m.given_name, m.family_name].filter(Boolean).join(' ') || m.email,
}));
}, [membersList]);
// Form state
const [bezeichnung, setBezeichnung] = useState('');
const [kategorie, setKategorie] = useState('');
const [groesse, setGroesse] = useState('');
const [seriennummer, setSeriennummer] = useState('');
const [inventarnummer, setInventarnummer] = useState('');
const [anschaffungDatum, setAnschaffungDatum] = useState('');
const [zustand, setZustand] = useState<PersoenlicheAusruestungZustand>('gut');
const [notizen, setNotizen] = useState('');
const [userId, setUserId] = useState<{ id: string; name: string } | null>(null);
const [eigenschaften, setEigenschaften] = useState<EigenschaftRow[]>([]);
// Initialize form from loaded item
useEffect(() => {
if (!item) return;
setBezeichnung(item.bezeichnung);
setKategorie(item.kategorie ?? '');
setGroesse(item.groesse ?? '');
setSeriennummer(item.seriennummer ?? '');
setInventarnummer(item.inventarnummer ?? '');
setAnschaffungDatum(item.anschaffung_datum ? item.anschaffung_datum.split('T')[0] : '');
setZustand(item.zustand);
setNotizen(item.notizen ?? '');
if (item.eigenschaften) {
setEigenschaften(item.eigenschaften.map(e => ({
id: e.id,
eigenschaft_id: e.eigenschaft_id,
name: e.name,
wert: e.wert,
})));
}
}, [item]);
// Set userId when item + memberOptions are ready
useEffect(() => {
if (!item?.user_id || memberOptions.length === 0) return;
const match = memberOptions.find(m => m.id === item.user_id);
if (match) setUserId(match);
}, [item, memberOptions]);
const updateMutation = useMutation({
mutationFn: (data: UpdatePersoenlicheAusruestungPayload) => personalEquipmentApi.update(id!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
showSuccess('Persönliche Ausrüstung aktualisiert');
navigate(`/persoenliche-ausruestung/${id}`);
},
onError: () => {
showError('Fehler beim Speichern');
},
});
const handleSave = () => {
if (!bezeichnung.trim()) return;
const payload: UpdatePersoenlicheAusruestungPayload = {
bezeichnung: bezeichnung.trim(),
kategorie: kategorie || null,
user_id: userId?.id || null,
groesse: groesse || null,
seriennummer: seriennummer || null,
inventarnummer: inventarnummer || null,
anschaffung_datum: anschaffungDatum || null,
zustand,
notizen: notizen || null,
eigenschaften: eigenschaften
.filter(e => e.name.trim() && e.wert.trim())
.map(e => ({ eigenschaft_id: e.eigenschaft_id, name: e.name, wert: e.wert })),
};
updateMutation.mutate(payload);
};
const addEigenschaft = () => {
setEigenschaften(prev => [...prev, { name: '', wert: '' }]);
};
const updateEigenschaft = (idx: number, field: 'name' | 'wert', value: string) => {
setEigenschaften(prev => prev.map((e, i) => i === idx ? { ...e, [field]: value } : e));
};
const removeEigenschaft = (idx: number) => {
setEigenschaften(prev => prev.filter((_, i) => i !== idx));
};
if (isLoading) {
return (
<DashboardLayout>
<Container maxWidth="sm">
<LinearProgress />
</Container>
</DashboardLayout>
);
}
if (isError || !item) {
return (
<DashboardLayout>
<Container maxWidth="sm">
<Typography color="error">Fehler beim Laden.</Typography>
</Container>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<Container maxWidth="sm">
<PageHeader
title="Ausrüstung bearbeiten"
backTo={`/persoenliche-ausruestung/${id}`}
/>
<Stack spacing={2}>
<TextField
label="Bezeichnung"
required
size="small"
value={bezeichnung}
onChange={(e) => setBezeichnung(e.target.value)}
/>
{canViewAll && (
<Autocomplete
options={memberOptions}
getOptionLabel={(o) => o.name}
value={userId}
onChange={(_e, v) => setUserId(v)}
renderInput={(params) => (
<TextField {...params} label="Benutzer" size="small" />
)}
size="small"
/>
)}
<TextField
label="Kategorie"
size="small"
value={kategorie}
onChange={(e) => setKategorie(e.target.value)}
/>
<TextField
label="Größe"
size="small"
value={groesse}
onChange={(e) => setGroesse(e.target.value)}
/>
<TextField
label="Seriennummer"
size="small"
value={seriennummer}
onChange={(e) => setSeriennummer(e.target.value)}
/>
<TextField
label="Inventarnummer"
size="small"
value={inventarnummer}
onChange={(e) => setInventarnummer(e.target.value)}
/>
<TextField
label="Anschaffungsdatum"
type="date"
size="small"
value={anschaffungDatum}
onChange={(e) => setAnschaffungDatum(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Zustand"
select
size="small"
value={zustand}
onChange={(e) => setZustand(e.target.value as PersoenlicheAusruestungZustand)}
>
{ZUSTAND_OPTIONS.map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
))}
</TextField>
<TextField
label="Notizen"
size="small"
multiline
rows={2}
value={notizen}
onChange={(e) => setNotizen(e.target.value)}
/>
{/* Eigenschaften */}
<Typography variant="subtitle2">Eigenschaften</Typography>
{eigenschaften.map((e, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
size="small"
label="Name"
value={e.name}
onChange={(ev) => updateEigenschaft(idx, 'name', ev.target.value)}
sx={{ flex: 1 }}
/>
<TextField
size="small"
label="Wert"
value={e.wert}
onChange={(ev) => updateEigenschaft(idx, 'wert', ev.target.value)}
sx={{ flex: 1 }}
/>
<IconButton size="small" onClick={() => removeEigenschaft(idx)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
))}
<Button size="small" startIcon={<AddIcon />} onClick={addEigenschaft}>
Eigenschaft hinzufügen
</Button>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1 }}>
<Button onClick={() => navigate(`/persoenliche-ausruestung/${id}`)}>
Abbrechen
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={updateMutation.isPending || !bezeichnung.trim()}
>
Speichern
</Button>
</Box>
</Stack>
</Container>
</DashboardLayout>
);
}