fix(persoenliche-ausruestung): save characteristics on create/edit and add editable eigenschaft fields to assignment page
This commit is contained in:
@@ -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 }>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,20 +1058,31 @@ 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 posEigResult = await client.query(
|
const providedEig = a.eigenschaften && a.eigenschaften.length > 0;
|
||||||
`SELECT poe.eigenschaft_id, aae.name, poe.wert
|
if (providedEig) {
|
||||||
FROM ausruestung_position_eigenschaften poe
|
for (const e of a.eigenschaften!) {
|
||||||
JOIN ausruestung_artikel_eigenschaften aae ON aae.id = poe.eigenschaft_id
|
await client.query(
|
||||||
WHERE poe.position_id = $1`,
|
`INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert)
|
||||||
[a.positionId],
|
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
|
||||||
);
|
[newId, e.eigenschaft_id ?? null, e.name, e.wert],
|
||||||
for (const row of posEigResult.rows) {
|
);
|
||||||
await client.query(
|
}
|
||||||
`INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert)
|
} else {
|
||||||
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
|
const posEigResult = await client.query(
|
||||||
[newId, row.eigenschaft_id, row.name, row.wert],
|
`SELECT poe.eigenschaft_id, aae.name, poe.wert
|
||||||
|
FROM ausruestung_position_eigenschaften poe
|
||||||
|
JOIN ausruestung_artikel_eigenschaften aae ON aae.id = poe.eigenschaft_id
|
||||||
|
WHERE poe.position_id = $1`,
|
||||||
|
[a.positionId],
|
||||||
);
|
);
|
||||||
|
for (const row of posEigResult.rows) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert)
|
||||||
|
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
|
||||||
|
[newId, row.eigenschaft_id, row.name, row.wert],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]));
|
||||||
positionId: Number(posId),
|
const payload = Object.entries(assignments).map(([posId, a]) => {
|
||||||
typ: a.typ,
|
const pos = posMap[Number(posId)];
|
||||||
fahrzeugId: a.typ === 'ausruestung' ? a.fahrzeugId : undefined,
|
const artikelEigs: AusruestungEigenschaft[] = pos?.artikel_id
|
||||||
standort: a.typ === 'ausruestung' ? a.standort : undefined,
|
? (artikelEigenschaftenMap[pos.artikel_id] ?? [])
|
||||||
userId: a.typ === 'persoenlich' ? a.userId : undefined,
|
: [];
|
||||||
benutzerName: a.typ === 'persoenlich' ? (a.benutzerName || anfrage.fuer_benutzer_name || anfrage.anfrager_name) : undefined,
|
return {
|
||||||
}));
|
positionId: Number(posId),
|
||||||
|
typ: a.typ,
|
||||||
|
fahrzeugId: a.typ === 'ausruestung' ? a.fahrzeugId : undefined,
|
||||||
|
standort: a.typ === 'ausruestung' ? a.standort : undefined,
|
||||||
|
userId: a.typ === 'persoenlich' ? a.userId : 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,34 +309,59 @@ export default function AusruestungsanfrageZuweisung() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{a.typ === 'persoenlich' && (
|
{a.typ === 'persoenlich' && (
|
||||||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
<Autocomplete
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
size="small"
|
<Autocomplete
|
||||||
options={memberOptions}
|
size="small"
|
||||||
getOptionLabel={(o) => o.name}
|
options={memberOptions}
|
||||||
value={memberOptions.find((m) => m.id === a.userId) ?? null}
|
getOptionLabel={(o) => o.name}
|
||||||
onChange={(_e, v) => updateAssignment(pos.id, { userId: v?.id, benutzerName: v?.name })}
|
value={memberOptions.find((m) => m.id === a.userId) ?? null}
|
||||||
renderInput={(params) => (
|
onChange={(_e, v) => updateAssignment(pos.id, { userId: v?.id, benutzerName: v?.name })}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Benutzer"
|
||||||
|
placeholder={detail.anfrage.fuer_benutzer_name || detail.anfrage.anfrager_name || ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
sx={{ minWidth: 200, flex: 1 }}
|
||||||
|
/>
|
||||||
|
{(() => {
|
||||||
|
if (!a.userId || !pos.artikel_id) return null;
|
||||||
|
const count = allPersonalItems.filter(i => i.user_id === a.userId && i.artikel_id === pos.artikel_id).length;
|
||||||
|
if (count === 0) return null;
|
||||||
|
return <Typography variant="caption" color="text.secondary">Hat bereits {count} Stk.</Typography>;
|
||||||
|
})()}
|
||||||
|
</Box>
|
||||||
|
{/* Editable characteristic fields from article definitions */}
|
||||||
|
{pos.artikel_id && (artikelEigenschaftenMap[pos.artikel_id] ?? []).map(e =>
|
||||||
|
e.typ === 'options' && e.optionen?.length ? (
|
||||||
<TextField
|
<TextField
|
||||||
{...params}
|
key={e.id}
|
||||||
label="Benutzer"
|
select
|
||||||
placeholder={detail.anfrage.fuer_benutzer_name || detail.anfrage.anfrager_name || ''}
|
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 },
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)
|
||||||
sx={{ minWidth: 200, flex: 1 }}
|
|
||||||
/>
|
|
||||||
{(() => {
|
|
||||||
if (!a.userId || !pos.artikel_id) return null;
|
|
||||||
const count = allPersonalItems.filter(i => i.user_id === a.userId && i.artikel_id === pos.artikel_id).length;
|
|
||||||
if (count === 0) return null;
|
|
||||||
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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user