feat(persoenliche-ausruestung): show catalog category, remove size/date columns, make zustand admin-configurable

This commit is contained in:
Matthias Hochmeister
2026-04-16 08:19:38 +02:00
parent dac0b79b3b
commit 058ee721e8
14 changed files with 282 additions and 96 deletions

View File

@@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import personalEquipmentService from '../services/personalEquipment.service';
import settingsService from '../services/settings.service';
import logger from '../utils/logger';
const uuidString = z.string().regex(
@@ -13,7 +14,7 @@ const isoDate = z.string().regex(
'Erwartet ISO-Datum im Format YYYY-MM-DD',
);
const ZustandEnum = z.enum(['gut', 'beschaedigt', 'abgaengig', 'verloren']);
const ZustandEnum = z.string().min(1).max(50);
const EigenschaftInput = z.object({
eigenschaft_id: z.number().int().positive().nullable().optional(),
@@ -180,6 +181,42 @@ class PersonalEquipmentController {
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht gelöscht werden' });
}
}
async getZustandOptions(_req: Request, res: Response): Promise<void> {
try {
const setting = await settingsService.get('personal_equipment_zustand_options');
const options = Array.isArray(setting?.value) ? setting!.value : [
{ key: 'gut', label: 'Gut', color: 'success' },
{ key: 'beschaedigt', label: 'Beschädigt', color: 'warning' },
{ key: 'abgaengig', label: 'Abgängig', color: 'error' },
{ key: 'verloren', label: 'Verloren', color: 'default' },
];
res.status(200).json({ success: true, data: options });
} catch (error) {
logger.error('personalEquipment.getZustandOptions error', { error });
res.status(500).json({ success: false, message: 'Zustände konnten nicht geladen werden' });
}
}
async updateZustandOptions(req: Request, res: Response): Promise<void> {
try {
const schema = z.array(z.object({
key: z.string().min(1).max(50),
label: z.string().min(1).max(100),
color: z.enum(['success', 'warning', 'error', 'default', 'primary', 'secondary', 'info']),
})).min(1);
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten() });
return;
}
await settingsService.set('personal_equipment_zustand_options', parsed.data, req.user!.id);
res.status(200).json({ success: true, data: parsed.data });
} catch (error) {
logger.error('personalEquipment.updateZustandOptions error', { error });
res.status(500).json({ success: false, message: 'Zustände konnten nicht gespeichert werden' });
}
}
}
export default new PersonalEquipmentController();

View File

@@ -0,0 +1,17 @@
-- Migration: 091_personal_equipment_configurable_zustand
-- Makes the zustand field on persoenliche_ausruestung admin-configurable
-- by removing the hard-coded CHECK constraint and seeding default options
-- into app_settings.
-- 1. Drop the hard-coded CHECK constraint
ALTER TABLE persoenliche_ausruestung DROP CONSTRAINT IF EXISTS persoenliche_ausruestung_zustand_check;
-- 2. Seed default zustand options into app_settings
INSERT INTO app_settings (key, value, updated_by, updated_at)
VALUES (
'personal_equipment_zustand_options',
'[{"key":"gut","label":"Gut","color":"success"},{"key":"beschaedigt","label":"Beschädigt","color":"warning"},{"key":"abgaengig","label":"Abgängig","color":"error"},{"key":"verloren","label":"Verloren","color":"default"}]',
'system',
NOW()
)
ON CONFLICT (key) DO NOTHING;

View File

@@ -24,6 +24,10 @@ router.get('/user/:userId', authenticate, async (req, res, next) => {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
}, personalEquipmentController.getByUser.bind(personalEquipmentController));
// Zustand options — GET (all authenticated users), PUT (admin:write)
router.get('/zustand-options', authenticate, personalEquipmentController.getZustandOptions.bind(personalEquipmentController));
router.put('/zustand-options', authenticate, requirePermission('admin:write'), personalEquipmentController.updateZustandOptions.bind(personalEquipmentController));
// Single item
router.get('/:id', authenticate, personalEquipmentController.getById.bind(personalEquipmentController));

View File

@@ -40,10 +40,12 @@ interface UpdatePersonalEquipmentData {
const BASE_SELECT = `
SELECT pa.*,
COALESCE(u.given_name || ' ' || u.family_name, u.name) AS user_display_name,
aa.bezeichnung AS artikel_bezeichnung
aa.bezeichnung AS artikel_bezeichnung,
akk.name AS artikel_kategorie_name
FROM persoenliche_ausruestung pa
LEFT JOIN users u ON u.id = pa.user_id
LEFT JOIN ausruestung_artikel aa ON aa.id = pa.artikel_id
LEFT JOIN ausruestung_kategorien_katalog akk ON akk.id = aa.kategorie_id
WHERE pa.geloescht_am IS NULL
`;

View File

@@ -3,7 +3,7 @@ import CheckroomIcon from '@mui/icons-material/Checkroom';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { personalEquipmentApi } from '../../services/personalEquipment';
import { ZUSTAND_LABELS, ZUSTAND_COLORS } from '../../types/personalEquipment.types';
import type { ZustandOption } from '../../types/personalEquipment.types';
import { WidgetCard } from '../templates/WidgetCard';
import { ItemListSkeleton } from '../templates/SkeletonPresets';
@@ -17,6 +17,15 @@ function PersoenlicheAusruestungWidget() {
retry: 1,
});
const { data: zustandOptions = [] } = useQuery<ZustandOption[]>({
queryKey: ['persoenliche-ausruestung', 'zustand-options'],
queryFn: () => personalEquipmentApi.getZustandOptions(),
staleTime: 5 * 60 * 1000,
});
const getZustandLabel = (key: string) => zustandOptions.find(o => o.key === key)?.label ?? key;
const getZustandColor = (key: string) => zustandOptions.find(o => o.key === key)?.color ?? 'default';
const displayItems = (items ?? []).slice(0, 5);
return (
@@ -43,13 +52,13 @@ function PersoenlicheAusruestungWidget() {
<ListItem key={item.id} disablePadding sx={{ py: 0.5 }}>
<ListItemText
primary={item.bezeichnung}
secondary={[item.kategorie, item.groesse].filter(Boolean).join(' · ') || undefined}
secondary={item.artikel_kategorie_name ?? item.kategorie ?? undefined}
primaryTypographyProps={{ variant: 'body2', noWrap: true }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
<Chip
label={ZUSTAND_LABELS[item.zustand]}
color={ZUSTAND_COLORS[item.zustand]}
label={getZustandLabel(item.zustand)}
color={getZustandColor(item.zustand) as any}
size="small"
variant="outlined"
sx={{ ml: 1, flexShrink: 0 }}

View File

@@ -28,6 +28,7 @@ import {
ExpandMore,
PictureAsPdf as PdfIcon,
Settings as SettingsIcon,
Checkroom as CheckroomIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Navigate } from 'react-router-dom';
@@ -35,6 +36,8 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext';
import { settingsApi } from '../services/settings';
import { personalEquipmentApi } from '../services/personalEquipment';
import type { ZustandOption } from '../types/personalEquipment.types';
interface LinkCollection {
id: string;
@@ -219,6 +222,27 @@ function AdminSettings() {
}
};
// Zustand options state + query + mutation
const [zustandOptions, setZustandOptions] = useState<ZustandOption[]>([]);
const { data: zustandOptionsData } = useQuery<ZustandOption[]>({
queryKey: ['persoenliche-ausruestung', 'zustand-options'],
queryFn: () => personalEquipmentApi.getZustandOptions(),
enabled: canAccess,
});
useEffect(() => {
if (zustandOptionsData) setZustandOptions(zustandOptionsData);
}, [zustandOptionsData]);
const zustandMutation = useMutation({
mutationFn: (options: ZustandOption[]) => personalEquipmentApi.updateZustandOptions(options),
onSuccess: () => {
showSuccess('Zustandsoptionen gespeichert');
queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung', 'zustand-options'] });
},
onError: () => {
showError('Fehler beim Speichern der Zustandsoptionen');
},
});
if (!canAccess) {
return <Navigate to="/dashboard" replace />;
}
@@ -572,7 +596,98 @@ function AdminSettings() {
</CardContent>
</Card>
{/* Section 5: Info */}
{/* Section 5: Zustandsoptionen (Persönliche Ausrüstung) */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CheckroomIcon color="primary" sx={{ mr: 2 }} />
<Typography variant="h6">Zustandsoptionen Persönliche Ausrüstung</Typography>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Konfigurierbare Zustandswerte für die persönliche Ausrüstung. Schlüssel wird intern gespeichert, Label wird angezeigt.
</Typography>
<Stack spacing={2}>
{zustandOptions.map((opt, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<TextField
label="Schlüssel"
value={opt.key}
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, key: e.target.value } : o))
)
}
size="small"
sx={{ flex: 1 }}
/>
<TextField
label="Label"
value={opt.label}
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, label: e.target.value } : o))
)
}
size="small"
sx={{ flex: 1 }}
/>
<FormControl size="small" sx={{ minWidth: 130 }}>
<InputLabel>Farbe</InputLabel>
<Select
value={opt.color}
label="Farbe"
onChange={(e) =>
setZustandOptions((prev) =>
prev.map((o, i) => (i === idx ? { ...o, color: e.target.value } : o))
)
}
>
<MenuItem value="success">Grün</MenuItem>
<MenuItem value="warning">Gelb</MenuItem>
<MenuItem value="error">Rot</MenuItem>
<MenuItem value="default">Grau</MenuItem>
<MenuItem value="info">Blau</MenuItem>
<MenuItem value="primary">Primär</MenuItem>
</Select>
</FormControl>
<IconButton
color="error"
onClick={() =>
setZustandOptions((prev) => prev.filter((_, i) => i !== idx))
}
aria-label="Option entfernen"
size="small"
>
<Delete />
</IconButton>
</Box>
))}
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
startIcon={<Add />}
onClick={() =>
setZustandOptions((prev) => [...prev, { key: '', label: '', color: 'default' }])
}
variant="outlined"
size="small"
>
Option hinzufügen
</Button>
<Button
onClick={() => zustandMutation.mutate(zustandOptions)}
variant="contained"
size="small"
disabled={zustandMutation.isPending}
>
Speichern
</Button>
</Box>
</Stack>
</CardContent>
</Card>
{/* Section 6: Info */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>

View File

@@ -11,8 +11,7 @@ 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 { ZustandOption } from '../types/personalEquipment.types';
import type {
AusruestungAnfrageFormItem,
AusruestungEigenschaft,
@@ -97,6 +96,15 @@ export default function AusruestungsanfrageNeu() {
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
});
const { data: zustandOptions = [] } = useQuery<ZustandOption[]>({
queryKey: ['persoenliche-ausruestung', 'zustand-options'],
queryFn: () => personalEquipmentApi.getZustandOptions(),
staleTime: 5 * 60 * 1000,
});
const getZustandLabel = (key: string) => zustandOptions.find(o => o.key === key)?.label ?? key;
const getZustandColor = (key: string) => zustandOptions.find(o => o.key === key)?.color ?? 'default';
const { data: orderUsers = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'orderUsers'],
queryFn: () => ausruestungsanfrageApi.getOrderUsers(),
@@ -367,8 +375,8 @@ export default function AusruestungsanfrageNeu() {
</Box>
)}
<Chip
label={ZUSTAND_LABELS[item.zustand as PersoenlicheAusruestungZustand]}
color={ZUSTAND_COLORS[item.zustand as PersoenlicheAusruestungZustand]}
label={getZustandLabel(item.zustand)}
color={getZustandColor(item.zustand) as any}
size="small"
/>
{!!assignedSelections[item.id] && (
@@ -380,8 +388,8 @@ export default function AusruestungsanfrageNeu() {
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>
{zustandOptions.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>{opt.label}</MenuItem>
))}
</TextField>
)}

View File

@@ -44,8 +44,7 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { membersService } from '../services/members';
import { atemschutzApi } from '../services/atemschutz';
import { personalEquipmentApi } from '../services/personalEquipment';
import { ZUSTAND_LABELS, ZUSTAND_COLORS } from '../types/personalEquipment.types';
import type { PersoenlicheAusruestung } from '../types/personalEquipment.types';
import type { PersoenlicheAusruestung, ZustandOption } from '../types/personalEquipment.types';
import { toGermanDate, fromGermanDate } from '../utils/dateInput';
import {
MemberWithProfile,
@@ -233,6 +232,7 @@ function MitgliedDetail() {
// Personal equipment data
const [personalEquipment, setPersonalEquipment] = useState<PersoenlicheAusruestung[]>([]);
const [personalEquipmentLoading, setPersonalEquipmentLoading] = useState(false);
const [zustandOptions, setZustandOptions] = useState<ZustandOption[]>([]);
// FDISK-synced sub-section data
const [befoerderungen, setBefoerderungen] = useState<Befoerderung[]>([]);
@@ -291,6 +291,9 @@ function MitgliedDetail() {
.then(setPersonalEquipment)
.catch(() => setPersonalEquipment([]))
.finally(() => setPersonalEquipmentLoading(false));
personalEquipmentApi.getZustandOptions()
.then(setZustandOptions)
.catch(() => {});
}, [userId]);
// Load FDISK-synced sub-section data
@@ -976,8 +979,8 @@ function MitgliedDetail() {
)}
</Box>
<Chip
label={ZUSTAND_LABELS[item.zustand]}
color={ZUSTAND_COLORS[item.zustand]}
label={zustandOptions.find(o => o.key === item.zustand)?.label ?? item.zustand}
color={(zustandOptions.find(o => o.key === item.zustand)?.color ?? 'default') as any}
size="small"
variant="outlined"
/>

View File

@@ -23,13 +23,7 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { PageHeader } from '../components/templates';
import { KatalogTab } from '../components/shared/KatalogTab';
import {
ZUSTAND_LABELS,
ZUSTAND_COLORS,
} from '../types/personalEquipment.types';
import type { PersoenlicheAusruestungZustand } from '../types/personalEquipment.types';
const ZUSTAND_OPTIONS = Object.entries(ZUSTAND_LABELS) as [PersoenlicheAusruestungZustand, string][];
import type { ZustandOption } from '../types/personalEquipment.types';
function PersoenlicheAusruestungPage() {
const navigate = useNavigate();
@@ -52,6 +46,15 @@ function PersoenlicheAusruestungPage() {
staleTime: 2 * 60 * 1000,
});
const { data: zustandOptions = [] } = useQuery<ZustandOption[]>({
queryKey: ['persoenliche-ausruestung', 'zustand-options'],
queryFn: () => personalEquipmentApi.getZustandOptions(),
staleTime: 5 * 60 * 1000,
});
const getZustandLabel = (key: string) => zustandOptions.find(o => o.key === key)?.label ?? key;
const getZustandColor = (key: string) => zustandOptions.find(o => o.key === key)?.color ?? 'default';
const { data: membersList } = useQuery({
queryKey: ['members-list-compact'],
queryFn: () => membersService.getMembers({ pageSize: 500 }),
@@ -126,8 +129,8 @@ function PersoenlicheAusruestungPage() {
sx={{ minWidth: 140 }}
>
<MenuItem value="">Alle</MenuItem>
{ZUSTAND_OPTIONS.map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
{zustandOptions.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>{opt.label}</MenuItem>
))}
</TextField>
{canSeeAll && (
@@ -178,15 +181,13 @@ function PersoenlicheAusruestungPage() {
<th>Bezeichnung</th>
<th>Kategorie</th>
{canSeeAll && <th>Benutzer</th>}
<th>Größe</th>
<th>Zustand</th>
<th>Anschaffung</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={canSeeAll ? 6 : 5}>
<td colSpan={canSeeAll ? 4 : 3}>
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
Lade Daten
</Typography>
@@ -194,7 +195,7 @@ function PersoenlicheAusruestungPage() {
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={canSeeAll ? 6 : 5}>
<td colSpan={canSeeAll ? 4 : 3}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6 }}>
<CheckroomIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography color="text.secondary">
@@ -230,7 +231,7 @@ function PersoenlicheAusruestungPage() {
)}
</td>
<td>
<Typography variant="body2">{item.kategorie ?? '—'}</Typography>
<Typography variant="body2">{item.artikel_kategorie_name ?? item.kategorie ?? '—'}</Typography>
</td>
{canSeeAll && (
<td>
@@ -239,24 +240,14 @@ function PersoenlicheAusruestungPage() {
</Typography>
</td>
)}
<td>
<Typography variant="body2">{item.groesse ?? '—'}</Typography>
</td>
<td>
<Chip
label={ZUSTAND_LABELS[item.zustand]}
color={ZUSTAND_COLORS[item.zustand]}
label={getZustandLabel(item.zustand)}
color={getZustandColor(item.zustand) as any}
size="small"
variant="outlined"
/>
</td>
<td>
<Typography variant="body2">
{item.anschaffung_datum
? new Date(item.anschaffung_datum).toLocaleDateString('de-AT')
: '—'}
</Typography>
</td>
</tr>
))
)}

View File

@@ -12,8 +12,7 @@ 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';
import type { ZustandOption } from '../types/personalEquipment.types';
export default function PersoenlicheAusruestungDetail() {
const { id } = useParams<{ id: string }>();
@@ -30,6 +29,15 @@ export default function PersoenlicheAusruestungDetail() {
enabled: !!id,
});
const { data: zustandOptions = [] } = useQuery<ZustandOption[]>({
queryKey: ['persoenliche-ausruestung', 'zustand-options'],
queryFn: () => personalEquipmentApi.getZustandOptions(),
staleTime: 5 * 60 * 1000,
});
const getZustandLabel = (key: string) => zustandOptions.find(o => o.key === key)?.label ?? key;
const getZustandColor = (key: string) => zustandOptions.find(o => o.key === key)?.color ?? 'default';
const canEdit = hasPermission('persoenliche_ausruestung:edit');
const canDelete = hasPermission('persoenliche_ausruestung:delete');
@@ -61,14 +69,14 @@ export default function PersoenlicheAusruestungDetail() {
<PageHeader
title={item.bezeichnung}
backTo="/persoenliche-ausruestung"
subtitle={item.kategorie || undefined}
subtitle={item.artikel_kategorie_name || 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]}
label={getZustandLabel(item.zustand)}
color={getZustandColor(item.zustand) as any}
variant="outlined"
/>
<Box sx={{ flex: 1 }} />
@@ -92,10 +100,8 @@ export default function PersoenlicheAusruestungDetail() {
<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}>

View File

@@ -21,14 +21,11 @@ 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,
ZustandOption,
UpdatePersoenlicheAusruestungPayload,
} from '../types/personalEquipment.types';
const ZUSTAND_OPTIONS = Object.entries(ZUSTAND_LABELS) as [PersoenlicheAusruestungZustand, string][];
interface EigenschaftRow {
id?: number;
eigenschaft_id?: number | null;
@@ -72,14 +69,19 @@ export default function PersoenlicheAusruestungEdit() {
staleTime: 5 * 60 * 1000,
});
const { data: zustandOptions = [] } = useQuery<ZustandOption[]>({
queryKey: ['persoenliche-ausruestung', 'zustand-options'],
queryFn: () => personalEquipmentApi.getZustandOptions(),
staleTime: 5 * 60 * 1000,
});
const [catalogEigenschaftValues, setCatalogEigenschaftValues] = useState<Record<number, string>>({});
// Form state
const [bezeichnung, setBezeichnung] = useState('');
const [seriennummer, setSeriennummer] = useState('');
const [inventarnummer, setInventarnummer] = useState('');
const [anschaffungDatum, setAnschaffungDatum] = useState('');
const [zustand, setZustand] = useState<PersoenlicheAusruestungZustand>('gut');
const [zustand, setZustand] = useState('gut');
const [notizen, setNotizen] = useState('');
const [userId, setUserId] = useState<{ id: string; name: string } | null>(null);
const [eigenschaften, setEigenschaften] = useState<EigenschaftRow[]>([]);
@@ -90,7 +92,6 @@ export default function PersoenlicheAusruestungEdit() {
setBezeichnung(item.bezeichnung);
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) {
@@ -139,7 +140,6 @@ export default function PersoenlicheAusruestungEdit() {
groesse: null,
seriennummer: seriennummer || null,
inventarnummer: inventarnummer || null,
anschaffung_datum: anschaffungDatum || null,
zustand,
notizen: notizen || null,
eigenschaften: item.artikel_id
@@ -231,24 +231,15 @@ export default function PersoenlicheAusruestungEdit() {
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)}
onChange={(e) => setZustand(e.target.value)}
>
{ZUSTAND_OPTIONS.map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
{zustandOptions.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>{opt.label}</MenuItem>
))}
</TextField>

View File

@@ -17,15 +17,12 @@ 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,
ZustandOption,
CreatePersoenlicheAusruestungPayload,
} from '../types/personalEquipment.types';
import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types';
const ZUSTAND_OPTIONS = Object.entries(ZUSTAND_LABELS) as [PersoenlicheAusruestungZustand, string][];
export default function PersoenlicheAusruestungNeu() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -37,7 +34,7 @@ export default function PersoenlicheAusruestungNeu() {
const [formArtikel, setFormArtikel] = useState<AusruestungArtikel | null>(null);
const [formUserId, setFormUserId] = useState<{ id: string; name: string } | null>(null);
const [formBenutzerName, setFormBenutzerName] = useState('');
const [formZustand, setFormZustand] = useState<PersoenlicheAusruestungZustand>('gut');
const [formZustand, setFormZustand] = useState('gut');
const [formNotizen, setFormNotizen] = useState('');
const [eigenschaftValues, setEigenschaftValues] = useState<Record<number, string>>({});
@@ -47,6 +44,12 @@ export default function PersoenlicheAusruestungNeu() {
staleTime: 10 * 60 * 1000,
});
const { data: zustandOptions = [] } = useQuery<ZustandOption[]>({
queryKey: ['persoenliche-ausruestung', 'zustand-options'],
queryFn: () => personalEquipmentApi.getZustandOptions(),
staleTime: 5 * 60 * 1000,
});
const { data: artikelEigenschaften = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'eigenschaften', formArtikel?.id],
queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(formArtikel!.id),
@@ -169,10 +172,10 @@ export default function PersoenlicheAusruestungNeu() {
select
size="small"
value={formZustand}
onChange={(e) => setFormZustand(e.target.value as PersoenlicheAusruestungZustand)}
onChange={(e) => setFormZustand(e.target.value)}
>
{ZUSTAND_OPTIONS.map(([key, label]) => (
<MenuItem key={key} value={key}>{label}</MenuItem>
{zustandOptions.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>{opt.label}</MenuItem>
))}
</TextField>

View File

@@ -3,6 +3,7 @@ import type {
PersoenlicheAusruestung,
CreatePersoenlicheAusruestungPayload,
UpdatePersoenlicheAusruestungPayload,
ZustandOption,
} from '../types/personalEquipment.types';
async function unwrap<T>(
@@ -61,4 +62,12 @@ export const personalEquipmentApi = {
async delete(id: string): Promise<void> {
await api.delete(`/api/persoenliche-ausruestung/${id}`);
},
async getZustandOptions(): Promise<ZustandOption[]> {
return unwrap(api.get('/api/persoenliche-ausruestung/zustand-options'));
},
async updateZustandOptions(options: ZustandOption[]): Promise<ZustandOption[]> {
return unwrap(api.put('/api/persoenliche-ausruestung/zustand-options', { options }));
},
};

View File

@@ -1,20 +1,10 @@
// Personal Equipment (Persönliche Ausrüstung) — Frontend Types
export type PersoenlicheAusruestungZustand = 'gut' | 'beschaedigt' | 'abgaengig' | 'verloren';
export const ZUSTAND_LABELS: Record<PersoenlicheAusruestungZustand, string> = {
gut: 'Gut',
beschaedigt: 'Beschädigt',
abgaengig: 'Abgängig',
verloren: 'Verloren',
};
export const ZUSTAND_COLORS: Record<PersoenlicheAusruestungZustand, 'success' | 'warning' | 'error' | 'default'> = {
gut: 'success',
beschaedigt: 'warning',
abgaengig: 'error',
verloren: 'default',
};
export interface ZustandOption {
key: string;
label: string;
color: string;
}
export interface PersoenlicheAusruestung {
id: string;
@@ -22,6 +12,7 @@ export interface PersoenlicheAusruestung {
kategorie?: string;
artikel_id?: number;
artikel_bezeichnung?: string;
artikel_kategorie_name?: string;
user_id?: string;
user_display_name?: string;
benutzer_name?: string;
@@ -29,7 +20,7 @@ export interface PersoenlicheAusruestung {
seriennummer?: string;
inventarnummer?: string;
anschaffung_datum?: string;
zustand: PersoenlicheAusruestungZustand;
zustand: string;
notizen?: string;
anfrage_id?: number;
anfrage_position_id?: number;
@@ -48,7 +39,7 @@ export interface CreatePersoenlicheAusruestungPayload {
seriennummer?: string;
inventarnummer?: string;
anschaffung_datum?: string;
zustand?: PersoenlicheAusruestungZustand;
zustand?: string;
notizen?: string;
eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[];
}
@@ -63,7 +54,7 @@ export interface UpdatePersoenlicheAusruestungPayload {
seriennummer?: string | null;
inventarnummer?: string | null;
anschaffung_datum?: string | null;
zustand?: PersoenlicheAusruestungZustand;
zustand?: string;
notizen?: string | null;
eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[] | null;
}