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

@@ -573,6 +573,7 @@ class AusruestungsanfrageController {
groesse?: string; groesse?: string;
kategorie?: string; kategorie?: string;
eigenschaften?: Array<{ eigenschaft_id?: number; name: string; wert: string }>; eigenschaften?: Array<{ eigenschaft_id?: number; name: string; wert: string }>;
replacedItemIds?: string[];
}>; }>;
}; };

View File

@@ -34,6 +34,7 @@ const CreateSchema = z.object({
anschaffung_datum: isoDate.optional(), anschaffung_datum: isoDate.optional(),
zustand: ZustandEnum.optional(), zustand: ZustandEnum.optional(),
notizen: z.string().max(2000).optional(), notizen: z.string().max(2000).optional(),
menge: z.number().int().min(1).default(1).optional(),
eigenschaften: z.array(EigenschaftInput).optional(), eigenschaften: z.array(EigenschaftInput).optional(),
}); });
@@ -49,6 +50,7 @@ const UpdateSchema = z.object({
anschaffung_datum: isoDate.nullable().optional(), anschaffung_datum: isoDate.nullable().optional(),
zustand: ZustandEnum.optional(), zustand: ZustandEnum.optional(),
notizen: z.string().max(2000).nullable().optional(), notizen: z.string().max(2000).nullable().optional(),
menge: z.number().int().min(1).nullable().optional(),
eigenschaften: z.array(EigenschaftInput).nullable().optional(), eigenschaften: z.array(EigenschaftInput).nullable().optional(),
}); });

View File

@@ -0,0 +1 @@
ALTER TABLE persoenliche_ausruestung ADD COLUMN IF NOT EXISTS menge INT NOT NULL DEFAULT 1;

View File

@@ -1007,6 +1007,7 @@ interface AssignmentInput {
groesse?: string; groesse?: string;
kategorie?: string; kategorie?: string;
eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[]; eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[];
replacedItemIds?: string[];
} }
async function assignDeliveredItems( async function assignDeliveredItems(
@@ -1033,7 +1034,7 @@ async function assignDeliveredItems(
for (const a of assignments) { for (const a of assignments) {
// Load position details // Load position details
const posResult = await client.query( const posResult = await client.query(
'SELECT bezeichnung, artikel_id FROM ausruestung_anfrage_positionen WHERE id = $1 AND anfrage_id = $2', 'SELECT bezeichnung, artikel_id, menge FROM ausruestung_anfrage_positionen WHERE id = $1 AND anfrage_id = $2',
[a.positionId, anfrageId], [a.positionId, anfrageId],
); );
if (posResult.rows.length === 0) continue; if (posResult.rows.length === 0) continue;
@@ -1088,8 +1089,8 @@ async function assignDeliveredItems(
const insertResult = await client.query( const insertResult = await client.query(
`INSERT INTO persoenliche_ausruestung ( `INSERT INTO persoenliche_ausruestung (
bezeichnung, kategorie, groesse, user_id, benutzer_name, bezeichnung, kategorie, groesse, user_id, benutzer_name,
anfrage_id, anfrage_position_id, artikel_id, erstellt_von anfrage_id, anfrage_position_id, artikel_id, menge, erstellt_von
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`, RETURNING id`,
[ [
pos.bezeichnung, pos.bezeichnung,
@@ -1100,6 +1101,7 @@ async function assignDeliveredItems(
anfrageId, anfrageId,
a.positionId, a.positionId,
pos.artikel_id ?? null, pos.artikel_id ?? null,
pos.menge ?? 1,
requestingUserId, requestingUserId,
], ],
); );
@@ -1146,6 +1148,15 @@ async function assignDeliveredItems(
); );
} }
if (a.replacedItemIds && a.replacedItemIds.length > 0) {
await client.query(
`UPDATE persoenliche_ausruestung
SET geloescht_am = NOW()
WHERE id = ANY($1::uuid[]) AND geloescht_am IS NULL`,
[a.replacedItemIds],
);
}
assigned++; assigned++;
} }

View File

@@ -19,6 +19,7 @@ interface CreatePersonalEquipmentData {
anschaffung_datum?: string; anschaffung_datum?: string;
zustand?: string; zustand?: string;
notizen?: string; notizen?: string;
menge?: number;
eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[]; eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[];
} }
@@ -34,6 +35,7 @@ interface UpdatePersonalEquipmentData {
anschaffung_datum?: string | null; anschaffung_datum?: string | null;
zustand?: string; zustand?: string;
notizen?: string | null; notizen?: string | null;
menge?: number | null;
eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[] | null; eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[] | null;
} }
@@ -154,8 +156,8 @@ async function create(data: CreatePersonalEquipmentData, requestingUserId: strin
`INSERT INTO persoenliche_ausruestung ( `INSERT INTO persoenliche_ausruestung (
bezeichnung, kategorie, artikel_id, user_id, benutzer_name, bezeichnung, kategorie, artikel_id, user_id, benutzer_name,
groesse, seriennummer, inventarnummer, anschaffung_datum, groesse, seriennummer, inventarnummer, anschaffung_datum,
zustand, notizen, erstellt_von zustand, notizen, menge, erstellt_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *`, RETURNING *`,
[ [
data.bezeichnung, data.bezeichnung,
@@ -169,6 +171,7 @@ async function create(data: CreatePersonalEquipmentData, requestingUserId: strin
data.anschaffung_datum ?? null, data.anschaffung_datum ?? null,
data.zustand ?? 'gut', data.zustand ?? 'gut',
data.notizen ?? null, data.notizen ?? null,
data.menge ?? 1,
requestingUserId, requestingUserId,
], ],
); );
@@ -214,6 +217,7 @@ async function update(id: string, data: UpdatePersonalEquipmentData) {
if (data.anschaffung_datum !== undefined) addField('anschaffung_datum', data.anschaffung_datum); if (data.anschaffung_datum !== undefined) addField('anschaffung_datum', data.anschaffung_datum);
if (data.zustand !== undefined) addField('zustand', data.zustand); if (data.zustand !== undefined) addField('zustand', data.zustand);
if (data.notizen !== undefined) addField('notizen', data.notizen); if (data.notizen !== undefined) addField('notizen', data.notizen);
if (data.menge !== undefined) addField('menge', data.menge ?? 1);
if (fields.length === 0 && data.eigenschaften === undefined) { if (fields.length === 0 && data.eigenschaften === undefined) {
throw new Error('No fields to update'); throw new Error('No fields to update');

View File

@@ -1,4 +1,4 @@
import { List, ListItem, ListItemText, Chip, Typography } from '@mui/material'; import { Box, List, ListItem, ListItemText, Chip, Typography } from '@mui/material';
import CheckroomIcon from '@mui/icons-material/Checkroom'; import CheckroomIcon from '@mui/icons-material/Checkroom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -51,11 +51,17 @@ function PersoenlicheAusruestungWidget() {
{displayItems.map((item) => ( {displayItems.map((item) => (
<ListItem key={item.id} disablePadding sx={{ py: 0.5 }}> <ListItem key={item.id} disablePadding sx={{ py: 0.5 }}>
<ListItemText <ListItemText
primary={item.bezeichnung} primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" noWrap>{item.bezeichnung}</Typography>
{item.menge > 1 && (
<Chip label={`${item.menge}x`} size="small" sx={{ ml: 0.5, height: 18, fontSize: '0.65rem' }} />
)}
</Box>
}
secondary={item.artikel_kategorie_parent_name secondary={item.artikel_kategorie_parent_name
? `${item.artikel_kategorie_parent_name} > ${item.artikel_kategorie_name}` ? `${item.artikel_kategorie_parent_name} > ${item.artikel_kategorie_name}`
: item.artikel_kategorie_name ?? item.kategorie ?? undefined} : item.artikel_kategorie_name ?? item.kategorie ?? undefined}
primaryTypographyProps={{ variant: 'body2', noWrap: true }}
secondaryTypographyProps={{ variant: 'caption' }} secondaryTypographyProps={{ variant: 'caption' }}
/> />
<Chip <Chip

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { import {
Box, Typography, Paper, Button, TextField, IconButton, Box, Typography, Paper, Button, TextField, IconButton,
Autocomplete, Divider, MenuItem, Checkbox, Chip, FormControlLabel, Switch, Autocomplete, Divider, MenuItem, Checkbox, Chip, FormControlLabel, Switch,
@@ -11,7 +11,6 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { personalEquipmentApi } from '../services/personalEquipment'; import { personalEquipmentApi } from '../services/personalEquipment';
import type { ZustandOption } from '../types/personalEquipment.types';
import type { import type {
AusruestungAnfrageFormItem, AusruestungAnfrageFormItem,
AusruestungEigenschaft, AusruestungEigenschaft,
@@ -80,7 +79,7 @@ export default function AusruestungsanfrageNeu() {
const [fuerBenutzer, setFuerBenutzer] = useState<{ id: string; name: string } | string | null>(null); const [fuerBenutzer, setFuerBenutzer] = useState<{ id: string; name: string } | string | null>(null);
const [catalogItems, setCatalogItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]); const [catalogItems, setCatalogItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]); 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 [catalogIstErsatz, setCatalogIstErsatz] = useState<Record<number, boolean>>({});
const [freeIstErsatz, setFreeIstErsatz] = useState<Record<number, boolean>>({}); const [freeIstErsatz, setFreeIstErsatz] = useState<Record<number, boolean>>({});
@@ -96,15 +95,6 @@ export default function AusruestungsanfrageNeu() {
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }), 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({ const { data: orderUsers = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'orderUsers'], queryKey: ['ausruestungsanfrage', 'orderUsers'],
queryFn: () => ausruestungsanfrageApi.getOrderUsers(), queryFn: () => ausruestungsanfrageApi.getOrderUsers(),
@@ -121,19 +111,48 @@ export default function AusruestungsanfrageNeu() {
staleTime: 2 * 60 * 1000, staleTime: 2 * 60 * 1000,
}); });
// Clear assigned selections when switching user // Clear replacement selections when switching user
const prevTargetUserRef = useRef(targetUserId); const prevTargetUserRef = useRef(targetUserId);
useEffect(() => { useEffect(() => {
if (prevTargetUserRef.current !== targetUserId) { if (prevTargetUserRef.current !== targetUserId) {
setAssignedSelections({}); setReplacementSelections({});
prevTargetUserRef.current = targetUserId; prevTargetUserRef.current = targetUserId;
} }
}, [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 ── // ── Mutations ──
const createMut = useMutation({ 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 }[] }) => 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, args.assignedItems), ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] });
showSuccess('Anfrage erstellt'); showSuccess('Anfrage erstellt');
@@ -171,7 +190,21 @@ export default function AusruestungsanfrageNeu() {
.filter(i => i.bezeichnung.trim()) .filter(i => i.bezeichnung.trim())
.map((i, idx) => ({ bezeichnung: i.bezeichnung, menge: i.menge, ist_ersatz: freeIstErsatz[idx] || false })); .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; if (allItems.length === 0) return;
// Check required eigenschaften for catalog items // Check required eigenschaften for catalog items
@@ -194,13 +227,10 @@ export default function AusruestungsanfrageNeu() {
bezeichnung: bezeichnung || undefined, bezeichnung: bezeichnung || undefined,
fuer_benutzer_id: typeof fuerBenutzer === 'object' && fuerBenutzer ? fuerBenutzer.id : undefined, fuer_benutzer_id: typeof fuerBenutzer === 'object' && fuerBenutzer ? fuerBenutzer.id : undefined,
fuer_benutzer_name: typeof fuerBenutzer === 'string' ? fuerBenutzer : 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 ( return (
<DashboardLayout> <DashboardLayout>
@@ -347,55 +377,58 @@ export default function AusruestungsanfrageNeu() {
</Button> </Button>
<Divider /> <Divider />
<Typography variant="subtitle2">Zugewiesene Gegenstände</Typography> <Typography variant="subtitle2">Zugewiesene Gegenstände (für Ersatz)</Typography>
{myPersonalItems.length === 0 ? ( {personalItemGroups.size === 0 ? (
<Typography variant="body2" color="text.secondary">Keine zugewiesenen Gegenstände vorhanden.</Typography> <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) => ( Array.from(personalItemGroups.entries()).map(([artikelId, group]) => {
<Box key={item.id}> const selectedMenge = replacementSelections[artikelId] ?? 0;
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', py: 0.5 }}> const checked = selectedMenge > 0;
<Checkbox return (
size="small" <Box key={artikelId} sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', py: 0.5 }}>
checked={!!assignedSelections[item.id]} <Checkbox
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
size="small" size="small"
label="Neuer Status" checked={checked}
sx={{ minWidth: 150 }} onChange={(e) => {
value={assignedSelections[item.id]} if (e.target.checked) {
onChange={(e) => setAssignedSelections(prev => ({ ...prev, [item.id]: e.target.value }))} setReplacementSelections(prev => ({ ...prev, [artikelId]: 1 }));
> } else {
{zustandOptions.map((opt) => ( setReplacementSelections(prev => {
<MenuItem key={opt.key} value={opt.key}>{opt.label}</MenuItem> const n = { ...prev };
))} delete n[artikelId];
</TextField> 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>
</Box> );
)) })
)} )}
<Divider /> <Divider />

View File

@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
import { import {
Box, Typography, Container, Button, Chip, Box, Typography, Container, Button, Chip,
TextField, Autocomplete, ToggleButton, ToggleButtonGroup, TextField, Autocomplete, ToggleButton, ToggleButtonGroup,
Stack, Divider, LinearProgress, MenuItem, Stack, Divider, LinearProgress, MenuItem, Checkbox, FormControlLabel,
} from '@mui/material'; } from '@mui/material';
import { Assignment as AssignmentIcon } from '@mui/icons-material'; import { Assignment as AssignmentIcon } from '@mui/icons-material';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -16,6 +16,7 @@ import { personalEquipmentApi } from '../services/personalEquipment';
import { vehiclesApi } from '../services/vehicles'; import { vehiclesApi } from '../services/vehicles';
import { membersService } from '../services/members'; import { membersService } from '../services/members';
import type { AusruestungAnfragePosition, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types'; import type { AusruestungAnfragePosition, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
import type { ZustandOption } from '../types/personalEquipment.types';
type AssignmentTyp = 'ausruestung' | 'persoenlich' | 'keine'; type AssignmentTyp = 'ausruestung' | 'persoenlich' | 'keine';
@@ -130,13 +131,34 @@ export default function AusruestungsanfrageZuweisung() {
staleTime: 2 * 60 * 1000, 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 [submitting, setSubmitting] = useState(false);
const [replacements, setReplacements] = useState<Record<number, string[]>>({});
const updateAssignment = (posId: number, patch: Partial<PositionAssignment>) => { const updateAssignment = (posId: number, patch: Partial<PositionAssignment>) => {
setAssignments((prev) => ({ setAssignments((prev) => {
...prev, const prevA = prev[posId];
[posId]: { ...prev[posId], ...patch }, // 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 = () => { const handleSkipAll = () => {
@@ -190,6 +212,7 @@ export default function AusruestungsanfrageZuweisung() {
wert, wert,
})) }))
: undefined, : undefined,
replacedItemIds: pos?.ist_ersatz ? (replacements[Number(posId)] ?? []) : undefined,
}; };
}); });
await ausruestungsanfrageApi.assignItems(anfrageId, payload); 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>
)} )}
</Box> </Box>

View File

@@ -882,7 +882,12 @@ function MitgliedDetail() {
onClick={() => navigate(`/persoenliche-ausruestung/${item.id}`)} onClick={() => navigate(`/persoenliche-ausruestung/${item.id}`)}
> >
<Box> <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 && ( {item.kategorie && (
<Typography variant="caption" color="text.secondary">{item.kategorie}</Typography> <Typography variant="caption" color="text.secondary">{item.kategorie}</Typography>
)} )}

View File

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

View File

@@ -102,6 +102,7 @@ export default function PersoenlicheAusruestungDetail() {
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}> <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
{([ {([
['Benutzer', item.user_display_name || item.benutzer_name], ['Benutzer', item.user_display_name || item.benutzer_name],
['Menge', item.menge > 1 ? String(item.menge) : null],
['Seriennummer', item.seriennummer], ['Seriennummer', item.seriennummer],
['Inventarnummer', item.inventarnummer], ['Inventarnummer', item.inventarnummer],
['Erstellt am', new Date(item.erstellt_am).toLocaleDateString('de-AT')], ['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 [seriennummer, setSeriennummer] = useState('');
const [inventarnummer, setInventarnummer] = useState(''); const [inventarnummer, setInventarnummer] = useState('');
const [zustand, setZustand] = useState('gut'); const [zustand, setZustand] = useState('gut');
const [menge, setMenge] = useState(1);
const [notizen, setNotizen] = useState(''); const [notizen, setNotizen] = useState('');
const [userId, setUserId] = useState<{ id: string; name: string } | null>(null); const [userId, setUserId] = useState<{ id: string; name: string } | null>(null);
const [eigenschaften, setEigenschaften] = useState<EigenschaftRow[]>([]); const [eigenschaften, setEigenschaften] = useState<EigenschaftRow[]>([]);
@@ -93,6 +94,7 @@ export default function PersoenlicheAusruestungEdit() {
setSeriennummer(item.seriennummer ?? ''); setSeriennummer(item.seriennummer ?? '');
setInventarnummer(item.inventarnummer ?? ''); setInventarnummer(item.inventarnummer ?? '');
setZustand(item.zustand); setZustand(item.zustand);
setMenge(item.menge ?? 1);
setNotizen(item.notizen ?? ''); setNotizen(item.notizen ?? '');
if (item.eigenschaften) { if (item.eigenschaften) {
setEigenschaften(item.eigenschaften.map(e => ({ setEigenschaften(item.eigenschaften.map(e => ({
@@ -141,6 +143,7 @@ export default function PersoenlicheAusruestungEdit() {
seriennummer: seriennummer || null, seriennummer: seriennummer || null,
inventarnummer: inventarnummer || null, inventarnummer: inventarnummer || null,
zustand, zustand,
menge,
notizen: notizen || null, notizen: notizen || null,
eigenschaften: item.artikel_id eigenschaften: item.artikel_id
? Object.entries(catalogEigenschaftValues) ? Object.entries(catalogEigenschaftValues)
@@ -231,6 +234,16 @@ export default function PersoenlicheAusruestungEdit() {
onChange={(e) => setInventarnummer(e.target.value)} 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 <TextField
label="Zustand" label="Zustand"
select select

View File

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

View File

@@ -99,20 +99,8 @@ export const ausruestungsanfrageApi = {
bezeichnung?: string, bezeichnung?: string,
fuer_benutzer_id?: string, fuer_benutzer_id?: string,
fuer_benutzer_name?: string, fuer_benutzer_name?: string,
assignedItems?: { persoenlich_id: string; neuer_zustand: string }[],
): Promise<AusruestungAnfrageDetailResponse> => { ): Promise<AusruestungAnfrageDetailResponse> => {
// Merge assigned personal items into the items array with ist_ersatz: true const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name });
const allItems = [
...items,
...(assignedItems ?? []).map(a => ({
bezeichnung: '', // backend resolves from persoenlich_id
menge: 1,
persoenlich_id: a.persoenlich_id,
neuer_zustand: a.neuer_zustand,
ist_ersatz: true,
})),
];
const r = await api.post('/api/ausruestungsanfragen/requests', { items: allItems, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name });
return r.data.data; return r.data.data;
}, },
updateRequest: async ( updateRequest: async (
@@ -148,6 +136,7 @@ export const ausruestungsanfrageApi = {
groesse?: string; groesse?: string;
kategorie?: string; kategorie?: string;
eigenschaften?: Array<{ eigenschaft_id?: number; name: string; wert: string }>; eigenschaften?: Array<{ eigenschaft_id?: number; name: string; wert: string }>;
replacedItemIds?: string[];
}>, }>,
): Promise<void> => { ): Promise<void> => {
await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/assign`, { assignments }); await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/assign`, { assignments });

View File

@@ -22,6 +22,7 @@ export interface PersoenlicheAusruestung {
inventarnummer?: string; inventarnummer?: string;
anschaffung_datum?: string; anschaffung_datum?: string;
zustand: string; zustand: string;
menge: number;
notizen?: string; notizen?: string;
anfrage_id?: number; anfrage_id?: number;
anfrage_position_id?: number; anfrage_position_id?: number;
@@ -41,6 +42,7 @@ export interface CreatePersoenlicheAusruestungPayload {
inventarnummer?: string; inventarnummer?: string;
anschaffung_datum?: string; anschaffung_datum?: string;
zustand?: string; zustand?: string;
menge?: number;
notizen?: string; notizen?: string;
eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[]; eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[];
} }
@@ -56,6 +58,7 @@ export interface UpdatePersoenlicheAusruestungPayload {
inventarnummer?: string | null; inventarnummer?: string | null;
anschaffung_datum?: string | null; anschaffung_datum?: string | null;
zustand?: string; zustand?: string;
menge?: number | null;
notizen?: string | null; notizen?: string | null;
eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[] | null; eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[] | null;
} }