diff --git a/backend/src/controllers/ausruestungsanfrage.controller.ts b/backend/src/controllers/ausruestungsanfrage.controller.ts index c37597a..262546a 100644 --- a/backend/src/controllers/ausruestungsanfrage.controller.ts +++ b/backend/src/controllers/ausruestungsanfrage.controller.ts @@ -5,6 +5,70 @@ import { permissionService } from '../services/permission.service'; import logger from '../utils/logger'; class AusruestungsanfrageController { + // ------------------------------------------------------------------------- + // Categories (DB-backed) + // ------------------------------------------------------------------------- + + async getKategorien(_req: Request, res: Response): Promise { + try { + const kategorien = await ausruestungsanfrageService.getKategorien(); + res.status(200).json({ success: true, data: kategorien }); + } catch (error) { + logger.error('AusruestungsanfrageController.getKategorien error', { error }); + res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' }); + } + } + + async createKategorie(req: Request, res: Response): Promise { + try { + const { name } = req.body; + if (!name || name.trim().length === 0) { + res.status(400).json({ success: false, message: 'Name ist erforderlich' }); + return; + } + const kategorie = await ausruestungsanfrageService.createKategorie(name.trim()); + res.status(201).json({ success: true, data: kategorie }); + } catch (error: any) { + if (error?.constraint === 'ausruestung_kategorien_katalog_name_key') { + res.status(409).json({ success: false, message: 'Kategorie existiert bereits' }); + return; + } + logger.error('AusruestungsanfrageController.createKategorie error', { error }); + res.status(500).json({ success: false, message: 'Kategorie konnte nicht erstellt werden' }); + } + } + + async updateKategorie(req: Request, res: Response): Promise { + try { + const id = Number(req.params.id); + const { name } = req.body; + if (!name || name.trim().length === 0) { + res.status(400).json({ success: false, message: 'Name ist erforderlich' }); + return; + } + const kategorie = await ausruestungsanfrageService.updateKategorie(id, name.trim()); + if (!kategorie) { + res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: kategorie }); + } catch (error) { + logger.error('AusruestungsanfrageController.updateKategorie error', { error }); + res.status(500).json({ success: false, message: 'Kategorie konnte nicht aktualisiert werden' }); + } + } + + async deleteKategorie(req: Request, res: Response): Promise { + try { + const id = Number(req.params.id); + await ausruestungsanfrageService.deleteKategorie(id); + res.status(200).json({ success: true, message: 'Kategorie gelöscht' }); + } catch (error) { + logger.error('AusruestungsanfrageController.deleteKategorie error', { error }); + res.status(500).json({ success: false, message: 'Kategorie konnte nicht gelöscht werden' }); + } + } + // ------------------------------------------------------------------------- // Catalog Items // ------------------------------------------------------------------------- @@ -12,8 +76,9 @@ class AusruestungsanfrageController { async getItems(req: Request, res: Response): Promise { try { const kategorie = req.query.kategorie as string | undefined; + const kategorie_id = req.query.kategorie_id ? Number(req.query.kategorie_id) : undefined; const aktiv = req.query.aktiv !== undefined ? req.query.aktiv === 'true' : undefined; - const items = await ausruestungsanfrageService.getItems({ kategorie, aktiv }); + const items = await ausruestungsanfrageService.getItems({ kategorie, kategorie_id, aktiv }); res.status(200).json({ success: true, data: items }); } catch (error) { logger.error('AusruestungsanfrageController.getItems error', { error }); @@ -87,6 +152,59 @@ class AusruestungsanfrageController { } } + // ------------------------------------------------------------------------- + // Artikel Eigenschaften (characteristics) + // ------------------------------------------------------------------------- + + async getArtikelEigenschaften(req: Request, res: Response): Promise { + try { + const artikelId = Number(req.params.id); + const eigenschaften = await ausruestungsanfrageService.getArtikelEigenschaften(artikelId); + res.status(200).json({ success: true, data: eigenschaften }); + } catch (error) { + logger.error('AusruestungsanfrageController.getArtikelEigenschaften error', { error }); + res.status(500).json({ success: false, message: 'Eigenschaften konnten nicht geladen werden' }); + } + } + + async upsertArtikelEigenschaft(req: Request, res: Response): Promise { + try { + const artikelId = Number(req.params.id); + const { name, typ, optionen, pflicht, sort_order, eigenschaft_id } = req.body; + if (!name || name.trim().length === 0) { + res.status(400).json({ success: false, message: 'Name ist erforderlich' }); + return; + } + if (!typ || !['options', 'freitext'].includes(typ)) { + res.status(400).json({ success: false, message: 'Typ muss "options" oder "freitext" sein' }); + return; + } + const eigenschaft = await ausruestungsanfrageService.upsertArtikelEigenschaft(artikelId, { + id: eigenschaft_id, + name: name.trim(), + typ, + optionen: optionen || undefined, + pflicht: pflicht ?? false, + sort_order: sort_order ?? 0, + }); + res.status(eigenschaft_id ? 200 : 201).json({ success: true, data: eigenschaft }); + } catch (error) { + logger.error('AusruestungsanfrageController.upsertArtikelEigenschaft error', { error }); + res.status(500).json({ success: false, message: 'Eigenschaft konnte nicht gespeichert werden' }); + } + } + + async deleteArtikelEigenschaft(req: Request, res: Response): Promise { + try { + const id = Number(req.params.eigenschaftId); + await ausruestungsanfrageService.deleteArtikelEigenschaft(id); + res.status(200).json({ success: true, message: 'Eigenschaft gelöscht' }); + } catch (error) { + logger.error('AusruestungsanfrageController.deleteArtikelEigenschaft error', { error }); + res.status(500).json({ success: false, message: 'Eigenschaft konnte nicht gelöscht werden' }); + } + } + // ------------------------------------------------------------------------- // Requests // ------------------------------------------------------------------------- @@ -131,7 +249,7 @@ class AusruestungsanfrageController { async createRequest(req: Request, res: Response): Promise { try { const { items, notizen, bezeichnung, fuer_benutzer_id } = req.body as { - items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[]; + items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; @@ -179,7 +297,7 @@ class AusruestungsanfrageController { const { bezeichnung, notizen, items } = req.body as { bezeichnung?: string; notizen?: string; - items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[]; + items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[]; }; // Validate items if provided @@ -209,8 +327,8 @@ class AusruestungsanfrageController { // Check permission: owner + status=offen, OR ausruestungsanfrage:edit const groups = req.user?.groups ?? []; const canEditAny = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:edit'); - const isOwner = existing.anfrager_id === req.user!.id; - if (!canEditAny && !(isOwner && existing.status === 'offen')) { + const isOwner = existing.anfrage.anfrager_id === req.user!.id; + if (!canEditAny && !(isOwner && existing.anfrage.status === 'offen')) { res.status(403).json({ success: false, message: 'Keine Berechtigung zum Bearbeiten dieser Anfrage' }); return; } @@ -253,11 +371,11 @@ class AusruestungsanfrageController { // Notify requester on status changes if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) { - const orderLabel = existing.bestell_jahr && existing.bestell_nummer - ? `${existing.bestell_jahr}/${String(existing.bestell_nummer).padStart(3, '0')}` + const orderLabel = existing.anfrage.bestell_jahr && existing.anfrage.bestell_nummer + ? `${existing.anfrage.bestell_jahr}/${String(existing.anfrage.bestell_nummer).padStart(3, '0')}` : `#${id}`; await notificationService.createNotification({ - user_id: existing.anfrager_id, + user_id: existing.anfrage.anfrager_id, typ: 'ausruestung_anfrage', titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`, nachricht: `Deine Ausrüstungsanfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`, diff --git a/backend/src/database/migrations/048_katalog_eigenschaften.sql b/backend/src/database/migrations/048_katalog_eigenschaften.sql new file mode 100644 index 0000000..e6d8c4a --- /dev/null +++ b/backend/src/database/migrations/048_katalog_eigenschaften.sql @@ -0,0 +1,64 @@ +-- Migration 048: Catalog categories table + item characteristics +-- - Admin-managed categories (replacing free-text kategorie) +-- - Per-item characteristics (options or free-text) +-- - Characteristic values per request position +-- - Remove view_all permission (approve covers it) +-- - Add manage_categories permission + +-- 1. Categories table +CREATE TABLE IF NOT EXISTS ausruestung_kategorien_katalog ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + erstellt_am TIMESTAMPTZ DEFAULT NOW() +); + +-- Migrate existing categories from free-text +INSERT INTO ausruestung_kategorien_katalog (name) +SELECT DISTINCT kategorie FROM ausruestung_artikel WHERE kategorie IS NOT NULL AND kategorie != '' +ON CONFLICT DO NOTHING; + +-- Add kategorie_id FK to artikel +ALTER TABLE ausruestung_artikel ADD COLUMN IF NOT EXISTS kategorie_id INT REFERENCES ausruestung_kategorien_katalog(id) ON DELETE SET NULL; + +-- Populate kategorie_id from existing text values +UPDATE ausruestung_artikel a +SET kategorie_id = k.id +FROM ausruestung_kategorien_katalog k +WHERE k.name = a.kategorie AND a.kategorie_id IS NULL; + +-- 2. Characteristics definitions per catalog item +CREATE TABLE IF NOT EXISTS ausruestung_artikel_eigenschaften ( + id SERIAL PRIMARY KEY, + artikel_id INT NOT NULL REFERENCES ausruestung_artikel(id) ON DELETE CASCADE, + name TEXT NOT NULL, + typ TEXT NOT NULL DEFAULT 'options', + optionen TEXT[], + pflicht BOOLEAN DEFAULT FALSE, + sort_order INT DEFAULT 0, + UNIQUE(artikel_id, name) +); + +-- 3. Characteristic values filled per request position +CREATE TABLE IF NOT EXISTS ausruestung_position_eigenschaften ( + id SERIAL PRIMARY KEY, + position_id INT NOT NULL REFERENCES ausruestung_anfrage_positionen(id) ON DELETE CASCADE, + eigenschaft_id INT NOT NULL REFERENCES ausruestung_artikel_eigenschaften(id) ON DELETE CASCADE, + wert TEXT NOT NULL, + UNIQUE(position_id, eigenschaft_id) +); + +-- 4. Add manage_categories permission +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) +VALUES ('ausruestungsanfrage:manage_categories', 'ausruestungsanfrage', 'Kategorien verwalten', 'Katalog-Kategorien erstellen und bearbeiten', 5) +ON CONFLICT (id) DO NOTHING; + +-- Grant manage_categories to groups that have manage_catalog +INSERT INTO group_permissions (authentik_group, permission_id) +SELECT gp.authentik_group, 'ausruestungsanfrage:manage_categories' +FROM group_permissions gp +WHERE gp.permission_id = 'ausruestungsanfrage:manage_catalog' +ON CONFLICT DO NOTHING; + +-- 5. Remove view_all permission (approve covers the "Alle Anfragen" tab now) +DELETE FROM group_permissions WHERE permission_id = 'ausruestungsanfrage:view_all'; +DELETE FROM permissions WHERE id = 'ausruestungsanfrage:view_all'; diff --git a/backend/src/routes/ausruestungsanfrage.routes.ts b/backend/src/routes/ausruestungsanfrage.routes.ts index e7c74b5..09db3fa 100644 --- a/backend/src/routes/ausruestungsanfrage.routes.ts +++ b/backend/src/routes/ausruestungsanfrage.routes.ts @@ -5,6 +5,15 @@ import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); +// --------------------------------------------------------------------------- +// Categories (DB-backed CRUD) +// --------------------------------------------------------------------------- + +router.get('/kategorien', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getKategorien.bind(ausruestungsanfrageController)); +router.post('/kategorien', authenticate, requirePermission('ausruestungsanfrage:manage_categories'), ausruestungsanfrageController.createKategorie.bind(ausruestungsanfrageController)); +router.patch('/kategorien/:id', authenticate, requirePermission('ausruestungsanfrage:manage_categories'), ausruestungsanfrageController.updateKategorie.bind(ausruestungsanfrageController)); +router.delete('/kategorien/:id', authenticate, requirePermission('ausruestungsanfrage:manage_categories'), ausruestungsanfrageController.deleteKategorie.bind(ausruestungsanfrageController)); + // --------------------------------------------------------------------------- // Catalog Items // --------------------------------------------------------------------------- @@ -15,13 +24,19 @@ router.post('/items', authenticate, requirePermission('ausruestungsanfrage:manag router.patch('/items/:id', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.updateItem.bind(ausruestungsanfrageController)); router.delete('/items/:id', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.deleteItem.bind(ausruestungsanfrageController)); +// Item characteristics (Eigenschaften) +router.get('/items/:id/eigenschaften', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getArtikelEigenschaften.bind(ausruestungsanfrageController)); +router.post('/items/:id/eigenschaften', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.upsertArtikelEigenschaft.bind(ausruestungsanfrageController)); +router.delete('/eigenschaften/:eigenschaftId', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.deleteArtikelEigenschaft.bind(ausruestungsanfrageController)); + +// Legacy text-based categories router.get('/categories', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getCategories.bind(ausruestungsanfrageController)); // --------------------------------------------------------------------------- // Overview // --------------------------------------------------------------------------- -router.get('/overview', authenticate, requirePermission('ausruestungsanfrage:view_all'), ausruestungsanfrageController.getOverview.bind(ausruestungsanfrageController)); +router.get('/overview', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.getOverview.bind(ausruestungsanfrageController)); // --------------------------------------------------------------------------- // Requests diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index 6518fb8..de11f07 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -1,34 +1,90 @@ import pool from '../config/database'; import logger from '../utils/logger'; +// --------------------------------------------------------------------------- +// Categories (ausruestung_kategorien_katalog) +// --------------------------------------------------------------------------- + +async function getKategorien() { + const result = await pool.query( + 'SELECT * FROM ausruestung_kategorien_katalog ORDER BY name', + ); + return result.rows; +} + +async function createKategorie(name: string) { + const result = await pool.query( + 'INSERT INTO ausruestung_kategorien_katalog (name) VALUES ($1) RETURNING *', + [name], + ); + return result.rows[0]; +} + +async function updateKategorie(id: number, name: string) { + const result = await pool.query( + 'UPDATE ausruestung_kategorien_katalog SET name = $1 WHERE id = $2 RETURNING *', + [name, id], + ); + return result.rows[0] || null; +} + +async function deleteKategorie(id: number) { + await pool.query('DELETE FROM ausruestung_kategorien_katalog WHERE id = $1', [id]); +} + // --------------------------------------------------------------------------- // Catalog Items (ausruestung_artikel) // --------------------------------------------------------------------------- -async function getItems(filters?: { kategorie?: string; aktiv?: boolean }) { +async function getItems(filters?: { kategorie?: string; kategorie_id?: number; aktiv?: boolean }) { const conditions: string[] = []; const params: unknown[] = []; if (filters?.kategorie) { params.push(filters.kategorie); - conditions.push(`kategorie = $${params.length}`); + conditions.push(`a.kategorie = $${params.length}`); + } + if (filters?.kategorie_id) { + params.push(filters.kategorie_id); + conditions.push(`a.kategorie_id = $${params.length}`); } if (filters?.aktiv !== undefined) { params.push(filters.aktiv); - conditions.push(`aktiv = $${params.length}`); + conditions.push(`a.aktiv = $${params.length}`); } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query( - `SELECT * FROM ausruestung_artikel ${where} ORDER BY kategorie, bezeichnung`, + `SELECT a.*, k.name AS kategorie_name, + (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 + ${where} + ORDER BY COALESCE(k.name, a.kategorie), a.bezeichnung`, params, ); return result.rows; } async function getItemById(id: number) { - const result = await pool.query('SELECT * FROM ausruestung_artikel WHERE id = $1', [id]); - return result.rows[0] || null; + const result = await pool.query( + `SELECT a.*, k.name AS kategorie_name + FROM ausruestung_artikel a + 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], + ); + + return { + ...result.rows[0], + eigenschaften: eigenschaften.rows, + }; } async function createItem( @@ -36,16 +92,17 @@ async function createItem( bezeichnung: string; beschreibung?: string; kategorie?: string; + kategorie_id?: number; geschaetzter_preis?: number; aktiv?: boolean; }, userId: string, ) { const result = await pool.query( - `INSERT INTO ausruestung_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, aktiv, erstellt_von) - VALUES ($1, $2, $3, $4, COALESCE($5, true), $6) + `INSERT INTO ausruestung_artikel (bezeichnung, beschreibung, kategorie, kategorie_id, geschaetzter_preis, aktiv, erstellt_von) + VALUES ($1, $2, $3, $4, COALESCE($5, true), $6, $7) RETURNING *`, - [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId], + [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.kategorie_id || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId], ); return result.rows[0]; } @@ -56,6 +113,7 @@ async function updateItem( bezeichnung?: string; beschreibung?: string; kategorie?: string; + kategorie_id?: number | null; geschaetzter_preis?: number; aktiv?: boolean; }, @@ -76,6 +134,10 @@ async function updateItem( params.push(data.kategorie); fields.push(`kategorie = $${params.length}`); } + if (data.kategorie_id !== undefined) { + params.push(data.kategorie_id); + fields.push(`kategorie_id = $${params.length}`); + } if (data.geschaetzter_preis !== undefined) { params.push(data.geschaetzter_preis); fields.push(`geschaetzter_preis = $${params.length}`); @@ -111,6 +173,45 @@ async function getCategories() { return result.rows.map((r: { kategorie: string }) => r.kategorie); } +// --------------------------------------------------------------------------- +// Artikel Eigenschaften (characteristics) +// --------------------------------------------------------------------------- + +async function getArtikelEigenschaften(artikelId: number) { + const result = await pool.query( + 'SELECT * FROM ausruestung_artikel_eigenschaften WHERE artikel_id = $1 ORDER BY sort_order, id', + [artikelId], + ); + return result.rows; +} + +async function upsertArtikelEigenschaft( + artikelId: number, + data: { id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }, +) { + if (data.id) { + const result = await pool.query( + `UPDATE ausruestung_artikel_eigenschaften + SET name = $1, typ = $2, optionen = $3, pflicht = $4, sort_order = $5 + WHERE id = $6 AND artikel_id = $7 + RETURNING *`, + [data.name, data.typ, data.optionen || null, data.pflicht ?? false, data.sort_order ?? 0, data.id, artikelId], + ); + return result.rows[0] || null; + } + const result = await pool.query( + `INSERT INTO ausruestung_artikel_eigenschaften (artikel_id, name, typ, optionen, pflicht, sort_order) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [artikelId, data.name, data.typ, data.optionen || null, data.pflicht ?? false, data.sort_order ?? 0], + ); + return result.rows[0]; +} + +async function deleteArtikelEigenschaft(id: number) { + await pool.query('DELETE FROM ausruestung_artikel_eigenschaften WHERE id = $1', [id]); +} + // --------------------------------------------------------------------------- // Requests (ausruestung_anfragen) // --------------------------------------------------------------------------- @@ -178,6 +279,33 @@ async function getRequestById(id: number) { [id], ); + // Load eigenschaft values per position + const positionIds = positionen.rows.map((p: { id: number }) => p.id); + let eigenschaftenMap: Record = {}; + if (positionIds.length > 0) { + const eigenschaftenResult = await pool.query( + `SELECT pe.position_id, pe.eigenschaft_id, ae.name AS eigenschaft_name, pe.wert + FROM ausruestung_position_eigenschaften pe + JOIN ausruestung_artikel_eigenschaften ae ON ae.id = pe.eigenschaft_id + WHERE pe.position_id = ANY($1) + ORDER BY ae.sort_order, ae.id`, + [positionIds], + ); + for (const row of eigenschaftenResult.rows) { + if (!eigenschaftenMap[row.position_id]) eigenschaftenMap[row.position_id] = []; + eigenschaftenMap[row.position_id].push({ + eigenschaft_id: row.eigenschaft_id, + eigenschaft_name: row.eigenschaft_name, + wert: row.wert, + }); + } + } + + const positionenWithEigenschaften = positionen.rows.map((p: { id: number }) => ({ + ...p, + eigenschaften: eigenschaftenMap[p.id] || [], + })); + const bestellungen = await pool.query( `SELECT b.* FROM ausruestung_anfrage_bestellung ab @@ -187,15 +315,15 @@ async function getRequestById(id: number) { ); return { - ...reqResult.rows[0], - positionen: positionen.rows, + anfrage: reqResult.rows[0], + positionen: positionenWithEigenschaften, linked_bestellungen: bestellungen.rows, }; } async function createRequest( userId: string, - items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[], + items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[], notizen?: string, bezeichnung?: string, ) { @@ -222,7 +350,7 @@ async function createRequest( const anfrage = anfrageResult.rows[0]; for (const item of items) { - let bezeichnung = item.bezeichnung; + let itemBezeichnung = item.bezeichnung; // If artikel_id is provided, copy bezeichnung from catalog if (item.artikel_id) { @@ -231,15 +359,28 @@ async function createRequest( [item.artikel_id], ); if (artikelResult.rows.length > 0) { - bezeichnung = artikelResult.rows[0].bezeichnung; + itemBezeichnung = artikelResult.rows[0].bezeichnung; } } - await client.query( + const posResult = await client.query( `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen) - VALUES ($1, $2, $3, $4, $5)`, - [anfrage.id, item.artikel_id || null, bezeichnung, item.menge, item.notizen || null], + VALUES ($1, $2, $3, $4, $5) + RETURNING id`, + [anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null], ); + + // Save eigenschaft values + if (item.eigenschaften && item.eigenschaften.length > 0) { + for (const e of item.eigenschaften) { + await client.query( + `INSERT INTO ausruestung_position_eigenschaften (position_id, eigenschaft_id, wert) + VALUES ($1, $2, $3) + ON CONFLICT (position_id, eigenschaft_id) DO UPDATE SET wert = $3`, + [posResult.rows[0].id, e.eigenschaft_id, e.wert], + ); + } + } } await client.query('COMMIT'); @@ -258,7 +399,7 @@ async function updateRequest( data: { bezeichnung?: string; notizen?: string; - items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[]; + items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[]; }, ) { const client = await pool.connect(); @@ -292,21 +433,34 @@ async function updateRequest( if (data.items) { await client.query('DELETE FROM ausruestung_anfrage_positionen WHERE anfrage_id = $1', [id]); for (const item of data.items) { - let bezeichnung = item.bezeichnung; + let itemBezeichnung = item.bezeichnung; if (item.artikel_id) { const artikelResult = await client.query( 'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1', [item.artikel_id], ); if (artikelResult.rows.length > 0) { - bezeichnung = artikelResult.rows[0].bezeichnung; + itemBezeichnung = artikelResult.rows[0].bezeichnung; } } - await client.query( + const posResult = await client.query( `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen) - VALUES ($1, $2, $3, $4, $5)`, - [id, item.artikel_id || null, bezeichnung, item.menge, item.notizen || null], + VALUES ($1, $2, $3, $4, $5) + RETURNING id`, + [id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null], ); + + // Save eigenschaft values + if (item.eigenschaften && item.eigenschaften.length > 0) { + for (const e of item.eigenschaften) { + await client.query( + `INSERT INTO ausruestung_position_eigenschaften (position_id, eigenschaft_id, wert) + VALUES ($1, $2, $3) + ON CONFLICT (position_id, eigenschaft_id) DO UPDATE SET wert = $3`, + [posResult.rows[0].id, e.eigenschaft_id, e.wert], + ); + } + } } } @@ -395,6 +549,7 @@ async function getOverview() { `SELECT COUNT(*) FILTER (WHERE status = 'offen')::int AS pending_count, COUNT(*) FILTER (WHERE status = 'genehmigt')::int AS approved_count, + COUNT(*) FILTER (WHERE status = 'offen' AND bearbeitet_von IS NULL)::int AS unhandled_count, COALESCE(SUM(sub.total), 0)::int AS total_items FROM ausruestung_anfragen a LEFT JOIN LATERAL ( @@ -412,12 +567,19 @@ async function getOverview() { } export default { + getKategorien, + createKategorie, + updateKategorie, + deleteKategorie, getItems, getItemById, createItem, updateItem, deleteItem, getCategories, + getArtikelEigenschaften, + upsertArtikelEigenschaft, + deleteArtikelEigenschaft, getRequests, getMyRequests, getRequestById, diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index a796e4a..988cdb6 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -100,8 +100,8 @@ const PERMISSION_SUB_GROUPS: Record> = { 'Widget': ['widget'], }, ausruestungsanfrage: { - 'Katalog': ['view', 'manage_catalog'], - 'Anfragen': ['create_request', 'approve', 'link_orders', 'view_all', 'order_for_user', 'edit'], + 'Katalog': ['view', 'manage_catalog', 'manage_categories'], + 'Anfragen': ['create_request', 'approve', 'link_orders', 'order_for_user', 'edit'], 'Widget': ['widget'], }, admin: { diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index 5e182e0..5c09aaa 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -190,7 +190,6 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { let ausruestungTabIdx = 0; if (hasPermission('ausruestungsanfrage:create_request')) { ausruestungSubItems.push({ text: 'Meine Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; } if (hasPermission('ausruestungsanfrage:approve')) { ausruestungSubItems.push({ text: 'Alle Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; } - if (hasPermission('ausruestungsanfrage:view_all')) { ausruestungSubItems.push({ text: 'Übersicht', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; } ausruestungSubItems.push({ text: 'Katalog', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); // Build Issues sub-items dynamically (tab order must match Issues.tsx) diff --git a/frontend/src/pages/Ausruestungsanfrage.tsx b/frontend/src/pages/Ausruestungsanfrage.tsx index 0694f75..6c3060d 100644 --- a/frontend/src/pages/Ausruestungsanfrage.tsx +++ b/frontend/src/pages/Ausruestungsanfrage.tsx @@ -1,14 +1,14 @@ -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import { - Box, Tab, Tabs, Typography, Card, CardContent, CardActions, Grid, Button, Chip, + Box, Tab, Tabs, Typography, Grid, Button, Chip, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, MenuItem, Select, FormControl, InputLabel, Autocomplete, - Divider, + Divider, Checkbox, FormControlLabel, Tooltip, } from '@mui/material'; import { - Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, ShoppingCart, - Check as CheckIcon, Close as CloseIcon, Link as LinkIcon, + Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, + Check as CheckIcon, Close as CloseIcon, Link as LinkIcon, Settings as SettingsIcon, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useSearchParams } from 'react-router-dom'; @@ -16,10 +16,15 @@ import DashboardLayout from '../components/dashboard/DashboardLayout'; import ChatAwareFab from '../components/shared/ChatAwareFab'; import { useNotification } from '../contexts/NotificationContext'; import { usePermissionContext } from '../contexts/PermissionContext'; +import { useAuth } from '../contexts/AuthContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { bestellungApi } from '../services/bestellung'; import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types'; -import type { AusruestungArtikel, AusruestungArtikelFormData, AusruestungAnfrageFormItem, AusruestungAnfrageDetailResponse, AusruestungAnfrageStatus, AusruestungAnfrage, AusruestungOverview } from '../types/ausruestungsanfrage.types'; +import type { + AusruestungArtikel, AusruestungArtikelFormData, AusruestungAnfrageFormItem, + AusruestungAnfrageDetailResponse, AusruestungAnfrageStatus, AusruestungAnfrage, + AusruestungOverview, AusruestungEigenschaft, +} from '../types/ausruestungsanfrage.types'; import type { Bestellung } from '../types/bestellung.types'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -33,6 +38,247 @@ function formatOrderId(r: AusruestungAnfrage): string { const ACTIVE_STATUSES: AusruestungAnfrageStatus[] = ['offen', 'genehmigt', 'bestellt']; +// ─── Eigenschaft Fields Component ──────────────────────────────────────────── + +interface EigenschaftFieldsProps { + eigenschaften: AusruestungEigenschaft[]; + values: Record; + onChange: (eigenschaftId: number, wert: string) => void; +} + +function EigenschaftFields({ eigenschaften, values, onChange }: EigenschaftFieldsProps) { + if (eigenschaften.length === 0) return null; + return ( + + {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 }} + /> + )} + + ))} + + ); +} + +// ─── Category Management Dialog ────────────────────────────────────────────── + +interface KategorieDialogProps { + open: boolean; + onClose: () => void; +} + +function KategorieDialog({ open, onClose }: KategorieDialogProps) { + const { showSuccess, showError } = useNotification(); + const queryClient = useQueryClient(); + const [newName, setNewName] = useState(''); + const [editId, setEditId] = useState(null); + const [editName, setEditName] = useState(''); + + const { data: kategorien = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'kategorien'], + queryFn: () => ausruestungsanfrageApi.getKategorien(), + enabled: open, + }); + + const createMut = useMutation({ + mutationFn: (name: string) => ausruestungsanfrageApi.createKategorie(name), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie erstellt'); setNewName(''); }, + onError: () => showError('Fehler beim Erstellen'), + }); + + const updateMut = useMutation({ + mutationFn: ({ id, name }: { id: number; name: string }) => ausruestungsanfrageApi.updateKategorie(id, name), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie aktualisiert'); setEditId(null); }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const deleteMut = useMutation({ + mutationFn: (id: number) => ausruestungsanfrageApi.deleteKategorie(id), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Kategorie gelöscht'); }, + onError: () => showError('Fehler beim Löschen'), + }); + + return ( + + Kategorien verwalten + + + setNewName(e.target.value)} + sx={{ flexGrow: 1 }} + onKeyDown={e => { if (e.key === 'Enter' && newName.trim()) createMut.mutate(newName.trim()); }} + /> + + + + {kategorien.length === 0 ? ( + Keine Kategorien vorhanden. + ) : ( + kategorien.map(k => ( + + {editId === k.id ? ( + <> + setEditName(e.target.value)} + sx={{ flexGrow: 1 }} + onKeyDown={e => { if (e.key === 'Enter' && editName.trim()) updateMut.mutate({ id: k.id, name: editName.trim() }); }} + /> + { if (editName.trim()) updateMut.mutate({ id: k.id, name: editName.trim() }); }}> + setEditId(null)}> + + ) : ( + <> + {k.name} + { setEditId(k.id); setEditName(k.name); }}> + deleteMut.mutate(k.id)}> + + )} + + )) + )} + + + + + + ); +} + +// ─── Eigenschaften Editor (in Artikel dialog) ──────────────────────────────── + +interface EigenschaftenEditorProps { + artikelId: number | null; +} + +function EigenschaftenEditor({ artikelId }: EigenschaftenEditorProps) { + const { showSuccess, showError } = useNotification(); + const queryClient = useQueryClient(); + + const [newName, setNewName] = useState(''); + const [newTyp, setNewTyp] = useState<'options' | 'freitext'>('options'); + const [newOptionen, setNewOptionen] = useState(''); + const [newPflicht, setNewPflicht] = useState(false); + + const { data: eigenschaften = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId], + queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(artikelId!), + enabled: artikelId != null, + }); + + const upsertMut = useMutation({ + mutationFn: (data: { eigenschaft_id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }) => + ausruestungsanfrageApi.upsertArtikelEigenschaft(artikelId!, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gespeichert'); }, + onError: () => showError('Fehler beim Speichern'), + }); + + const deleteMut = useMutation({ + mutationFn: (id: number) => ausruestungsanfrageApi.deleteArtikelEigenschaft(id), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'eigenschaften', artikelId] }); showSuccess('Eigenschaft gelöscht'); }, + onError: () => showError('Fehler beim Löschen'), + }); + + const handleAdd = () => { + if (!newName.trim()) return; + const optionen = newTyp === 'options' ? newOptionen.split(',').map(s => s.trim()).filter(Boolean) : undefined; + upsertMut.mutate({ + name: newName.trim(), + typ: newTyp, + optionen, + pflicht: newPflicht, + sort_order: eigenschaften.length, + }); + setNewName(''); + setNewOptionen(''); + setNewPflicht(false); + }; + + if (artikelId == null) return Bitte speichern Sie den Artikel zuerst, bevor Sie Eigenschaften hinzufügen.; + + return ( + + Eigenschaften + {eigenschaften.map(e => ( + + + {e.name} ({e.typ === 'options' ? `Auswahl: ${e.optionen?.join(', ')}` : 'Freitext'}) + {e.pflicht && } + + deleteMut.mutate(e.id)}> + + ))} + + + setNewName(e.target.value)} sx={{ flexGrow: 1 }} /> + + Typ + + + setNewPflicht(e.target.checked)} />} + label="Pflicht" + /> + + {newTyp === 'options' && ( + setNewOptionen(e.target.value)} + placeholder="S, M, L, XL" + fullWidth + /> + )} + + + + ); +} + // ─── Detail Modal ───────────────────────────────────────────────────────────── interface DetailModalProps { @@ -123,6 +369,7 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can bezeichnung: p.bezeichnung, menge: p.menge, notizen: p.notizen, + eigenschaften: p.eigenschaften?.map(e => ({ eigenschaft_id: e.eigenschaft_id, wert: e.wert })), }))); setEditing(true); }; @@ -257,9 +504,20 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can Positionen {detail.positionen.map(p => ( - - - {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''} - + + + - {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''} + + {p.eigenschaften && p.eigenschaften.length > 0 && ( + + {p.eigenschaften.map(e => ( + + {e.eigenschaft_name}: {e.wert} + + ))} + + )} + ))} {detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && ( @@ -417,20 +675,22 @@ function KatalogTab() { const queryClient = useQueryClient(); const canManage = hasPermission('ausruestungsanfrage:manage_catalog'); + const canManageCategories = hasPermission('ausruestungsanfrage:manage_categories'); - const [filterKategorie, setFilterKategorie] = useState(''); + const [filterKategorie, setFilterKategorie] = useState(''); const [artikelDialogOpen, setArtikelDialogOpen] = useState(false); const [editArtikel, setEditArtikel] = useState(null); const [artikelForm, setArtikelForm] = useState({ bezeichnung: '' }); + const [kategorieDialogOpen, setKategorieDialogOpen] = useState(false); const { data: items = [], isLoading } = useQuery({ queryKey: ['ausruestungsanfrage', 'items', filterKategorie], - queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie: filterKategorie } : undefined), + queryFn: () => ausruestungsanfrageApi.getItems(filterKategorie ? { kategorie_id: filterKategorie as number } : undefined), }); - const { data: categories = [] } = useQuery({ - queryKey: ['ausruestungsanfrage', 'categories'], - queryFn: () => ausruestungsanfrageApi.getCategories(), + const { data: kategorien = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'kategorien'], + queryFn: () => ausruestungsanfrageApi.getKategorien(), }); const createItemMut = useMutation({ @@ -456,7 +716,7 @@ function KatalogTab() { }; const openEditArtikel = (a: AusruestungArtikel) => { setEditArtikel(a); - setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie: a.kategorie }); + setArtikelForm({ bezeichnung: a.bezeichnung, beschreibung: a.beschreibung, kategorie_id: a.kategorie_id ?? null }); setArtikelDialogOpen(true); }; const saveArtikel = () => { @@ -471,49 +731,62 @@ function KatalogTab() { Kategorie - setFilterKategorie(e.target.value as number | '')}> Alle - {categories.map(c => {c})} + {kategorien.map(k => {k.name})} + {canManageCategories && ( + + setKategorieDialogOpen(true)}> + + + + )} - {/* Catalog grid */} + {/* Catalog table */} {isLoading ? ( Lade Katalog... ) : items.length === 0 ? ( Keine Artikel vorhanden. ) : ( - - {items.map(item => ( - - - {item.bild_pfad ? ( - - ) : ( - - - - )} - - {item.bezeichnung} - {item.beschreibung && {item.beschreibung}} - {item.kategorie && ( - - + + + + + Bezeichnung + Kategorie + Beschreibung + {canManage && Aktionen} + + + + {items.map(item => ( + + + + {item.bezeichnung} + {(item.eigenschaften_count ?? 0) > 0 && ( + + )} + + {item.kategorie_name || item.kategorie || '-'} + + {item.beschreibung || '-'} + + {canManage && ( + + openEditArtikel(item)}> + deleteItemMut.mutate(item.id)}> + )} - - {canManage && ( - - openEditArtikel(item)}> - deleteItemMut.mutate(item.id)}> - - )} - - - ))} - + + ))} + +
+
)} {/* Artikel create/edit dialog */} @@ -522,13 +795,18 @@ function KatalogTab() { setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} fullWidth /> setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} /> - setArtikelForm(f => ({ ...f, kategorie: val || undefined }))} - renderInput={params => } - /> + + Kategorie + + + {canManage && } @@ -538,6 +816,9 @@ function KatalogTab() { + {/* Kategorie management dialog */} + setKategorieDialogOpen(false)} /> + {/* FAB for new catalog item */} {canManage && ( @@ -552,6 +833,7 @@ function KatalogTab() { function MeineAnfragenTab() { const { hasPermission } = usePermissionContext(); + const { user } = useAuth(); const queryClient = useQueryClient(); const { showSuccess, showError } = useNotification(); @@ -566,6 +848,10 @@ function MeineAnfragenTab() { const [newNotizen, setNewNotizen] = useState(''); const [newFuerBenutzer, setNewFuerBenutzer] = useState<{ id: string; name: string } | null>(null); const [newItems, setNewItems] = useState([{ bezeichnung: '', menge: 1 }]); + // Track loaded eigenschaften per item row (by artikel_id) + const [itemEigenschaften, setItemEigenschaften] = useState>({}); + // Track eigenschaft values per item row index + const [itemEigenschaftValues, setItemEigenschaftValues] = useState>>({}); const { data: requests = [], isLoading } = useQuery({ queryKey: ['ausruestungsanfrage', 'myRequests'], @@ -601,11 +887,42 @@ function MeineAnfragenTab() { setNewNotizen(''); setNewFuerBenutzer(null); setNewItems([{ bezeichnung: '', menge: 1 }]); + setItemEigenschaften({}); + setItemEigenschaftValues({}); }; + const loadEigenschaften = useCallback(async (artikelId: number) => { + if (itemEigenschaften[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()); + const validItems = newItems.filter(i => i.bezeichnung.trim()).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 + 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]) { + 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`); + return; + } + } + } + } + createMut.mutate({ items: validItems, notizen: newNotizen || undefined, @@ -687,6 +1004,7 @@ function MeineAnfragenTab() { onClose={() => setDetailId(null)} showEditButton canEditAny={canEditAny} + currentUserId={user?.id} /> {/* Create Request Dialog */} @@ -719,39 +1037,55 @@ function MeineAnfragenTab() { Positionen {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} - onChange={(_, v) => { - if (typeof v === 'string') { - setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: v, artikel_id: undefined } : it)); - } else if (v) { - setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it)); - } - }} - onInputChange={(_, val, reason) => { - if (reason === 'input') { - setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: val, artikel_id: undefined } : it)); - } - }} - renderInput={params => } - sx={{ flexGrow: 1 }} - /> - setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))} - sx={{ width: 90 }} - inputProps={{ min: 1 }} - /> - setNewItems(prev => prev.filter((_, i) => i !== idx))} disabled={newItems.length <= 1}> - - + + + typeof o === 'string' ? o : o.bezeichnung} + value={item.artikel_id ? catalogItems.find(c => c.id === item.artikel_id) || item.bezeichnung : item.bezeichnung} + 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) { + setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it)); + loadEigenschaften(v.id); + } + }} + onInputChange={(_, val, reason) => { + if (reason === 'input') { + setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: val, artikel_id: undefined } : it)); + } + }} + renderInput={params => } + sx={{ flexGrow: 1 }} + /> + setNewItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value)) } : it))} + sx={{ width: 90 }} + inputProps={{ min: 1 }} + /> + setNewItems(prev => prev.filter((_, i) => i !== idx))} disabled={newItems.length <= 1}> + + + + {/* Eigenschaft fields for this item */} + {item.artikel_id && itemEigenschaften[item.artikel_id] && itemEigenschaften[item.artikel_id].length > 0 && ( + setItemEigenschaftValues(prev => ({ + ...prev, + [idx]: { ...(prev[idx] || {}), [eid]: wert }, + }))} + /> + )} ))}