feat(persoenliche-ausruestung): show catalog category, remove size/date columns, make zustand admin-configurable
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user