From 279cc03b6bfd28fe7093a33117d7a55b7b600900 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Wed, 15 Apr 2026 10:20:36 +0200 Subject: [PATCH] feat(ausruestung): catalog-driven item tracking, im_haus in overview, order quantity override, fix stale queries --- .../services/ausruestungsanfrage.service.ts | 18 ++- frontend/src/main.tsx | 2 +- frontend/src/pages/Ausruestungsanfrage.tsx | 4 + .../pages/AusruestungsanfrageZuBestellung.tsx | 20 ++- .../pages/AusruestungsanfrageZuweisung.tsx | 49 +++---- frontend/src/pages/BestellungDetail.tsx | 10 ++ .../src/pages/PersoenlicheAusruestung.tsx | 2 +- .../src/pages/PersoenlicheAusruestungEdit.tsx | 127 +++++++++++------- .../src/pages/PersoenlicheAusruestungNeu.tsx | 76 ++++++----- .../src/types/ausruestungsanfrage.types.ts | 1 + 10 files changed, 195 insertions(+), 114 deletions(-) diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index 87944a9..e52336a 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -324,7 +324,13 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string }) COALESCE(u2.given_name || ' ' || u2.family_name, u2.name) AS bearbeitet_von_name, a.fuer_benutzer_name, (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count, - (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count + (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count, + EXISTS( + SELECT 1 FROM ausruestung_anfrage_bestellung ab + JOIN bestellungen b ON b.id = ab.bestellung_id + WHERE ab.anfrage_id = a.id + AND b.status IN ('lieferung_pruefen', 'abgeschlossen') + ) AS im_haus FROM ausruestung_anfragen a LEFT JOIN users u ON u.id = a.anfrager_id LEFT JOIN users u2 ON u2.id = a.bearbeitet_von @@ -339,7 +345,13 @@ async function getMyRequests(userId: string) { const result = await pool.query( `SELECT a.*, (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count, - (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count + (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count, + EXISTS( + SELECT 1 FROM ausruestung_anfrage_bestellung ab + JOIN bestellungen b ON b.id = ab.bestellung_id + WHERE ab.anfrage_id = a.id + AND b.status IN ('lieferung_pruefen', 'abgeschlossen') + ) AS im_haus FROM ausruestung_anfragen a WHERE a.anfrager_id = $1 ORDER BY a.erstellt_am DESC`, @@ -1100,7 +1112,7 @@ async function getUnassignedPositions() { FROM ausruestung_anfrage_positionen p JOIN ausruestung_anfragen a ON a.id = p.anfrage_id LEFT JOIN users u ON u.id = a.anfrager_id - WHERE p.geliefert = true AND p.zuweisung_typ IS NULL + WHERE p.geliefert = true AND (p.zuweisung_typ IS NULL OR p.zuweisung_typ = 'keine') ORDER BY a.bestell_jahr DESC NULLS LAST, a.bestell_nummer DESC NULLS LAST, p.bezeichnung `); return result.rows; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8b71964..3e4d1b0 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,7 +8,7 @@ import App from './App'; const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 5 * 60 * 1000, // 5 minutes + staleTime: 30 * 1000, // 30 seconds gcTime: 10 * 60 * 1000, // keep cache 10 minutes retry: 1, refetchOnWindowFocus: false, // prevent refetch on every tab switch diff --git a/frontend/src/pages/Ausruestungsanfrage.tsx b/frontend/src/pages/Ausruestungsanfrage.tsx index bbd4b6a..e5ad503 100644 --- a/frontend/src/pages/Ausruestungsanfrage.tsx +++ b/frontend/src/pages/Ausruestungsanfrage.tsx @@ -96,6 +96,7 @@ function MeineAnfragenTab() { Anfrage ID Bezeichnung Status + Im Haus Positionen Geliefert Erstellt am @@ -107,6 +108,7 @@ function MeineAnfragenTab() { {formatOrderId(r)} {r.bezeichnung || '-'} + {r.im_haus ? : null} {r.positionen_count ?? r.items_count ?? '-'} {r.positionen_count != null && r.positionen_count > 0 @@ -204,6 +206,7 @@ function AlleAnfragenTab() { Bezeichnung Anfrage für Status + Im Haus Positionen Geliefert Erstellt am @@ -216,6 +219,7 @@ function AlleAnfragenTab() { {r.bezeichnung || '-'} {r.fuer_benutzer_name || r.anfrager_name || r.anfrager_id} + {r.im_haus ? : null} {r.positionen_count ?? r.items_count ?? '-'} {r.positionen_count != null && r.positionen_count > 0 diff --git a/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx b/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx index 97b9fd7..e306fae 100644 --- a/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx +++ b/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx @@ -84,6 +84,7 @@ export default function AusruestungsanfrageZuBestellung() { // ── State ── const [assignments, setAssignments] = useState>({}); const [orderNames, setOrderNames] = useState>({}); + const [quantities, setQuantities] = useState>({}); // New vendor dialog const [newVendorDialog, setNewVendorDialog] = useState(false); @@ -153,6 +154,8 @@ export default function AusruestungsanfrageZuBestellung() { showSuccess(`${result.created_bestellungen.length} Bestellung(en) erfolgreich erstellt`); queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', requestId] }); navigate('/bestellungen'); + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); }, onError: () => showError('Bestellungen konnten nicht erstellt werden'), }); @@ -191,7 +194,7 @@ export default function AusruestungsanfrageZuBestellung() { positionen: g.positionen.map(p => ({ position_id: p.id, bezeichnung: p.bezeichnung, - menge: p.menge, + menge: quantities[p.id] ?? p.menge, einheit: p.einheit, notizen: p.notizen, artikel_id: p.artikel_id, @@ -281,6 +284,7 @@ export default function AusruestungsanfrageZuBestellung() { Artikel Menge + Bestellmenge Lieferant @@ -301,6 +305,20 @@ export default function AusruestungsanfrageZuBestellung() { {pos.menge} {pos.einheit ?? 'Stk'} + + { + const val = Math.min(pos.menge, Math.max(1, Number(e.target.value))); + setQuantities(prev => ({ ...prev, [pos.id]: val })); + }} + inputProps={{ min: 1, max: pos.menge }} + helperText={pos.menge > 1 ? `max ${pos.menge}` : undefined} + /> + diff --git a/frontend/src/pages/AusruestungsanfrageZuweisung.tsx b/frontend/src/pages/AusruestungsanfrageZuweisung.tsx index c0bdf9c..d0b1c63 100644 --- a/frontend/src/pages/AusruestungsanfrageZuweisung.tsx +++ b/frontend/src/pages/AusruestungsanfrageZuweisung.tsx @@ -12,6 +12,7 @@ import { PageHeader } from '../components/templates'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; +import { personalEquipmentApi } from '../services/personalEquipment'; import { vehiclesApi } from '../services/vehicles'; import { membersService } from '../services/members'; import type { AusruestungAnfragePosition } from '../types/ausruestungsanfrage.types'; @@ -24,8 +25,6 @@ interface PositionAssignment { standort?: string; userId?: string; benutzerName?: string; - groesse?: string; - kategorie?: string; } function getUnassignedPositions(positions: AusruestungAnfragePosition[]): AusruestungAnfragePosition[] { @@ -92,6 +91,12 @@ export default function AusruestungsanfrageZuweisung() { name: v.bezeichnung ?? v.kurzname, })); + const { data: allPersonalItems = [] } = useQuery({ + queryKey: ['persoenliche-ausruestung', 'all-for-count'], + queryFn: () => personalEquipmentApi.getAll(), + staleTime: 2 * 60 * 1000, + }); + const [submitting, setSubmitting] = useState(false); const updateAssignment = (posId: number, patch: Partial) => { @@ -137,12 +142,12 @@ export default function AusruestungsanfrageZuweisung() { 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, - groesse: a.typ === 'persoenlich' ? a.groesse : undefined, - kategorie: a.typ === 'persoenlich' ? a.kategorie : undefined, })); await ausruestungsanfrageApi.assignItems(anfrageId, payload); showSuccess('Gegenstände zugewiesen'); navigate(`/ausruestungsanfrage/${id}`); + queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] }); + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); } catch { showError('Fehler beim Zuweisen'); } finally { @@ -233,11 +238,10 @@ export default function AusruestungsanfrageZuweisung() { size="small" onChange={(_e, val) => val && updateAssignment(pos.id, { typ: val })} sx={{ mb: 1.5 }} - disabled={!pos.artikel_id} > - Ausrüstung - Persönlich - Nicht erfassen + Ausrüstung + Persönlich + Nicht verfolgt {a.typ === 'ausruestung' && ( @@ -262,7 +266,7 @@ export default function AusruestungsanfrageZuweisung() { )} {a.typ === 'persoenlich' && ( - + - updateAssignment(pos.id, { groesse: e.target.value })} - sx={{ minWidth: 100 }} - /> - updateAssignment(pos.id, { kategorie: e.target.value })} - sx={{ minWidth: 140 }} - /> + {(() => { + 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 Hat bereits {count} Stk.; + })()} + {pos.eigenschaften && pos.eigenschaften.length > 0 && ( + + {pos.eigenschaften.map(e => ( + + ))} + + )} )} diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index c72c2ee..6ecf1fa 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -214,6 +214,7 @@ export default function BestellungDetail() { mutationFn: ({ status, force }: { status: string; force?: boolean }) => bestellungApi.updateStatus(orderId, status, force), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); showSuccess('Status aktualisiert'); setStatusConfirmTarget(null); setStatusForce(false); @@ -225,6 +226,7 @@ export default function BestellungDetail() { mutationFn: (data: BestellpositionFormData) => bestellungApi.addLineItem(orderId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); setNewItem({ ...emptyItem }); setSelectedKatalogItem(null); setKatalogEigenschaften([]); @@ -238,6 +240,7 @@ export default function BestellungDetail() { mutationFn: (itemId: number) => bestellungApi.deleteLineItem(itemId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); setDeleteItemTarget(null); showSuccess('Position gelöscht'); }, @@ -249,6 +252,7 @@ export default function BestellungDetail() { bestellungApi.updateReceivedQty(itemId, menge), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); }, onError: () => showError('Fehler beim Aktualisieren'), }); @@ -257,6 +261,7 @@ export default function BestellungDetail() { mutationFn: (file: File) => bestellungApi.uploadFile(orderId, file), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); showSuccess('Datei hochgeladen'); }, onError: () => showError('Fehler beim Hochladen der Datei'), @@ -266,6 +271,7 @@ export default function BestellungDetail() { mutationFn: (fileId: number) => bestellungApi.deleteFile(fileId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); setDeleteFileTarget(null); showSuccess('Datei gelöscht'); }, @@ -276,6 +282,7 @@ export default function BestellungDetail() { mutationFn: (data: ErinnerungFormData) => bestellungApi.addReminder(orderId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); setReminderForm({ faellig_am: '', nachricht: '' }); setReminderFormOpen(false); showSuccess('Erinnerung erstellt'); @@ -287,6 +294,7 @@ export default function BestellungDetail() { mutationFn: (reminderId: number) => bestellungApi.markReminderDone(reminderId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); }, onError: () => showError('Fehler beim Aktualisieren'), }); @@ -295,6 +303,7 @@ export default function BestellungDetail() { mutationFn: (reminderId: number) => bestellungApi.deleteReminder(reminderId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); setDeleteReminderTarget(null); showSuccess('Erinnerung gelöscht'); }, @@ -351,6 +360,7 @@ export default function BestellungDetail() { } } await queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); + queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); showSuccess('Änderungen gespeichert'); setEditMode(false); setEditItemsData({}); diff --git a/frontend/src/pages/PersoenlicheAusruestung.tsx b/frontend/src/pages/PersoenlicheAusruestung.tsx index 33961b7..ab34762 100644 --- a/frontend/src/pages/PersoenlicheAusruestung.tsx +++ b/frontend/src/pages/PersoenlicheAusruestung.tsx @@ -102,7 +102,7 @@ function PersoenlicheAusruestungPage() { setActiveTab(v)} sx={{ mb: 3 }}> - {canApprove && } + {canApprove && } {activeTab === 0 && ( diff --git a/frontend/src/pages/PersoenlicheAusruestungEdit.tsx b/frontend/src/pages/PersoenlicheAusruestungEdit.tsx index 713c8fa..8c8e475 100644 --- a/frontend/src/pages/PersoenlicheAusruestungEdit.tsx +++ b/frontend/src/pages/PersoenlicheAusruestungEdit.tsx @@ -16,6 +16,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { personalEquipmentApi } from '../services/personalEquipment'; +import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { membersService } from '../services/members'; import { usePermissionContext } from '../contexts/PermissionContext'; import { useNotification } from '../contexts/NotificationContext'; @@ -64,10 +65,17 @@ export default function PersoenlicheAusruestungEdit() { })); }, [membersList]); + const { data: artikelEigenschaften = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'eigenschaften', item?.artikel_id], + queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(item!.artikel_id!), + enabled: !!item?.artikel_id, + staleTime: 5 * 60 * 1000, + }); + + const [catalogEigenschaftValues, setCatalogEigenschaftValues] = useState>({}); + // Form state const [bezeichnung, setBezeichnung] = useState(''); - const [kategorie, setKategorie] = useState(''); - const [groesse, setGroesse] = useState(''); const [seriennummer, setSeriennummer] = useState(''); const [inventarnummer, setInventarnummer] = useState(''); const [anschaffungDatum, setAnschaffungDatum] = useState(''); @@ -80,8 +88,6 @@ export default function PersoenlicheAusruestungEdit() { useEffect(() => { if (!item) return; setBezeichnung(item.bezeichnung); - setKategorie(item.kategorie ?? ''); - setGroesse(item.groesse ?? ''); setSeriennummer(item.seriennummer ?? ''); setInventarnummer(item.inventarnummer ?? ''); setAnschaffungDatum(item.anschaffung_datum ? item.anschaffung_datum.split('T')[0] : ''); @@ -95,6 +101,13 @@ export default function PersoenlicheAusruestungEdit() { wert: e.wert, }))); } + if (item.artikel_id && item.eigenschaften) { + const vals: Record = {}; + item.eigenschaften.forEach(e => { + if (e.eigenschaft_id != null) vals[e.eigenschaft_id] = e.wert; + }); + setCatalogEigenschaftValues(vals); + } }, [item]); // Set userId when item + memberOptions are ready @@ -117,21 +130,27 @@ export default function PersoenlicheAusruestungEdit() { }); const handleSave = () => { - if (!bezeichnung.trim()) return; + if (!bezeichnung.trim() || !item) return; const payload: UpdatePersoenlicheAusruestungPayload = { bezeichnung: bezeichnung.trim(), - kategorie: kategorie || null, + kategorie: null, user_id: userId?.id || null, - groesse: groesse || null, + groesse: null, seriennummer: seriennummer || null, inventarnummer: inventarnummer || null, anschaffung_datum: anschaffungDatum || null, zustand, notizen: notizen || null, - eigenschaften: eigenschaften - .filter(e => e.name.trim() && e.wert.trim()) - .map(e => ({ eigenschaft_id: e.eigenschaft_id, name: e.name, wert: e.wert })), + eigenschaften: item.artikel_id + ? Object.entries(catalogEigenschaftValues) + .filter(([, v]) => v.trim()) + .map(([id, wert]) => ({ + eigenschaft_id: Number(id), + name: artikelEigenschaften.find(e => e.id === Number(id))?.name ?? '', + wert, + })) + : eigenschaften.filter(e => e.name.trim() && e.wert.trim()).map(e => ({ eigenschaft_id: e.eigenschaft_id, name: e.name, wert: e.wert })), }; updateMutation.mutate(payload); }; @@ -198,20 +217,6 @@ export default function PersoenlicheAusruestungEdit() { /> )} - setKategorie(e.target.value)} - /> - - setGroesse(e.target.value)} - /> - {/* Eigenschaften */} - Eigenschaften - {eigenschaften.map((e, idx) => ( - - updateEigenschaft(idx, 'name', ev.target.value)} - sx={{ flex: 1 }} - /> - updateEigenschaft(idx, 'wert', ev.target.value)} - sx={{ flex: 1 }} - /> - removeEigenschaft(idx)}> - - - - ))} - + {item.artikel_id ? ( + <> + Eigenschaften + {artikelEigenschaften.map(e => + e.typ === 'options' && e.optionen?.length ? ( + setCatalogEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))} + > + + {e.optionen.map(opt => {opt})} + + ) : ( + setCatalogEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))} + /> + ) + )} + + ) : ( + <> + Eigenschaften + {eigenschaften.map((e, idx) => ( + + updateEigenschaft(idx, 'name', ev.target.value)} + sx={{ flex: 1 }} + /> + updateEigenschaft(idx, 'wert', ev.target.value)} + sx={{ flex: 1 }} + /> + removeEigenschaft(idx)}> + + + + ))} + + + )} diff --git a/frontend/src/types/ausruestungsanfrage.types.ts b/frontend/src/types/ausruestungsanfrage.types.ts index e2caf4b..95e9729 100644 --- a/frontend/src/types/ausruestungsanfrage.types.ts +++ b/frontend/src/types/ausruestungsanfrage.types.ts @@ -96,6 +96,7 @@ export interface AusruestungAnfrage { positionen_count?: number; geliefert_count?: number; items_count?: number; + im_haus?: boolean; } export interface AusruestungAnfragePosition {