feat(persoenliche-ausruestung): add quantity field and article-grouped replacement flow in order dialog

This commit is contained in:
Matthias Hochmeister
2026-04-24 12:36:28 +02:00
parent 9410441ce2
commit 4ec719ad0a
15 changed files with 263 additions and 96 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import {
Box, Typography, Paper, Button, TextField, IconButton,
Autocomplete, Divider, MenuItem, Checkbox, Chip, FormControlLabel, Switch,
@@ -11,7 +11,6 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { personalEquipmentApi } from '../services/personalEquipment';
import type { ZustandOption } from '../types/personalEquipment.types';
import type {
AusruestungAnfrageFormItem,
AusruestungEigenschaft,
@@ -80,7 +79,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>>({});
const [replacementSelections, setReplacementSelections] = useState<Record<number, number>>({});
const [catalogIstErsatz, setCatalogIstErsatz] = useState<Record<number, boolean>>({});
const [freeIstErsatz, setFreeIstErsatz] = useState<Record<number, boolean>>({});
@@ -96,15 +95,6 @@ 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(),
@@ -121,19 +111,48 @@ export default function AusruestungsanfrageNeu() {
staleTime: 2 * 60 * 1000,
});
// Clear assigned selections when switching user
// Clear replacement selections when switching user
const prevTargetUserRef = useRef(targetUserId);
useEffect(() => {
if (prevTargetUserRef.current !== targetUserId) {
setAssignedSelections({});
setReplacementSelections({});
prevTargetUserRef.current = targetUserId;
}
}, [targetUserId]);
// Group personal items by artikel_id (skip items without artikel_id)
const personalItemGroups = useMemo(() => {
const groups = new Map<number, {
bezeichnung: string;
totalMenge: number;
eigenschaften: { name: string; wert: string }[];
}>();
for (const item of myPersonalItems) {
if (!item.artikel_id) continue;
const itemMenge = item.menge;
const existing = groups.get(item.artikel_id);
if (existing) {
existing.totalMenge += itemMenge;
for (const e of item.eigenschaften ?? []) {
if (!existing.eigenschaften.some(x => x.name === e.name && x.wert === e.wert)) {
existing.eigenschaften.push({ name: e.name, wert: e.wert });
}
}
} else {
groups.set(item.artikel_id, {
bezeichnung: item.artikel_bezeichnung || item.bezeichnung,
totalMenge: itemMenge,
eigenschaften: (item.eigenschaften ?? []).map(e => ({ name: e.name, wert: e.wert })),
});
}
}
return groups;
}, [myPersonalItems]);
// ── Mutations ──
const createMut = useMutation({
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),
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),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
showSuccess('Anfrage erstellt');
@@ -171,7 +190,21 @@ export default function AusruestungsanfrageNeu() {
.filter(i => i.bezeichnung.trim())
.map((i, idx) => ({ bezeichnung: i.bezeichnung, menge: i.menge, ist_ersatz: freeIstErsatz[idx] || false }));
const allItems = [...catalogValidItems, ...freeValidItems];
// Replacement items (from grouped personal items)
const replacementItems: AusruestungAnfrageFormItem[] = Object.entries(replacementSelections)
.filter(([, menge]) => menge > 0)
.map(([artikelIdStr, menge]) => {
const artikelId = Number(artikelIdStr);
const group = personalItemGroups.get(artikelId);
return {
artikel_id: artikelId,
bezeichnung: group?.bezeichnung ?? '',
menge,
ist_ersatz: true,
};
});
const allItems = [...catalogValidItems, ...freeValidItems, ...replacementItems];
if (allItems.length === 0) return;
// Check required eigenschaften for catalog items
@@ -194,13 +227,10 @@ 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()) || Object.keys(assignedSelections).length > 0;
const hasValidItems = catalogItems.some(i => !!i.artikel_id) || freeItems.some(i => i.bezeichnung.trim()) || Object.values(replacementSelections).some(m => m > 0);
return (
<DashboardLayout>
@@ -347,55 +377,58 @@ export default function AusruestungsanfrageNeu() {
</Button>
<Divider />
<Typography variant="subtitle2">Zugewiesene Gegenstände</Typography>
{myPersonalItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine zugewiesenen Gegenstände vorhanden.</Typography>
<Typography variant="subtitle2">Zugewiesene Gegenstände (für Ersatz)</Typography>
{personalItemGroups.size === 0 ? (
<Typography variant="body2" color="text.secondary">
{targetUserId || !canOrderForUser ? 'Keine zugewiesenen Gegenstände vorhanden.' : 'Wähle einen Benutzer, um zugewiesene Gegenstände zu sehen.'}
</Typography>
) : (
myPersonalItems.map((item) => (
<Box key={item.id}>
<Box 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 }));
if (item.artikel_id) loadEigenschaften(item.artikel_id);
} else {
setAssignedSelections(prev => { const n = { ...prev }; delete n[item.id]; return n; });
}
}}
/>
<Typography variant="body2" sx={{ flex: 1 }}>{item.bezeichnung}</Typography>
{item.eigenschaften && item.eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{item.eigenschaften.map(e => (
<Chip key={e.id} label={`${e.name}: ${e.wert}`} size="small" variant="outlined" />
))}
</Box>
)}
<Chip
label={getZustandLabel(item.zustand)}
color={getZustandColor(item.zustand) as any}
size="small"
/>
{!!assignedSelections[item.id] && (
<TextField
select
Array.from(personalItemGroups.entries()).map(([artikelId, group]) => {
const selectedMenge = replacementSelections[artikelId] ?? 0;
const checked = selectedMenge > 0;
return (
<Box key={artikelId} sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', py: 0.5 }}>
<Checkbox
size="small"
label="Neuer Status"
sx={{ minWidth: 150 }}
value={assignedSelections[item.id]}
onChange={(e) => setAssignedSelections(prev => ({ ...prev, [item.id]: e.target.value }))}
>
{zustandOptions.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>{opt.label}</MenuItem>
))}
</TextField>
)}
checked={checked}
onChange={(e) => {
if (e.target.checked) {
setReplacementSelections(prev => ({ ...prev, [artikelId]: 1 }));
} else {
setReplacementSelections(prev => {
const n = { ...prev };
delete n[artikelId];
return n;
});
}
}}
/>
<Typography variant="body2" sx={{ flex: 1, minWidth: 160 }}>{group.bezeichnung}</Typography>
<Chip label={`${group.totalMenge} Stk. zugewiesen`} size="small" variant="outlined" />
{group.eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{group.eigenschaften.map((e, i) => (
<Chip key={`${e.name}-${e.wert}-${i}`} label={`${e.name}: ${e.wert}`} size="small" variant="outlined" />
))}
</Box>
)}
{checked && (
<TextField
size="small"
type="number"
label="Menge ersetzen"
value={selectedMenge}
onChange={(e) => {
const v = Math.max(1, Math.min(group.totalMenge, Number(e.target.value) || 1));
setReplacementSelections(prev => ({ ...prev, [artikelId]: v }));
}}
inputProps={{ min: 1, max: group.totalMenge }}
sx={{ width: 130 }}
/>
)}
</Box>
</Box>
))
);
})
)}
<Divider />

View File

@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
import {
Box, Typography, Container, Button, Chip,
TextField, Autocomplete, ToggleButton, ToggleButtonGroup,
Stack, Divider, LinearProgress, MenuItem,
Stack, Divider, LinearProgress, MenuItem, Checkbox, FormControlLabel,
} from '@mui/material';
import { Assignment as AssignmentIcon } from '@mui/icons-material';
import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -16,6 +16,7 @@ import { personalEquipmentApi } from '../services/personalEquipment';
import { vehiclesApi } from '../services/vehicles';
import { membersService } from '../services/members';
import type { AusruestungAnfragePosition, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
import type { ZustandOption } from '../types/personalEquipment.types';
type AssignmentTyp = 'ausruestung' | 'persoenlich' | 'keine';
@@ -130,13 +131,34 @@ export default function AusruestungsanfrageZuweisung() {
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 [submitting, setSubmitting] = useState(false);
const [replacements, setReplacements] = useState<Record<number, string[]>>({});
const updateAssignment = (posId: number, patch: Partial<PositionAssignment>) => {
setAssignments((prev) => ({
...prev,
[posId]: { ...prev[posId], ...patch },
}));
setAssignments((prev) => {
const prevA = prev[posId];
// If userId changes, clear replacements for this position
if (patch.userId !== undefined && prevA?.userId !== patch.userId) {
setReplacements((r) => {
const n = { ...r };
delete n[posId];
return n;
});
}
return {
...prev,
[posId]: { ...prevA, ...patch },
};
});
};
const handleSkipAll = () => {
@@ -190,6 +212,7 @@ export default function AusruestungsanfrageZuweisung() {
wert,
}))
: undefined,
replacedItemIds: pos?.ist_ersatz ? (replacements[Number(posId)] ?? []) : undefined,
};
});
await ausruestungsanfrageApi.assignItems(anfrageId, payload);
@@ -369,6 +392,65 @@ export default function AusruestungsanfrageZuweisung() {
/>
)
)}
{pos.ist_ersatz && a.userId && pos.artikel_id && (() => {
const oldItems = allPersonalItems.filter(
i => i.artikel_id === pos.artikel_id && i.user_id === a.userId,
);
if (oldItems.length === 0) return null;
const selectedIds = replacements[pos.id] ?? [];
const selectedTotal = oldItems
.filter(i => selectedIds.includes(i.id))
.reduce((sum, i) => sum + i.menge, 0);
const limitReached = selectedTotal >= pos.menge;
return (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Alte Gegenstände ersetzen ({selectedTotal} / {pos.menge})
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{oldItems.map(item => {
const isChecked = selectedIds.includes(item.id);
const itemMenge = item.menge;
return (
<FormControlLabel
key={item.id}
control={
<Checkbox
size="small"
checked={isChecked}
disabled={!isChecked && limitReached}
onChange={(ev) => {
setReplacements(prev => {
const cur = prev[pos.id] ?? [];
const next = ev.target.checked
? [...cur, item.id]
: cur.filter(x => x !== item.id);
return { ...prev, [pos.id]: next };
});
}}
/>
}
label={
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2">{item.bezeichnung}</Typography>
<Chip label={`${itemMenge} Stk.`} size="small" variant="outlined" />
{(item.eigenschaften ?? []).map(e => (
<Chip key={e.id} label={`${e.name}: ${e.wert}`} size="small" variant="outlined" />
))}
<Chip
label={getZustandLabel(item.zustand)}
color={getZustandColor(item.zustand) as any}
size="small"
/>
</Box>
}
/>
);
})}
</Box>
</Box>
);
})()}
</Box>
)}
</Box>

View File

@@ -882,7 +882,12 @@ function MitgliedDetail() {
onClick={() => navigate(`/persoenliche-ausruestung/${item.id}`)}
>
<Box>
<Typography variant="body2" fontWeight={500}>{item.bezeichnung}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" fontWeight={500}>{item.bezeichnung}</Typography>
{item.menge > 1 && (
<Chip label={`${item.menge}x`} size="small" sx={{ ml: 0.5, height: 18, fontSize: '0.65rem' }} />
)}
</Box>
{item.kategorie && (
<Typography variant="caption" color="text.secondary">{item.kategorie}</Typography>
)}

View File

@@ -181,6 +181,7 @@ function PersoenlicheAusruestungPage() {
<tr>
<th>Bezeichnung</th>
<th>Kategorie</th>
<th>Menge</th>
{canSeeAll && <th>Benutzer</th>}
<th>Zustand</th>
</tr>
@@ -188,7 +189,7 @@ function PersoenlicheAusruestungPage() {
<tbody>
{isLoading ? (
<tr>
<td colSpan={canSeeAll ? 4 : 3}>
<td colSpan={canSeeAll ? 5 : 4}>
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
Lade Daten
</Typography>
@@ -196,7 +197,7 @@ function PersoenlicheAusruestungPage() {
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={canSeeAll ? 4 : 3}>
<td colSpan={canSeeAll ? 5 : 4}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6 }}>
<CheckroomIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography color="text.secondary">
@@ -236,6 +237,9 @@ function PersoenlicheAusruestungPage() {
? `${item.artikel_kategorie_parent_name} > ${item.artikel_kategorie_name}`
: item.artikel_kategorie_name ?? item.kategorie ?? '—'}</Typography>
</td>
<td>
{item.menge > 1 && <Chip label={`${item.menge}x`} size="small" variant="outlined" />}
</td>
{canSeeAll && (
<td>
<Typography variant="body2">

View File

@@ -102,6 +102,7 @@ export default function PersoenlicheAusruestungDetail() {
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
{([
['Benutzer', item.user_display_name || item.benutzer_name],
['Menge', item.menge > 1 ? String(item.menge) : null],
['Seriennummer', item.seriennummer],
['Inventarnummer', item.inventarnummer],
['Erstellt am', new Date(item.erstellt_am).toLocaleDateString('de-AT')],

View File

@@ -82,6 +82,7 @@ export default function PersoenlicheAusruestungEdit() {
const [seriennummer, setSeriennummer] = useState('');
const [inventarnummer, setInventarnummer] = useState('');
const [zustand, setZustand] = useState('gut');
const [menge, setMenge] = useState(1);
const [notizen, setNotizen] = useState('');
const [userId, setUserId] = useState<{ id: string; name: string } | null>(null);
const [eigenschaften, setEigenschaften] = useState<EigenschaftRow[]>([]);
@@ -93,6 +94,7 @@ export default function PersoenlicheAusruestungEdit() {
setSeriennummer(item.seriennummer ?? '');
setInventarnummer(item.inventarnummer ?? '');
setZustand(item.zustand);
setMenge(item.menge ?? 1);
setNotizen(item.notizen ?? '');
if (item.eigenschaften) {
setEigenschaften(item.eigenschaften.map(e => ({
@@ -141,6 +143,7 @@ export default function PersoenlicheAusruestungEdit() {
seriennummer: seriennummer || null,
inventarnummer: inventarnummer || null,
zustand,
menge,
notizen: notizen || null,
eigenschaften: item.artikel_id
? Object.entries(catalogEigenschaftValues)
@@ -231,6 +234,16 @@ export default function PersoenlicheAusruestungEdit() {
onChange={(e) => setInventarnummer(e.target.value)}
/>
<TextField
label="Menge"
type="number"
size="small"
value={menge}
onChange={(e) => setMenge(Math.max(1, parseInt(e.target.value, 10) || 1))}
inputProps={{ min: 1 }}
sx={{ width: 120 }}
/>
<TextField
label="Zustand"
select

View File

@@ -35,6 +35,7 @@ export default function PersoenlicheAusruestungNeu() {
const [formUserId, setFormUserId] = useState<{ id: string; name: string } | null>(null);
const [formBenutzerName, setFormBenutzerName] = useState('');
const [formZustand, setFormZustand] = useState('gut');
const [formMenge, setFormMenge] = useState(1);
const [formNotizen, setFormNotizen] = useState('');
const [eigenschaftValues, setEigenschaftValues] = useState<Record<number, string>>({});
@@ -93,6 +94,7 @@ export default function PersoenlicheAusruestungNeu() {
user_id: formUserId?.id || undefined,
benutzer_name: formBenutzerName || undefined,
zustand: formZustand,
menge: formMenge,
notizen: formNotizen || undefined,
eigenschaften: Object.entries(eigenschaftValues)
.filter(([, v]) => v.trim())
@@ -179,6 +181,16 @@ export default function PersoenlicheAusruestungNeu() {
))}
</TextField>
<TextField
label="Menge"
type="number"
size="small"
value={formMenge}
onChange={(e) => setFormMenge(Math.max(1, parseInt(e.target.value, 10) || 1))}
inputProps={{ min: 1 }}
sx={{ width: 120 }}
/>
<TextField
label="Notizen"
size="small"