fix(multi): FDISK sync, order UX, Ausbildungen display, untracked items

FDISK sync:
- fix(sync): strip 'KFZ-Führerschein / ' prefix from license class select option text before whitelist validation
- fix(sync): fix navigateAndGetTableRows to pick date column with most matches (prevents sidebar tables from hijacking dateColIdx for Beförderungen)
- fix(sync): input.value fallback now falls through to textContent when value is empty
- feat(sync): expand Ausbildungen to capture Kursnummer, Kurz, Kurs (full name), Erfolgscode from FDISK table; add migration 086

External orders (Bestellungen):
- fix(bestellungen): allow erhalten_menge editing in lieferung_pruefen status (resolves deadlock preventing order completion)
- fix(bestellungen): show cost/received warnings for bestellt/teillieferung/lieferung_pruefen, not just when abgeschlossen is next
- feat(bestellungen): rename status labels to Eingereicht, Genehmigt, Teilweise geliefert, Vollständig geliefert
- fix(bestellungen): remove duplicate Bestelldatum from PDF export
- feat(bestellungen): add catalog item autocomplete to creation form (auto-fills bezeichnung + artikelnummer)

Internal orders (Ausruestungsanfrage):
- fix(ausruestung): untracked items with zuweisung_typ='keine' now appear in Nicht-zugewiesen tab (frontend filter was too strict)
- feat(ausruestung): load user-specific personal items when ordering for another user
- feat(ausruestung): auto-set ist_ersatz=true for items from personal equipment list; add toggle for catalog/free-text items
- feat(ausruestung): load item eigenschaften when personal item with artikel_id is checked

Ausbildungen display:
- feat(mitglieder): show kursname (full), kurs_kurzbezeichnung chip, erfolgscode chip (color-coded) per Ausbildung entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-04-15 13:22:04 +02:00
parent 916aa488d2
commit 50dbf6e9fd
14 changed files with 182 additions and 65 deletions

View File

@@ -37,7 +37,7 @@ function formatOrderId(r: AusruestungAnfrage): string {
// ── Helpers ──
function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] {
return positions.filter((p) => p.geliefert && !p.zuweisung_typ);
return positions.filter((p) => p.geliefert && (!p.zuweisung_typ || p.zuweisung_typ === 'keine'));
}
// ══════════════════════════════════════════════════════════════════════════════

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import {
Box, Typography, Paper, Button, TextField, IconButton,
Autocomplete, Divider, MenuItem, Checkbox, Chip,
Autocomplete, Divider, MenuItem, Checkbox, Chip, FormControlLabel, Switch,
} from '@mui/material';
import { ArrowBack, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -82,6 +82,8 @@ export default function AusruestungsanfrageNeu() {
const [catalogItems, setCatalogItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]);
const [assignedSelections, setAssignedSelections] = useState<Record<string, string>>({});
const [catalogIstErsatz, setCatalogIstErsatz] = useState<Record<number, boolean>>({});
const [freeIstErsatz, setFreeIstErsatz] = useState<Record<number, boolean>>({});
// Eigenschaften state
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
@@ -101,12 +103,24 @@ export default function AusruestungsanfrageNeu() {
enabled: canOrderForUser,
});
// Determine which user's personal items to load
const targetUserId = typeof fuerBenutzer === 'object' && fuerBenutzer ? fuerBenutzer.id : null;
const { data: myPersonalItems = [] } = useQuery({
queryKey: ['persoenliche-ausruestung', 'my-for-request'],
queryFn: () => personalEquipmentApi.getMy(),
queryKey: ['persoenliche-ausruestung', targetUserId ? 'user-for-request' : 'my-for-request', targetUserId],
queryFn: () => targetUserId ? personalEquipmentApi.getByUserId(targetUserId) : personalEquipmentApi.getMy(),
staleTime: 2 * 60 * 1000,
});
// Clear assigned selections when switching user
const prevTargetUserRef = useRef(targetUserId);
useEffect(() => {
if (prevTargetUserRef.current !== targetUserId) {
setAssignedSelections({});
prevTargetUserRef.current = targetUserId;
}
}, [targetUserId]);
// ── 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 }[] }) =>
@@ -140,13 +154,13 @@ export default function AusruestungsanfrageNeu() {
const eigenschaften = Object.entries(vals)
.filter(([, wert]) => wert.trim())
.map(([eid, wert]) => ({ eigenschaft_id: Number(eid), wert }));
return { ...item, eigenschaften: eigenschaften.length > 0 ? eigenschaften : undefined };
return { ...item, eigenschaften: eigenschaften.length > 0 ? eigenschaften : undefined, ist_ersatz: catalogIstErsatz[idx] || false };
});
// Free-text items
const freeValidItems = freeItems
.filter(i => i.bezeichnung.trim())
.map(i => ({ bezeichnung: i.bezeichnung, menge: i.menge }));
.map((i, idx) => ({ bezeichnung: i.bezeichnung, menge: i.menge, ist_ersatz: freeIstErsatz[idx] || false }));
const allItems = [...catalogValidItems, ...freeValidItems];
if (allItems.length === 0) return;
@@ -260,6 +274,11 @@ export default function AusruestungsanfrageNeu() {
sx={{ width: 90 }}
inputProps={{ min: 1 }}
/>
<FormControlLabel
control={<Switch size="small" checked={catalogIstErsatz[idx] || false} onChange={e => setCatalogIstErsatz(prev => ({ ...prev, [idx]: e.target.checked }))} />}
label="Ersatz"
sx={{ ml: 0, mr: 0 }}
/>
<IconButton size="small" onClick={() => setCatalogItems(prev => prev.filter((_, i) => i !== idx))} disabled={catalogItems.length <= 1}>
<DeleteIcon fontSize="small" />
</IconButton>
@@ -303,6 +322,11 @@ export default function AusruestungsanfrageNeu() {
sx={{ width: 90 }}
inputProps={{ min: 1 }}
/>
<FormControlLabel
control={<Switch size="small" checked={freeIstErsatz[idx] || false} onChange={e => setFreeIstErsatz(prev => ({ ...prev, [idx]: e.target.checked }))} />}
label="Ersatz"
sx={{ ml: 0, mr: 0 }}
/>
<IconButton size="small" onClick={() => setFreeItems(prev => prev.filter((_, i) => i !== idx))}>
<DeleteIcon fontSize="small" />
</IconButton>
@@ -319,19 +343,28 @@ export default function AusruestungsanfrageNeu() {
<Typography variant="body2" color="text.secondary">Keine zugewiesenen Gegenstände vorhanden.</Typography>
) : (
myPersonalItems.map((item) => (
<Box key={item.id} sx={{ display: 'flex', gap: 1, alignItems: 'center', py: 0.5 }}>
<Box key={item.id}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', py: 0.5 }}>
<Checkbox
size="small"
checked={!!assignedSelections[item.id]}
onChange={(e) => {
if (e.target.checked) {
setAssignedSelections(prev => ({ ...prev, [item.id]: item.zustand }));
if (item.artikel_id) loadEigenschaften(item.artikel_id);
} else {
setAssignedSelections(prev => { const n = { ...prev }; delete n[item.id]; return n; });
}
}}
/>
<Typography variant="body2" sx={{ flex: 1 }}>{item.bezeichnung}</Typography>
{item.eigenschaften && item.eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{item.eigenschaften.map(e => (
<Chip key={e.id} label={`${e.name}: ${e.wert}`} size="small" variant="outlined" />
))}
</Box>
)}
<Chip
label={ZUSTAND_LABELS[item.zustand as PersoenlicheAusruestungZustand]}
color={ZUSTAND_COLORS[item.zustand as PersoenlicheAusruestungZustand]}
@@ -351,6 +384,7 @@ export default function AusruestungsanfrageNeu() {
))}
</TextField>
)}
</Box>
</Box>
))
)}

View File

@@ -28,7 +28,7 @@ interface PositionAssignment {
}
function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] {
return positions.filter((p) => p.geliefert && !p.zuweisung_typ);
return positions.filter((p) => p.geliefert && (!p.zuweisung_typ || p.zuweisung_typ === 'keine'));
}
export default function AusruestungsanfrageZuweisung() {

View File

@@ -487,7 +487,6 @@ export default function BestellungDetail() {
curY += 5;
row('Bezeichnung', bestellung.bezeichnung);
row('Erstellt am', formatDate(bestellung.erstellt_am));
if (bestellung.bestellt_am) row('Bestelldatum', formatDate(bestellung.bestellt_am));
curY += 5;
// ── Place and date ──
@@ -807,10 +806,10 @@ export default function BestellungDetail() {
{/* ── Status Action ── */}
{(canManageOrders || canCreate || canApprove) && (
<Box sx={{ mb: 3 }}>
{validTransitions.includes('abgeschlossen' as BestellungStatus) && !allCostsEntered && (
{(['bestellt', 'teillieferung', 'lieferung_pruefen'] as BestellungStatus[]).includes(bestellung.status) && !allCostsEntered && (
<Alert severity="warning" sx={{ mb: 2 }}>Nicht alle Positionen haben Kosten. Bitte Einzelpreise eintragen, bevor die Bestellung abgeschlossen wird.</Alert>
)}
{validTransitions.includes('abgeschlossen' as BestellungStatus) && !allItemsReceived && (
{(['bestellt', 'teillieferung', 'lieferung_pruefen'] as BestellungStatus[]).includes(bestellung.status) && !allItemsReceived && (
<Alert severity="warning" sx={{ mb: 2 }}>Nicht alle Positionen wurden vollständig empfangen. Bitte Eingangsmenge prüfen, bevor die Bestellung abgeschlossen wird.</Alert>
)}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
@@ -954,7 +953,7 @@ export default function BestellungDetail() {
{canManageOrders ? (
<TextField size="small" type="number" sx={{ width: 70 }} value={p.erhalten_menge}
inputProps={{ min: 0, max: p.menge }}
disabled={bestellung.status !== 'bestellt' && bestellung.status !== 'teillieferung'}
disabled={!['bestellt', 'teillieferung', 'lieferung_pruefen'].includes(bestellung.status)}
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} />
) : p.erhalten_menge}
</TableCell>
@@ -1026,7 +1025,7 @@ export default function BestellungDetail() {
sx={{ width: 70 }}
value={p.erhalten_menge}
inputProps={{ min: 0, max: p.menge }}
disabled={bestellung.status !== 'bestellt' && bestellung.status !== 'teillieferung'}
disabled={!['bestellt', 'teillieferung', 'lieferung_pruefen'].includes(bestellung.status)}
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })}
/>
) : (

View File

@@ -19,7 +19,9 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageHeader, FormLayout } from '../components/templates';
import { useNotification } from '../contexts/NotificationContext';
import { bestellungApi } from '../services/bestellung';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types';
const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', notizen: '', positionen: [] };
const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
@@ -45,6 +47,12 @@ export default function BestellungNeu() {
queryFn: bestellungApi.getOrderUsers,
});
const { data: katalogItems = [] } = useQuery({
queryKey: ['katalog'],
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
staleTime: 5 * 60 * 1000,
});
// ── Mutations ──
const createOrder = useMutation({
mutationFn: (data: BestellungFormData) => bestellungApi.createOrder(data),
@@ -154,17 +162,40 @@ export default function BestellungNeu() {
<Typography variant="subtitle2" sx={{ mt: 1 }}>Positionen</Typography>
{(orderForm.positionen || []).map((pos, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
label="Bezeichnung"
<Autocomplete<AusruestungArtikel, false, false, true>
freeSolo
size="small"
required
InputLabelProps={{ shrink: true }}
value={pos.bezeichnung}
onChange={(e) => {
options={katalogItems}
getOptionLabel={(o) => typeof o === 'string' ? o : o.bezeichnung}
value={pos.bezeichnung || ''}
onChange={(_, v) => {
const next = [...(orderForm.positionen || [])];
next[idx] = { ...next[idx], bezeichnung: e.target.value };
if (v && typeof v !== 'string') {
next[idx] = { ...next[idx], bezeichnung: v.bezeichnung, artikel_id: v.id };
} else if (typeof v === 'string') {
next[idx] = { ...next[idx], bezeichnung: v, artikel_id: undefined };
} else {
next[idx] = { ...next[idx], bezeichnung: '', artikel_id: undefined };
}
setOrderForm((f) => ({ ...f, positionen: next }));
}}
onInputChange={(_, val, reason) => {
if (reason === 'input') {
const next = [...(orderForm.positionen || [])];
next[idx] = { ...next[idx], bezeichnung: val, artikel_id: undefined };
setOrderForm((f) => ({ ...f, positionen: next }));
}
}}
renderInput={(params) => <TextField {...params} label="Bezeichnung" size="small" required InputLabelProps={{ shrink: true }} />}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<Box>
<Typography variant="body2">{option.bezeichnung}</Typography>
{option.kategorie_name && <Typography variant="caption" color="text.secondary">{option.kategorie_name}</Typography>}
</Box>
</li>
)}
isOptionEqualToValue={(o, v) => o.id === (typeof v === 'object' ? v.id : undefined)}
sx={{ flexGrow: 1 }}
/>
<TextField

View File

@@ -1146,23 +1146,28 @@ function MitgliedDetail() {
{a.kurs_datum ? new Date(a.kurs_datum).toLocaleDateString('de-AT') : '—'}
</Typography>
</Box>
{(a.ort || a.status !== 'abgeschlossen') && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 0.25 }}>
{a.ort && (
<Typography variant="body2" color="text.secondary">
{a.ort}
</Typography>
)}
{a.status !== 'abgeschlossen' && (
<Chip
label={a.status === 'in_bearbeitung' ? 'In Bearbeitung' : 'Abgelaufen'}
size="small"
color={a.status === 'abgelaufen' ? 'warning' : 'info'}
variant="outlined"
/>
)}
</Box>
)}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center', mt: 0.25 }}>
{a.kurs_kurzbezeichnung && (
<Chip label={a.kurs_kurzbezeichnung} size="small" variant="outlined" />
)}
{a.erfolgscode && (
<Chip
label={a.erfolgscode}
size="small"
variant="outlined"
color={
/mit erfolg|bestanden/i.test(a.erfolgscode) ? 'success'
: /teilgenommen/i.test(a.erfolgscode) ? 'info'
: 'default'
}
/>
)}
{a.ort && (
<Typography variant="body2" color="text.secondary">
{a.ort}
</Typography>
)}
</Box>
{a.ablaufdatum && (
<Typography variant="caption" color="text.secondary">
Gültig bis: {new Date(a.ablaufdatum).toLocaleDateString('de-AT')}

View File

@@ -101,7 +101,18 @@ export const ausruestungsanfrageApi = {
fuer_benutzer_name?: string,
assignedItems?: { persoenlich_id: string; neuer_zustand: string }[],
): Promise<AusruestungAnfrageDetailResponse> => {
const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name, assignedItems });
// 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 });
return r.data.data;
},
updateRequest: async (

View File

@@ -32,11 +32,11 @@ export type BestellungStatus = 'entwurf' | 'wartet_auf_genehmigung' | 'bereit_zu
export const BESTELLUNG_STATUS_LABELS: Record<BestellungStatus, string> = {
entwurf: 'Entwurf',
wartet_auf_genehmigung: 'Wartet auf Genehmigung',
bereit_zur_bestellung: 'Bereit zur Bestellung',
wartet_auf_genehmigung: 'Eingereicht',
bereit_zur_bestellung: 'Genehmigt',
bestellt: 'Bestellt',
teillieferung: 'Teillieferung',
lieferung_pruefen: 'Lieferung prüfen',
teillieferung: 'Teilweise geliefert',
lieferung_pruefen: 'Vollständig geliefert',
abgeschlossen: 'Abgeschlossen',
};

View File

@@ -238,4 +238,7 @@ export interface Ausbildung {
bemerkung: string | null;
status: string;
created_at: string;
kursnummer?: string | null;
kurs_kurzbezeichnung?: string | null;
erfolgscode?: string | null;
}