fix(persoenliche-ausruestung): save characteristics on create/edit and add editable eigenschaft fields to assignment page

This commit is contained in:
Matthias Hochmeister
2026-04-15 20:06:02 +02:00
parent 260b71baf8
commit 3f8c4d151d
6 changed files with 141 additions and 51 deletions

View File

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

View File

@@ -15,6 +15,12 @@ const isoDate = z.string().regex(
const ZustandEnum = z.enum(['gut', 'beschaedigt', 'abgaengig', 'verloren']); const ZustandEnum = z.enum(['gut', 'beschaedigt', 'abgaengig', 'verloren']);
const EigenschaftInput = z.object({
eigenschaft_id: z.number().int().positive().nullable().optional(),
name: z.string().min(1).max(200),
wert: z.string().max(500),
});
const CreateSchema = z.object({ const CreateSchema = z.object({
bezeichnung: z.string().min(1).max(200), bezeichnung: z.string().min(1).max(200),
kategorie: z.string().max(100).optional(), kategorie: z.string().max(100).optional(),
@@ -27,6 +33,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(),
eigenschaften: z.array(EigenschaftInput).optional(),
}); });
const UpdateSchema = z.object({ const UpdateSchema = z.object({
@@ -41,6 +48,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(),
eigenschaften: z.array(EigenschaftInput).nullable().optional(),
}); });
function isValidUUID(s: string): boolean { function isValidUUID(s: string): boolean {

View File

@@ -959,6 +959,7 @@ interface AssignmentInput {
benutzerName?: string; benutzerName?: string;
groesse?: string; groesse?: string;
kategorie?: string; kategorie?: string;
eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[];
} }
async function assignDeliveredItems( async function assignDeliveredItems(
@@ -1057,7 +1058,17 @@ async function assignDeliveredItems(
); );
const newId = insertResult.rows[0].id; const newId = insertResult.rows[0].id;
// Copy position eigenschaften to persoenliche_ausruestung_eigenschaften // Copy eigenschaften: use frontend-provided values if any, otherwise copy from position
const providedEig = a.eigenschaften && a.eigenschaften.length > 0;
if (providedEig) {
for (const e of a.eigenschaften!) {
await client.query(
`INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
[newId, e.eigenschaft_id ?? null, e.name, e.wert],
);
}
} else {
const posEigResult = await client.query( const posEigResult = await client.query(
`SELECT poe.eigenschaft_id, aae.name, poe.wert `SELECT poe.eigenschaft_id, aae.name, poe.wert
FROM ausruestung_position_eigenschaften poe FROM ausruestung_position_eigenschaften poe
@@ -1072,6 +1083,7 @@ async function assignDeliveredItems(
[newId, row.eigenschaft_id, row.name, row.wert], [newId, row.eigenschaft_id, row.name, row.wert],
); );
} }
}
await client.query( await client.query(
`UPDATE ausruestung_anfrage_positionen `UPDATE ausruestung_anfrage_positionen

View File

@@ -19,7 +19,7 @@ interface CreatePersonalEquipmentData {
anschaffung_datum?: string; anschaffung_datum?: string;
zustand?: string; zustand?: string;
notizen?: string; notizen?: string;
eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[]; eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[];
} }
interface UpdatePersonalEquipmentData { interface UpdatePersonalEquipmentData {

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, Stack, Divider, LinearProgress, MenuItem,
} 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';
@@ -15,7 +15,7 @@ import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { personalEquipmentApi } from '../services/personalEquipment'; 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 } from '../types/ausruestungsanfrage.types'; import type { AusruestungAnfragePosition, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
type AssignmentTyp = 'ausruestung' | 'persoenlich' | 'keine'; type AssignmentTyp = 'ausruestung' | 'persoenlich' | 'keine';
@@ -25,6 +25,7 @@ interface PositionAssignment {
standort?: string; standort?: string;
userId?: string; userId?: string;
benutzerName?: string; benutzerName?: string;
eigenschaften?: Record<number, string>; // eigenschaft_id → wert
} }
function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] { function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] {
@@ -58,12 +59,38 @@ export default function AusruestungsanfrageZuweisung() {
const [assignments, setAssignments] = useState<Record<number, PositionAssignment>>({}); const [assignments, setAssignments] = useState<Record<number, PositionAssignment>>({});
// Collect unique artikel_ids from unassigned positions to batch-load their eigenschaften
const uniqueArtikelIds = useMemo(
() => [...new Set(unassigned.filter(p => p.artikel_id).map(p => p.artikel_id!))],
[unassigned],
);
const { data: artikelEigenschaftenMap = {} } = useQuery<Record<number, AusruestungEigenschaft[]>>({
queryKey: ['ausruestungsanfrage', 'eigenschaften-batch', uniqueArtikelIds],
queryFn: async () => {
const results: Record<number, AusruestungEigenschaft[]> = {};
await Promise.all(
uniqueArtikelIds.map(async (aid) => {
results[aid] = await ausruestungsanfrageApi.getArtikelEigenschaften(aid);
}),
);
return results;
},
enabled: uniqueArtikelIds.length > 0,
staleTime: 5 * 60 * 1000,
});
// Initialize assignments when unassigned positions load // Initialize assignments when unassigned positions load
useMemo(() => { useMemo(() => {
if (unassigned.length > 0 && Object.keys(assignments).length === 0) { if (unassigned.length > 0 && Object.keys(assignments).length === 0) {
const init: Record<number, PositionAssignment> = {}; const init: Record<number, PositionAssignment> = {};
for (const p of unassigned) { for (const p of unassigned) {
init[p.id] = { typ: 'persoenlich' }; // Pre-fill eigenschaften from position values
const prefilled: Record<number, string> = {};
for (const e of p.eigenschaften ?? []) {
prefilled[e.eigenschaft_id] = e.wert;
}
init[p.id] = { typ: 'persoenlich', eigenschaften: prefilled };
} }
setAssignments(init); setAssignments(init);
} }
@@ -135,14 +162,30 @@ export default function AusruestungsanfrageZuweisung() {
setSubmitting(true); setSubmitting(true);
try { try {
const anfrage = detail.anfrage; const anfrage = detail.anfrage;
const payload = Object.entries(assignments).map(([posId, a]) => ({ const posMap = Object.fromEntries(unassigned.map(p => [p.id, p]));
const payload = Object.entries(assignments).map(([posId, a]) => {
const pos = posMap[Number(posId)];
const artikelEigs: AusruestungEigenschaft[] = pos?.artikel_id
? (artikelEigenschaftenMap[pos.artikel_id] ?? [])
: [];
return {
positionId: Number(posId), positionId: Number(posId),
typ: a.typ, typ: a.typ,
fahrzeugId: a.typ === 'ausruestung' ? a.fahrzeugId : undefined, fahrzeugId: a.typ === 'ausruestung' ? a.fahrzeugId : undefined,
standort: a.typ === 'ausruestung' ? a.standort : undefined, standort: a.typ === 'ausruestung' ? a.standort : undefined,
userId: a.typ === 'persoenlich' ? a.userId : undefined, userId: a.typ === 'persoenlich' ? a.userId : undefined,
benutzerName: a.typ === 'persoenlich' ? (a.benutzerName || anfrage.fuer_benutzer_name || anfrage.anfrager_name) : undefined, benutzerName: a.typ === 'persoenlich' ? (a.benutzerName || anfrage.fuer_benutzer_name || anfrage.anfrager_name) : undefined,
})); eigenschaften: a.typ === 'persoenlich' && a.eigenschaften
? Object.entries(a.eigenschaften)
.filter(([, v]) => v.trim())
.map(([eid, wert]) => ({
eigenschaft_id: Number(eid),
name: artikelEigs.find(e => e.id === Number(eid))?.name ?? '',
wert,
}))
: undefined,
};
});
await ausruestungsanfrageApi.assignItems(anfrageId, payload); await ausruestungsanfrageApi.assignItems(anfrageId, payload);
showSuccess('Gegenstände zugewiesen'); showSuccess('Gegenstände zugewiesen');
navigate(`/ausruestungsanfrage/${id}`); navigate(`/ausruestungsanfrage/${id}`);
@@ -266,6 +309,7 @@ export default function AusruestungsanfrageZuweisung() {
)} )}
{a.typ === 'persoenlich' && ( {a.typ === 'persoenlich' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
<Autocomplete <Autocomplete
size="small" size="small"
@@ -288,12 +332,36 @@ export default function AusruestungsanfrageZuweisung() {
if (count === 0) return null; if (count === 0) return null;
return <Typography variant="caption" color="text.secondary">Hat bereits {count} Stk.</Typography>; return <Typography variant="caption" color="text.secondary">Hat bereits {count} Stk.</Typography>;
})()} })()}
{pos.eigenschaften && pos.eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
{pos.eigenschaften.map(e => (
<Chip key={e.eigenschaft_id} label={`${e.eigenschaft_name}: ${e.wert}`} size="small" variant="outlined" />
))}
</Box> </Box>
{/* Editable characteristic fields from article definitions */}
{pos.artikel_id && (artikelEigenschaftenMap[pos.artikel_id] ?? []).map(e =>
e.typ === 'options' && e.optionen?.length ? (
<TextField
key={e.id}
select
size="small"
label={e.name}
required={e.pflicht}
value={a.eigenschaften?.[e.id] ?? ''}
onChange={ev => updateAssignment(pos.id, {
eigenschaften: { ...a.eigenschaften, [e.id]: ev.target.value },
})}
>
<MenuItem value=""></MenuItem>
{e.optionen.map(opt => <MenuItem key={opt} value={opt}>{opt}</MenuItem>)}
</TextField>
) : (
<TextField
key={e.id}
size="small"
label={e.name}
required={e.pflicht}
value={a.eigenschaften?.[e.id] ?? ''}
onChange={ev => updateAssignment(pos.id, {
eigenschaften: { ...a.eigenschaften, [e.id]: ev.target.value },
})}
/>
)
)} )}
</Box> </Box>
)} )}

View File

@@ -147,6 +147,7 @@ export const ausruestungsanfrageApi = {
benutzerName?: string; benutzerName?: string;
groesse?: string; groesse?: string;
kategorie?: string; kategorie?: string;
eigenschaften?: Array<{ eigenschaft_id?: number; name: string; wert: string }>;
}>, }>,
): Promise<void> => { ): Promise<void> => {
await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/assign`, { assignments }); await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/assign`, { assignments });