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

@@ -1,7 +1,7 @@
import { useState, useCallback, useRef } from 'react';
import {
Box, Typography, Paper, Button, TextField, IconButton,
Autocomplete, Divider, MenuItem,
Autocomplete, Divider, MenuItem, Checkbox, Chip,
} from '@mui/material';
import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -10,6 +10,9 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { personalEquipmentApi } from '../services/personalEquipment';
import { ZUSTAND_LABELS, ZUSTAND_COLORS } from '../types/personalEquipment.types';
import type { PersoenlicheAusruestungZustand } from '../types/personalEquipment.types';
import type {
AusruestungAnfrageFormItem,
AusruestungEigenschaft,
@@ -78,6 +81,7 @@ export default function AusruestungsanfrageNeu() {
const [fuerBenutzer, setFuerBenutzer] = useState<{ id: string; name: string } | string | null>(null);
const [catalogItems, setCatalogItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]);
const [assignedSelections, setAssignedSelections] = useState<Record<string, string>>({});
// Eigenschaften state
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
@@ -97,10 +101,16 @@ export default function AusruestungsanfrageNeu() {
enabled: canOrderForUser,
});
const { data: myPersonalItems = [] } = useQuery({
queryKey: ['persoenliche-ausruestung', 'my-for-request'],
queryFn: () => personalEquipmentApi.getMy(),
staleTime: 2 * 60 * 1000,
});
// ── Mutations ──
const createMut = useMutation({
mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; fuer_benutzer_name?: string }) =>
ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name),
mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; fuer_benutzer_name?: string; assignedItems?: { persoenlich_id: string; neuer_zustand: string }[] }) =>
ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name, args.assignedItems),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
showSuccess('Anfrage erstellt');
@@ -161,10 +171,13 @@ export default function AusruestungsanfrageNeu() {
bezeichnung: bezeichnung || undefined,
fuer_benutzer_id: typeof fuerBenutzer === 'object' && fuerBenutzer ? fuerBenutzer.id : undefined,
fuer_benutzer_name: typeof fuerBenutzer === 'string' ? fuerBenutzer : undefined,
assignedItems: Object.entries(assignedSelections)
.filter(([, v]) => !!v)
.map(([id, neuer_zustand]) => ({ persoenlich_id: id, neuer_zustand })),
});
};
const hasValidItems = catalogItems.some(i => !!i.artikel_id) || freeItems.some(i => i.bezeichnung.trim());
const hasValidItems = catalogItems.some(i => !!i.artikel_id) || freeItems.some(i => i.bezeichnung.trim()) || Object.keys(assignedSelections).length > 0;
return (
<DashboardLayout>
@@ -300,6 +313,48 @@ export default function AusruestungsanfrageNeu() {
Freitext-Position hinzufügen
</Button>
<Divider />
<Typography variant="subtitle2">Zugewiesene Gegenstände</Typography>
{myPersonalItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine zugewiesenen Gegenstände vorhanden.</Typography>
) : (
myPersonalItems.map((item) => (
<Box key={item.id} sx={{ display: 'flex', gap: 1, alignItems: 'center', py: 0.5 }}>
<Checkbox
size="small"
checked={!!assignedSelections[item.id]}
onChange={(e) => {
if (e.target.checked) {
setAssignedSelections(prev => ({ ...prev, [item.id]: item.zustand }));
} else {
setAssignedSelections(prev => { const n = { ...prev }; delete n[item.id]; return n; });
}
}}
/>
<Typography variant="body2" sx={{ flex: 1 }}>{item.bezeichnung}</Typography>
<Chip
label={ZUSTAND_LABELS[item.zustand as PersoenlicheAusruestungZustand]}
color={ZUSTAND_COLORS[item.zustand as PersoenlicheAusruestungZustand]}
size="small"
/>
{!!assignedSelections[item.id] && (
<TextField
select
size="small"
label="Neuer Status"
sx={{ minWidth: 150 }}
value={assignedSelections[item.id]}
onChange={(e) => setAssignedSelections(prev => ({ ...prev, [item.id]: e.target.value }))}
>
{Object.entries(ZUSTAND_LABELS).map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
))}
</TextField>
)}
</Box>
))
)}
<Divider />
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button onClick={() => navigate('/ausruestungsanfrage')}>Abbrechen</Button>

View File

@@ -5,11 +5,12 @@ import {
Stack, Divider, LinearProgress,
} from '@mui/material';
import { Assignment as AssignmentIcon } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageHeader } from '../components/templates';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { vehiclesApi } from '../services/vehicles';
import { membersService } from '../services/members';
@@ -36,6 +37,13 @@ export default function AusruestungsanfrageZuweisung() {
const navigate = useNavigate();
const { showSuccess, showError } = useNotification();
const anfrageId = Number(id);
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const canManageCatalog = hasPermission('ausruestungsanfrage:manage_catalog');
const [createArtikelFor, setCreateArtikelFor] = useState<number | null>(null);
const [newArtikelBezeichnung, setNewArtikelBezeichnung] = useState('');
const [newArtikelSubmitting, setNewArtikelSubmitting] = useState(false);
const { data: detail, isLoading, isError } = useQuery({
queryKey: ['ausruestungsanfrage', 'request', anfrageId],
@@ -101,6 +109,22 @@ export default function AusruestungsanfrageZuweisung() {
setAssignments(updated);
};
const handleCreateArtikel = async (posId: number) => {
if (!newArtikelBezeichnung.trim()) return;
setNewArtikelSubmitting(true);
try {
const newArtikel = await ausruestungsanfrageApi.createItem({ bezeichnung: newArtikelBezeichnung.trim(), aktiv: true });
await ausruestungsanfrageApi.linkPositionToArtikel(posId, newArtikel.id);
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'request', anfrageId] });
setCreateArtikelFor(null);
showSuccess('Katalogartikel erstellt und Position verknüpft');
} catch {
showError('Fehler beim Erstellen des Katalogartikels');
} finally {
setNewArtikelSubmitting(false);
}
};
const handleSubmit = async () => {
if (!detail) return;
setSubmitting(true);
@@ -161,12 +185,55 @@ export default function AusruestungsanfrageZuweisung() {
<Chip label={`${pos.menge}x`} size="small" variant="outlined" />
</Box>
{!pos.artikel_id && (
<Box sx={{ mb: 1 }}>
<Chip label="Nicht im Katalog" color="warning" size="small" sx={{ mb: 1 }} />
{canManageCatalog && createArtikelFor !== pos.id && (
<Button
size="small"
variant="outlined"
color="warning"
sx={{ ml: 1 }}
onClick={() => { setCreateArtikelFor(pos.id); setNewArtikelBezeichnung(pos.bezeichnung); }}
>
Als Katalogartikel anlegen
</Button>
)}
{createArtikelFor === pos.id && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}>
<TextField
size="small"
label="Bezeichnung"
value={newArtikelBezeichnung}
onChange={(e) => setNewArtikelBezeichnung(e.target.value)}
sx={{ flex: 1 }}
/>
<Button
size="small"
variant="contained"
disabled={newArtikelSubmitting || !newArtikelBezeichnung.trim()}
onClick={() => handleCreateArtikel(pos.id)}
>
Erstellen
</Button>
<Button
size="small"
onClick={() => setCreateArtikelFor(null)}
>
Abbrechen
</Button>
</Box>
)}
</Box>
)}
<ToggleButtonGroup
value={a.typ}
exclusive
size="small"
onChange={(_e, val) => val && updateAssignment(pos.id, { typ: val })}
sx={{ mb: 1.5 }}
disabled={!pos.artikel_id}
>
<ToggleButton value="ausruestung">Ausrüstung</ToggleButton>
<ToggleButton value="persoenlich">Persönlich</ToggleButton>

View File

@@ -869,12 +869,29 @@ function MitgliedDetail() {
</Typography>
) : (
personalEquipment.map((item) => (
<Box key={item.id} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid', borderColor: 'divider' }}>
<Box
key={item.id}
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 0.75, borderBottom: '1px solid', borderColor: 'divider', cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
onClick={() => navigate(`/persoenliche-ausruestung/${item.id}`)}
>
<Box>
<Typography variant="body2" fontWeight={500}>{item.bezeichnung}</Typography>
{item.kategorie && (
<Typography variant="caption" color="text.secondary">{item.kategorie}</Typography>
)}
{item.eigenschaften && item.eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.25 }}>
{item.eigenschaften.map((e) => (
<Chip
key={e.id}
label={`${e.name}: ${e.wert}`}
size="small"
variant="outlined"
sx={{ height: 18, fontSize: '0.65rem' }}
/>
))}
</Box>
)}
</Box>
<Chip
label={ZUSTAND_LABELS[item.zustand]}

View File

@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
import {
Autocomplete,
Box,
Button,
Chip,
Container,
MenuItem,
@@ -16,6 +17,7 @@ import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { personalEquipmentApi } from '../services/personalEquipment';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { membersService } from '../services/members';
import { usePermissionContext } from '../contexts/PermissionContext';
import ChatAwareFab from '../components/shared/ChatAwareFab';
@@ -35,6 +37,7 @@ function PersoenlicheAusruestungPage() {
const canViewAll = hasPermission('persoenliche_ausruestung:view_all');
const canCreate = hasPermission('persoenliche_ausruestung:create');
const canApprove = hasPermission('ausruestungsanfrage:approve');
const [activeTab, setActiveTab] = useState(0);
const [filterZustand, setFilterZustand] = useState<string>('');
@@ -55,6 +58,13 @@ function PersoenlicheAusruestungPage() {
enabled: canViewAll,
});
const { data: unassignedPositions, isLoading: unassignedLoading } = useQuery({
queryKey: ['ausruestungsanfrage', 'nicht-zugewiesen'],
queryFn: () => ausruestungsanfrageApi.getUnassignedPositions(),
staleTime: 2 * 60 * 1000,
enabled: canApprove && activeTab === 2,
});
const memberOptions = useMemo(() => {
return (membersList?.items ?? []).map((m) => ({
id: m.id,
@@ -92,6 +102,7 @@ function PersoenlicheAusruestungPage() {
<Tabs value={activeTab} onChange={(_e, v) => setActiveTab(v)} sx={{ mb: 3 }}>
<Tab label="Zuweisungen" />
<Tab label="Katalog" />
{canApprove && <Tab label="Nicht Zugewiesen" />}
</Tabs>
{activeTab === 0 && (
@@ -193,7 +204,7 @@ function PersoenlicheAusruestungPage() {
</tr>
) : (
filtered.map((item) => (
<tr key={item.id}>
<tr key={item.id} onClick={() => navigate(`/persoenliche-ausruestung/${item.id}`)}>
<td>
<Typography variant="body2" fontWeight={500}>
{item.bezeichnung}
@@ -203,6 +214,19 @@ function PersoenlicheAusruestungPage() {
{item.artikel_bezeichnung}
</Typography>
)}
{item.eigenschaften && item.eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
{item.eigenschaften.map((e) => (
<Chip
key={e.id}
label={`${e.name}: ${e.wert}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
))}
</Box>
)}
</td>
<td>
<Typography variant="body2">{item.kategorie ?? '—'}</Typography>
@@ -242,6 +266,97 @@ function PersoenlicheAusruestungPage() {
)}
{activeTab === 1 && <KatalogTab />}
{activeTab === 2 && canApprove && (
<Box sx={{ overflowX: 'auto' }}>
<Box
component="table"
sx={{
width: '100%',
borderCollapse: 'collapse',
'& th, & td': {
textAlign: 'left',
py: 1.5,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
},
'& th': {
fontWeight: 600,
fontSize: '0.75rem',
textTransform: 'uppercase',
color: 'text.secondary',
letterSpacing: '0.06em',
},
'& tbody tr': {
'&:hover': { bgcolor: 'action.hover' },
},
}}
>
<thead>
<tr>
<th>Bezeichnung</th>
<th>Anfrage</th>
<th>Für wen</th>
<th>Im Katalog</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{unassignedLoading ? (
<tr>
<td colSpan={5}>
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
Lade Daten
</Typography>
</td>
</tr>
) : !unassignedPositions || unassignedPositions.length === 0 ? (
<tr>
<td colSpan={5}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6 }}>
<CheckroomIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography color="text.secondary">
Alle Positionen sind zugewiesen
</Typography>
</Box>
</td>
</tr>
) : (
unassignedPositions.map((pos) => (
<tr key={pos.id}>
<td>
<Typography variant="body2" fontWeight={500}>{pos.bezeichnung}</Typography>
<Chip label={`${pos.menge}x`} size="small" variant="outlined" sx={{ ml: 1, height: 20 }} />
</td>
<td>
<Typography
variant="body2"
component="span"
sx={{ cursor: 'pointer', color: 'primary.main', textDecoration: 'underline' }}
onClick={() => navigate(`/ausruestungsanfrage/${pos.anfrage_id}`)}
>
{pos.anfrage_bezeichnung || (pos.bestell_jahr && pos.bestell_nummer ? `${pos.bestell_jahr}/${String(pos.bestell_nummer).padStart(3, '0')}` : `#${pos.anfrage_id}`)}
</Typography>
</td>
<td>
<Typography variant="body2" fontWeight={500}>{pos.fuer_wen || '—'}</Typography>
</td>
<td>
<Chip label={pos.artikel_id ? 'Ja' : 'Nein'} color={pos.artikel_id ? 'success' : 'warning'} size="small" variant="outlined" />
</td>
<td>
<Button size="small" variant="outlined" onClick={() => navigate(`/ausruestungsanfrage/${pos.anfrage_id}/zuweisen`)}>
Zuweisen
</Button>
</td>
</tr>
))
)}
</tbody>
</Box>
</Box>
)}
</Container>
{/* FAB */}

View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import {
Box, Typography, Container, Chip, Button, Paper, Divider,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions,
LinearProgress,
} from '@mui/material';
import { Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageHeader } from '../components/templates';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { personalEquipmentApi } from '../services/personalEquipment';
import { ZUSTAND_LABELS, ZUSTAND_COLORS } from '../types/personalEquipment.types';
import type { PersoenlicheAusruestungZustand } from '../types/personalEquipment.types';
export default function PersoenlicheAusruestungDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { hasPermission } = usePermissionContext();
const { showSuccess, showError } = useNotification();
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const { data: item, isLoading, isError } = useQuery({
queryKey: ['persoenliche-ausruestung', 'detail', id],
queryFn: () => personalEquipmentApi.getById(id!),
enabled: !!id,
});
const canEdit = hasPermission('persoenliche_ausruestung:edit');
const canDelete = hasPermission('persoenliche_ausruestung:delete');
const handleDelete = async () => {
if (!id) return;
setDeleting(true);
try {
await personalEquipmentApi.delete(id);
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] });
showSuccess('Persönliche Ausrüstung gelöscht');
navigate('/persoenliche-ausruestung');
} catch {
showError('Fehler beim Löschen');
} finally {
setDeleting(false);
setDeleteOpen(false);
}
};
return (
<DashboardLayout>
<Container maxWidth="md">
{isLoading ? (
<LinearProgress />
) : isError || !item ? (
<Typography color="error">Fehler beim Laden.</Typography>
) : (
<>
<PageHeader
title={item.bezeichnung}
backTo="/persoenliche-ausruestung"
subtitle={item.kategorie || undefined}
/>
{/* Status + actions row */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 3 }}>
<Chip
label={ZUSTAND_LABELS[item.zustand as PersoenlicheAusruestungZustand]}
color={ZUSTAND_COLORS[item.zustand as PersoenlicheAusruestungZustand]}
variant="outlined"
/>
<Box sx={{ flex: 1 }} />
{canEdit && (
<Button startIcon={<EditIcon />} variant="outlined" size="small"
onClick={() => navigate(`/persoenliche-ausruestung/${id}/edit`)}>
Bearbeiten
</Button>
)}
{canDelete && (
<Button startIcon={<DeleteIcon />} variant="outlined" color="error" size="small"
onClick={() => setDeleteOpen(true)}>
Löschen
</Button>
)}
</Box>
{/* Info card */}
<Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>Details</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
{([
['Benutzer', item.user_display_name || item.benutzer_name],
['Größe', item.groesse],
['Seriennummer', item.seriennummer],
['Inventarnummer', item.inventarnummer],
['Anschaffungsdatum', item.anschaffung_datum ? new Date(item.anschaffung_datum).toLocaleDateString('de-AT') : null],
['Erstellt am', new Date(item.erstellt_am).toLocaleDateString('de-AT')],
] as [string, string | null | undefined][]).map(([label, value]) => value ? (
<Box key={label}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="body2">{value}</Typography>
</Box>
) : null)}
</Box>
{item.anfrage_id && (
<>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" color="text.secondary">Aus Anfrage</Typography>
<Typography
variant="body2"
sx={{ cursor: 'pointer', color: 'primary.main' }}
onClick={() => navigate(`/ausruestungsanfrage/${item.anfrage_id}`)}
>
Anfrage #{item.anfrage_id}
</Typography>
</>
)}
</Paper>
{/* Eigenschaften */}
{item.eigenschaften && item.eigenschaften.length > 0 && (
<Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>Eigenschaften</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{item.eigenschaften.map((e) => (
<Box key={e.id}>
<Typography variant="caption" color="text.secondary">{e.name}</Typography>
<Typography variant="body2" fontWeight={500}>{e.wert}</Typography>
</Box>
))}
</Box>
</Paper>
)}
{/* Notizen */}
{item.notizen && (
<Paper sx={{ p: 2.5 }}>
<Typography variant="subtitle2" gutterBottom>Notizen</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{item.notizen}</Typography>
</Paper>
)}
</>
)}
</Container>
{/* Delete confirmation */}
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
<DialogTitle>Persönliche Ausrüstung löschen?</DialogTitle>
<DialogContent>
<DialogContentText>
Dieser Eintrag wird dauerhaft gelöscht und kann nicht wiederhergestellt werden.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteOpen(false)}>Abbrechen</Button>
<Button onClick={handleDelete} color="error" disabled={deleting}>
Löschen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}

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>
);
}