feat(persoenliche-ausruestung): add quantity field and article-grouped replacement flow in order dialog
This commit is contained in:
@@ -573,6 +573,7 @@ class AusruestungsanfrageController {
|
||||
groesse?: string;
|
||||
kategorie?: string;
|
||||
eigenschaften?: Array<{ eigenschaft_id?: number; name: string; wert: string }>;
|
||||
replacedItemIds?: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const CreateSchema = z.object({
|
||||
anschaffung_datum: isoDate.optional(),
|
||||
zustand: ZustandEnum.optional(),
|
||||
notizen: z.string().max(2000).optional(),
|
||||
menge: z.number().int().min(1).default(1).optional(),
|
||||
eigenschaften: z.array(EigenschaftInput).optional(),
|
||||
});
|
||||
|
||||
@@ -49,6 +50,7 @@ const UpdateSchema = z.object({
|
||||
anschaffung_datum: isoDate.nullable().optional(),
|
||||
zustand: ZustandEnum.optional(),
|
||||
notizen: z.string().max(2000).nullable().optional(),
|
||||
menge: z.number().int().min(1).nullable().optional(),
|
||||
eigenschaften: z.array(EigenschaftInput).nullable().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE persoenliche_ausruestung ADD COLUMN IF NOT EXISTS menge INT NOT NULL DEFAULT 1;
|
||||
@@ -1007,6 +1007,7 @@ interface AssignmentInput {
|
||||
groesse?: string;
|
||||
kategorie?: string;
|
||||
eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[];
|
||||
replacedItemIds?: string[];
|
||||
}
|
||||
|
||||
async function assignDeliveredItems(
|
||||
@@ -1033,7 +1034,7 @@ async function assignDeliveredItems(
|
||||
for (const a of assignments) {
|
||||
// Load position details
|
||||
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],
|
||||
);
|
||||
if (posResult.rows.length === 0) continue;
|
||||
@@ -1088,8 +1089,8 @@ async function assignDeliveredItems(
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO persoenliche_ausruestung (
|
||||
bezeichnung, kategorie, groesse, user_id, benutzer_name,
|
||||
anfrage_id, anfrage_position_id, artikel_id, erstellt_von
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
anfrage_id, anfrage_position_id, artikel_id, menge, erstellt_von
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id`,
|
||||
[
|
||||
pos.bezeichnung,
|
||||
@@ -1100,6 +1101,7 @@ async function assignDeliveredItems(
|
||||
anfrageId,
|
||||
a.positionId,
|
||||
pos.artikel_id ?? null,
|
||||
pos.menge ?? 1,
|
||||
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++;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ interface CreatePersonalEquipmentData {
|
||||
anschaffung_datum?: string;
|
||||
zustand?: string;
|
||||
notizen?: string;
|
||||
menge?: number;
|
||||
eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[];
|
||||
}
|
||||
|
||||
@@ -34,6 +35,7 @@ interface UpdatePersonalEquipmentData {
|
||||
anschaffung_datum?: string | null;
|
||||
zustand?: string;
|
||||
notizen?: string | null;
|
||||
menge?: number | 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 (
|
||||
bezeichnung, kategorie, artikel_id, user_id, benutzer_name,
|
||||
groesse, seriennummer, inventarnummer, anschaffung_datum,
|
||||
zustand, notizen, erstellt_von
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
zustand, notizen, menge, erstellt_von
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.bezeichnung,
|
||||
@@ -169,6 +171,7 @@ async function create(data: CreatePersonalEquipmentData, requestingUserId: strin
|
||||
data.anschaffung_datum ?? null,
|
||||
data.zustand ?? 'gut',
|
||||
data.notizen ?? null,
|
||||
data.menge ?? 1,
|
||||
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.zustand !== undefined) addField('zustand', data.zustand);
|
||||
if (data.notizen !== undefined) addField('notizen', data.notizen);
|
||||
if (data.menge !== undefined) addField('menge', data.menge ?? 1);
|
||||
|
||||
if (fields.length === 0 && data.eigenschaften === undefined) {
|
||||
throw new Error('No fields to update');
|
||||
|
||||
@@ -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 { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -51,11 +51,17 @@ function PersoenlicheAusruestungWidget() {
|
||||
{displayItems.map((item) => (
|
||||
<ListItem key={item.id} disablePadding sx={{ py: 0.5 }}>
|
||||
<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
|
||||
? `${item.artikel_kategorie_parent_name} > ${item.artikel_kategorie_name}`
|
||||
: item.artikel_kategorie_name ?? item.kategorie ?? undefined}
|
||||
primaryTypographyProps={{ variant: 'body2', noWrap: true }}
|
||||
secondaryTypographyProps={{ variant: 'caption' }}
|
||||
/>
|
||||
<Chip
|
||||
|
||||
@@ -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 }}>
|
||||
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"
|
||||
checked={!!assignedSelections[item.id]}
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setAssignedSelections(prev => ({ ...prev, [item.id]: item.zustand }));
|
||||
if (item.artikel_id) loadEigenschaften(item.artikel_id);
|
||||
setReplacementSelections(prev => ({ ...prev, [artikelId]: 1 }));
|
||||
} else {
|
||||
setAssignedSelections(prev => { const n = { ...prev }; delete n[item.id]; return n; });
|
||||
setReplacementSelections(prev => {
|
||||
const n = { ...prev };
|
||||
delete n[artikelId];
|
||||
return n;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ flex: 1 }}>{item.bezeichnung}</Typography>
|
||||
{item.eigenschaften && item.eigenschaften.length > 0 && (
|
||||
<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' }}>
|
||||
{item.eigenschaften.map(e => (
|
||||
<Chip key={e.id} label={`${e.name}: ${e.wert}`} size="small" variant="outlined" />
|
||||
{group.eigenschaften.map((e, i) => (
|
||||
<Chip key={`${e.name}-${e.wert}-${i}`} 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] && (
|
||||
{checked && (
|
||||
<TextField
|
||||
select
|
||||
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>
|
||||
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 />
|
||||
|
||||
@@ -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) => ({
|
||||
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]: { ...prev[posId], ...patch },
|
||||
}));
|
||||
[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>
|
||||
|
||||
@@ -882,7 +882,12 @@ function MitgliedDetail() {
|
||||
onClick={() => navigate(`/persoenliche-ausruestung/${item.id}`)}
|
||||
>
|
||||
<Box>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -99,20 +99,8 @@ export const ausruestungsanfrageApi = {
|
||||
bezeichnung?: string,
|
||||
fuer_benutzer_id?: string,
|
||||
fuer_benutzer_name?: string,
|
||||
assignedItems?: { persoenlich_id: string; neuer_zustand: string }[],
|
||||
): Promise<AusruestungAnfrageDetailResponse> => {
|
||||
// Merge assigned personal items into the items array with ist_ersatz: true
|
||||
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 });
|
||||
const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name });
|
||||
return r.data.data;
|
||||
},
|
||||
updateRequest: async (
|
||||
@@ -148,6 +136,7 @@ export const ausruestungsanfrageApi = {
|
||||
groesse?: string;
|
||||
kategorie?: string;
|
||||
eigenschaften?: Array<{ eigenschaft_id?: number; name: string; wert: string }>;
|
||||
replacedItemIds?: string[];
|
||||
}>,
|
||||
): Promise<void> => {
|
||||
await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/assign`, { assignments });
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface PersoenlicheAusruestung {
|
||||
inventarnummer?: string;
|
||||
anschaffung_datum?: string;
|
||||
zustand: string;
|
||||
menge: number;
|
||||
notizen?: string;
|
||||
anfrage_id?: number;
|
||||
anfrage_position_id?: number;
|
||||
@@ -41,6 +42,7 @@ export interface CreatePersoenlicheAusruestungPayload {
|
||||
inventarnummer?: string;
|
||||
anschaffung_datum?: string;
|
||||
zustand?: string;
|
||||
menge?: number;
|
||||
notizen?: string;
|
||||
eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[];
|
||||
}
|
||||
@@ -56,6 +58,7 @@ export interface UpdatePersoenlicheAusruestungPayload {
|
||||
inventarnummer?: string | null;
|
||||
anschaffung_datum?: string | null;
|
||||
zustand?: string;
|
||||
menge?: number | null;
|
||||
notizen?: string | null;
|
||||
eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[] | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user