rework internal order system

This commit is contained in:
Matthias Hochmeister
2026-03-24 09:15:57 +01:00
parent 6ff5cc89ad
commit 39b8b30ca2
2 changed files with 166 additions and 88 deletions

View File

@@ -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<string>();
async function tableExists(tableName: string): Promise<boolean> {
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

View File

@@ -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 => (
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{e.typ === 'options' && e.optionen && e.optionen.length > 0 ? (
<FormControl size="small" sx={{ minWidth: 160 }} required={e.pflicht}>
<InputLabel>{e.name}</InputLabel>
<Select
value={values[e.id] || ''}
label={e.name}
onChange={ev => onChange(e.id, ev.target.value)}
>
<MenuItem value=""></MenuItem>
{e.optionen.map(opt => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
<TextField
select
size="small"
label={e.name}
value={values[e.id] || ''}
onChange={ev => onChange(e.id, ev.target.value)}
required={e.pflicht}
sx={{ minWidth: 160 }}
>
<MenuItem value=""></MenuItem>
{e.optionen.map(opt => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</TextField>
) : (
<TextField
size="small"
@@ -268,13 +269,17 @@ function EigenschaftenEditor({ artikelId }: EigenschaftenEditorProps) {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1, p: 1, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField size="small" label="Name" value={newName} onChange={e => setNewName(e.target.value)} sx={{ flexGrow: 1 }} />
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Typ</InputLabel>
<Select value={newTyp} label="Typ" onChange={e => setNewTyp(e.target.value as 'options' | 'freitext')}>
<MenuItem value="options">Auswahl</MenuItem>
<MenuItem value="freitext">Freitext</MenuItem>
</Select>
</FormControl>
<TextField
select
size="small"
label="Typ"
value={newTyp}
onChange={e => setNewTyp(e.target.value as 'options' | 'freitext')}
sx={{ minWidth: 120 }}
>
<MenuItem value="options">Auswahl</MenuItem>
<MenuItem value="freitext">Freitext</MenuItem>
</TextField>
<FormControlLabel
control={<Checkbox size="small" checked={newPflicht} onChange={e => setNewPflicht(e.target.checked)} />}
label="Pflicht"
@@ -766,13 +771,17 @@ function KatalogTab() {
<Box>
{/* Filter */}
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Kategorie</InputLabel>
<Select value={filterKategorie} label="Kategorie" onChange={e => setFilterKategorie(e.target.value as number | '')}>
<MenuItem value="">Alle</MenuItem>
{kategorieOptions.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</Select>
</FormControl>
<TextField
select
size="small"
label="Kategorie"
value={filterKategorie}
onChange={e => setFilterKategorie(e.target.value as number | '')}
sx={{ minWidth: 200 }}
>
<MenuItem value="">Alle</MenuItem>
{kategorieOptions.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</TextField>
{canManageCategories && (
<Tooltip title="Kategorien verwalten">
<IconButton size="small" onClick={() => setKategorieDialogOpen(true)}>
@@ -832,17 +841,16 @@ function KatalogTab() {
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<TextField label="Bezeichnung" required value={artikelForm.bezeichnung} onChange={e => setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} fullWidth />
<TextField label="Beschreibung" multiline rows={2} value={artikelForm.beschreibung ?? ''} onChange={e => setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} />
<FormControl fullWidth>
<InputLabel>Kategorie</InputLabel>
<Select
value={artikelForm.kategorie_id ?? ''}
label="Kategorie"
onChange={e => setArtikelForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : null }))}
>
<MenuItem value="">Keine</MenuItem>
{kategorieOptions.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</Select>
</FormControl>
<TextField
select
label="Kategorie"
value={artikelForm.kategorie_id ?? ''}
onChange={e => setArtikelForm(f => ({ ...f, kategorie_id: e.target.value ? Number(e.target.value) : null }))}
fullWidth
>
<MenuItem value="">Keine</MenuItem>
{kategorieOptions.map(k => <MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>)}
</TextField>
{canManage && <EigenschaftenEditor artikelId={editArtikel?.id ?? null} />}
</DialogContent>
<DialogActions>
@@ -887,8 +895,12 @@ function MeineAnfragenTab() {
const [newItems, setNewItems] = useState<AusruestungAnfrageFormItem[]>([{ bezeichnung: '', menge: 1 }]);
// Track loaded eigenschaften per item row (by artikel_id)
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
const itemEigenschaftenRef = useRef(itemEigenschaften);
itemEigenschaftenRef.current = itemEigenschaften;
// Track eigenschaft values per item row index
const [itemEigenschaftValues, setItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
// 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 (
<Box>
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Status</InputLabel>
<Select value={currentFilterValue} label="Status" onChange={e => handleStatusFilterChange(e.target.value)}>
<TextField
select
size="small"
label="Status"
value={currentFilterValue}
onChange={e => handleStatusFilterChange(e.target.value)}
sx={{ minWidth: 200 }}
>
<MenuItem value="active">Aktive Anfragen</MenuItem>
<MenuItem value="all">Alle</MenuItem>
{(Object.keys(AUSRUESTUNG_STATUS_LABELS) as AusruestungAnfrageStatus[]).map(s => (
<MenuItem key={s} value={s}>{AUSRUESTUNG_STATUS_LABELS[s]}</MenuItem>
))}
</Select>
</FormControl>
</TextField>
</Box>
{filteredRequests.length === 0 ? (
@@ -1072,31 +1097,24 @@ function MeineAnfragenTab() {
fullWidth
/>
<Divider />
<Typography variant="subtitle2">Positionen</Typography>
<Typography variant="subtitle2">Aus Katalog</Typography>
{newItems.map((item, idx) => (
<Box key={idx}>
<Box key={`cat-${idx}`}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Autocomplete
freeSolo
options={catalogItems}
getOptionLabel={o => 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 => <TextField {...params} label="Artikel" size="small" />}
renderInput={params => <TextField {...params} label="Katalogartikel" size="small" />}
sx={{ flexGrow: 1 }}
/>
<TextField
@@ -1126,7 +1144,40 @@ function MeineAnfragenTab() {
</Box>
))}
<Button size="small" startIcon={<AddIcon />} onClick={() => setNewItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
Position hinzufügen
Katalogartikel hinzufügen
</Button>
<Divider />
<Typography variant="subtitle2">Freitext-Positionen</Typography>
{newFreeItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine Freitext-Positionen.</Typography>
) : (
newFreeItems.map((item, idx) => (
<Box key={`free-${idx}`} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
size="small"
label="Bezeichnung"
value={item.bezeichnung}
onChange={e => setNewFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: e.target.value } : it))}
sx={{ flexGrow: 1 }}
/>
<TextField
size="small"
type="number"
label="Menge"
value={item.menge}
onChange={e => setNewFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))}
sx={{ width: 90 }}
inputProps={{ min: 1 }}
/>
<IconButton size="small" onClick={() => setNewFreeItems(prev => prev.filter((_, i) => i !== idx))}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
))
)}
<Button size="small" startIcon={<AddIcon />} onClick={() => setNewFreeItems(prev => [...prev, { bezeichnung: '', menge: 1 }])}>
Freitext-Position hinzufügen
</Button>
</DialogContent>
<DialogActions>
@@ -1134,7 +1185,7 @@ function MeineAnfragenTab() {
<Button
variant="contained"
onClick={handleCreateSubmit}
disabled={createMut.isPending || newItems.every(i => !i.bezeichnung.trim())}
disabled={createMut.isPending || (newItems.every(i => !i.artikel_id) && newFreeItems.every(i => !i.bezeichnung.trim()))}
>
Anfrage erstellen
</Button>
@@ -1203,15 +1254,19 @@ function AlleAnfragenTab() {
</Grid>
</Grid>
<FormControl size="small" sx={{ minWidth: 200, mb: 2 }}>
<InputLabel>Status Filter</InputLabel>
<Select value={statusFilter} label="Status Filter" onChange={e => setStatusFilter(e.target.value)}>
<MenuItem value="">Alle</MenuItem>
{(Object.keys(AUSRUESTUNG_STATUS_LABELS) as AusruestungAnfrageStatus[]).map(s => (
<MenuItem key={s} value={s}>{AUSRUESTUNG_STATUS_LABELS[s]}</MenuItem>
))}
</Select>
</FormControl>
<TextField
select
size="small"
label="Status Filter"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
sx={{ minWidth: 200, mb: 2 }}
>
<MenuItem value="">Alle</MenuItem>
{(Object.keys(AUSRUESTUNG_STATUS_LABELS) as AusruestungAnfrageStatus[]).map(s => (
<MenuItem key={s} value={s}>{AUSRUESTUNG_STATUS_LABELS[s]}</MenuItem>
))}
</TextField>
{requests.length === 0 ? (
<Typography color="text.secondary">Keine Anfragen vorhanden.</Typography>