From 39b8b30ca26244f407890f38341a42c47bd2fa9d Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 24 Mar 2026 09:15:57 +0100 Subject: [PATCH] rework internal order system --- .../services/ausruestungsanfrage.service.ts | 43 +++- frontend/src/pages/Ausruestungsanfrage.tsx | 211 +++++++++++------- 2 files changed, 166 insertions(+), 88 deletions(-) diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index 4950089..1ecfaeb 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -1,11 +1,26 @@ import pool from '../config/database'; import logger from '../utils/logger'; +// Helper: check if a table exists (cached per process lifetime) +const existingTables = new Set(); +async function tableExists(tableName: string): Promise { + if (existingTables.has(tableName)) return true; + try { + const r = await pool.query( + "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = $1) AS ok", + [tableName], + ); + if (r.rows[0]?.ok) { existingTables.add(tableName); return true; } + } catch { /* ignore */ } + return false; +} + // --------------------------------------------------------------------------- // Categories (ausruestung_kategorien_katalog) — hierarchical with parent_id // --------------------------------------------------------------------------- async function getKategorien() { + if (!(await tableExists('ausruestung_kategorien_katalog'))) return []; const result = await pool.query( 'SELECT * FROM ausruestung_kategorien_katalog ORDER BY parent_id NULLS FIRST, name', ); @@ -13,6 +28,7 @@ async function getKategorien() { } async function createKategorie(name: string, parentId?: number | null) { + if (!(await tableExists('ausruestung_kategorien_katalog'))) throw new Error('Migration 048 has not been applied yet'); const result = await pool.query( 'INSERT INTO ausruestung_kategorien_katalog (name, parent_id) VALUES ($1, $2) RETURNING *', [name, parentId ?? null], @@ -69,32 +85,37 @@ async function getItems(filters?: { kategorie?: string; kategorie_id?: number; a } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const hasKategorien = await tableExists('ausruestung_kategorien_katalog'); + const hasEigenschaften = await tableExists('ausruestung_artikel_eigenschaften'); const result = await pool.query( - `SELECT a.*, k.name AS kategorie_name, - (SELECT COUNT(*)::int FROM ausruestung_artikel_eigenschaften e WHERE e.artikel_id = a.id) AS eigenschaften_count + `SELECT a.*${hasKategorien ? ', k.name AS kategorie_name' : ''}${hasEigenschaften ? ',\n (SELECT COUNT(*)::int FROM ausruestung_artikel_eigenschaften e WHERE e.artikel_id = a.id) AS eigenschaften_count' : ''} FROM ausruestung_artikel a - LEFT JOIN ausruestung_kategorien_katalog k ON k.id = a.kategorie_id + ${hasKategorien ? 'LEFT JOIN ausruestung_kategorien_katalog k ON k.id = a.kategorie_id' : ''} ${where} - ORDER BY COALESCE(k.name, a.kategorie), a.bezeichnung`, + ORDER BY ${hasKategorien ? 'COALESCE(k.name, a.kategorie)' : 'a.kategorie'}, a.bezeichnung`, params, ); return result.rows; } async function getItemById(id: number) { + const hasKategorien = await tableExists('ausruestung_kategorien_katalog'); const result = await pool.query( - `SELECT a.*, k.name AS kategorie_name + `SELECT a.*${hasKategorien ? ', k.name AS kategorie_name' : ''} FROM ausruestung_artikel a - LEFT JOIN ausruestung_kategorien_katalog k ON k.id = a.kategorie_id + ${hasKategorien ? 'LEFT JOIN ausruestung_kategorien_katalog k ON k.id = a.kategorie_id' : ''} WHERE a.id = $1`, [id], ); if (!result.rows[0]) return null; - const eigenschaften = await pool.query( - 'SELECT * FROM ausruestung_artikel_eigenschaften WHERE artikel_id = $1 ORDER BY sort_order, id', - [id], - ); + let eigenschaften: { rows: unknown[] } = { rows: [] }; + if (await tableExists('ausruestung_artikel_eigenschaften')) { + eigenschaften = await pool.query( + 'SELECT * FROM ausruestung_artikel_eigenschaften WHERE artikel_id = $1 ORDER BY sort_order, id', + [id], + ); + } return { ...result.rows[0], @@ -193,6 +214,7 @@ async function getCategories() { // --------------------------------------------------------------------------- async function getArtikelEigenschaften(artikelId: number) { + if (!(await tableExists('ausruestung_artikel_eigenschaften'))) return []; const result = await pool.query( 'SELECT * FROM ausruestung_artikel_eigenschaften WHERE artikel_id = $1 ORDER BY sort_order, id', [artikelId], @@ -204,6 +226,7 @@ async function upsertArtikelEigenschaft( artikelId: number, data: { id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }, ) { + if (!(await tableExists('ausruestung_artikel_eigenschaften'))) throw new Error('Migration 048 has not been applied yet'); if (data.id) { const result = await pool.query( `UPDATE ausruestung_artikel_eigenschaften diff --git a/frontend/src/pages/Ausruestungsanfrage.tsx b/frontend/src/pages/Ausruestungsanfrage.tsx index d24020a..e61842a 100644 --- a/frontend/src/pages/Ausruestungsanfrage.tsx +++ b/frontend/src/pages/Ausruestungsanfrage.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { Box, Tab, Tabs, Typography, Grid, Button, Chip, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, @@ -53,19 +53,20 @@ function EigenschaftFields({ eigenschaften, values, onChange }: EigenschaftField {eigenschaften.map(e => ( {e.typ === 'options' && e.optionen && e.optionen.length > 0 ? ( - - {e.name} - - + onChange(e.id, ev.target.value)} + required={e.pflicht} + sx={{ minWidth: 160 }} + > + + {e.optionen.map(opt => ( + {opt} + ))} + ) : ( setNewName(e.target.value)} sx={{ flexGrow: 1 }} /> - - Typ - - + setNewTyp(e.target.value as 'options' | 'freitext')} + sx={{ minWidth: 120 }} + > + Auswahl + Freitext + setNewPflicht(e.target.checked)} />} label="Pflicht" @@ -766,13 +771,17 @@ function KatalogTab() { {/* Filter */} - - Kategorie - - + setFilterKategorie(e.target.value as number | '')} + sx={{ minWidth: 200 }} + > + Alle + {kategorieOptions.map(k => {k.name})} + {canManageCategories && ( setKategorieDialogOpen(true)}> @@ -832,17 +841,16 @@ function KatalogTab() { setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} fullWidth /> setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} /> - - Kategorie - - + setArtikelForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : null }))} + fullWidth + > + Keine + {kategorieOptions.map(k => {k.name})} + {canManage && } @@ -887,8 +895,12 @@ function MeineAnfragenTab() { const [newItems, setNewItems] = useState([{ bezeichnung: '', menge: 1 }]); // Track loaded eigenschaften per item row (by artikel_id) const [itemEigenschaften, setItemEigenschaften] = useState>({}); + const itemEigenschaftenRef = useRef(itemEigenschaften); + itemEigenschaftenRef.current = itemEigenschaften; // Track eigenschaft values per item row index const [itemEigenschaftValues, setItemEigenschaftValues] = useState>>({}); + // Separate free-text items + const [newFreeItems, setNewFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]); const { data: requests = [], isLoading } = useQuery({ queryKey: ['ausruestungsanfrage', 'myRequests'], @@ -924,33 +936,42 @@ function MeineAnfragenTab() { setNewNotizen(''); setNewFuerBenutzer(null); setNewItems([{ bezeichnung: '', menge: 1 }]); + setNewFreeItems([]); setItemEigenschaften({}); setItemEigenschaftValues({}); }; const loadEigenschaften = useCallback(async (artikelId: number) => { - if (itemEigenschaften[artikelId]) return; + if (itemEigenschaftenRef.current[artikelId]) return; try { const eigs = await ausruestungsanfrageApi.getArtikelEigenschaften(artikelId); setItemEigenschaften(prev => ({ ...prev, [artikelId]: eigs })); } catch { /* ignore */ } - }, [itemEigenschaften]); + }, []); const handleCreateSubmit = () => { - const validItems = newItems.filter(i => i.bezeichnung.trim()).map((item, idx) => { + // Catalog items with eigenschaften + const catalogValidItems = newItems.filter(i => i.bezeichnung.trim() && i.artikel_id).map((item, idx) => { const vals = itemEigenschaftValues[idx] || {}; 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 }; }); - if (validItems.length === 0) return; - // Check required eigenschaften + // Free-text items + const freeValidItems = newFreeItems + .filter(i => i.bezeichnung.trim()) + .map(i => ({ bezeichnung: i.bezeichnung, menge: i.menge })); + + const allItems = [...catalogValidItems, ...freeValidItems]; + if (allItems.length === 0) return; + + // Check required eigenschaften for catalog items for (let idx = 0; idx < newItems.length; idx++) { const item = newItems[idx]; - if (!item.bezeichnung.trim()) continue; - if (item.artikel_id && itemEigenschaften[item.artikel_id]) { + if (!item.bezeichnung.trim() || !item.artikel_id) continue; + if (itemEigenschaften[item.artikel_id]) { for (const e of itemEigenschaften[item.artikel_id]) { if (e.pflicht && !(itemEigenschaftValues[idx]?.[e.id]?.trim())) { showError(`Pflichtfeld "${e.name}" für "${item.bezeichnung}" fehlt`); @@ -961,7 +982,7 @@ function MeineAnfragenTab() { } createMut.mutate({ - items: validItems, + items: allItems, notizen: newNotizen || undefined, bezeichnung: newBezeichnung || undefined, fuer_benutzer_id: newFuerBenutzer?.id, @@ -994,16 +1015,20 @@ function MeineAnfragenTab() { return ( - - Status - - + {filteredRequests.length === 0 ? ( @@ -1072,31 +1097,24 @@ function MeineAnfragenTab() { fullWidth /> - Positionen + Aus Katalog {newItems.map((item, idx) => ( - + typeof o === 'string' ? o : o.bezeichnung} - value={item.artikel_id ? catalogItems.find(c => c.id === item.artikel_id) || item.bezeichnung : item.bezeichnung} + value={item.artikel_id ? catalogItems.find(c => c.id === item.artikel_id) || null : null} onChange={(_, v) => { - if (typeof v === 'string') { - setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: v, artikel_id: undefined } : it)); - // Clear eigenschaften for this row - setItemEigenschaftValues(prev => { const n = { ...prev }; delete n[idx]; return n; }); - } else if (v) { + if (v && typeof v !== 'string') { setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it)); loadEigenschaften(v.id); + } else { + setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: undefined, bezeichnung: '' } : it)); + setItemEigenschaftValues(prev => { const n = { ...prev }; delete n[idx]; return n; }); } }} - onInputChange={(_, val, reason) => { - if (reason === 'input') { - setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: val, artikel_id: undefined } : it)); - } - }} - renderInput={params => } + renderInput={params => } sx={{ flexGrow: 1 }} /> ))} + + + Freitext-Positionen + {newFreeItems.length === 0 ? ( + Keine Freitext-Positionen. + ) : ( + newFreeItems.map((item, idx) => ( + + setNewFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: e.target.value } : it))} + sx={{ flexGrow: 1 }} + /> + setNewFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))} + sx={{ width: 90 }} + inputProps={{ min: 1 }} + /> + setNewFreeItems(prev => prev.filter((_, i) => i !== idx))}> + + + + )) + )} + @@ -1134,7 +1185,7 @@ function MeineAnfragenTab() { @@ -1203,15 +1254,19 @@ function AlleAnfragenTab() { - - Status Filter - - + setStatusFilter(e.target.value)} + sx={{ minWidth: 200, mb: 2 }} + > + Alle + {(Object.keys(AUSRUESTUNG_STATUS_LABELS) as AusruestungAnfrageStatus[]).map(s => ( + {AUSRUESTUNG_STATUS_LABELS[s]} + ))} + {requests.length === 0 ? ( Keine Anfragen vorhanden.