From 29d66e37a130d0b1822e6751bd51d03ca7aded1f Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 27 Mar 2026 14:50:31 +0100 Subject: [PATCH] shared catalog in Bestellungen, catalog picker in line items, Ersatzbeschaffung flag, vendor detail flash fix --- .../ausruestungsanfrage.controller.ts | 23 ++++ .../src/controllers/bestellung.controller.ts | 45 +++++++ .../065_shared_catalog_and_request_type.sql | 9 ++ .../src/routes/ausruestungsanfrage.routes.ts | 1 + backend/src/routes/bestellung.routes.ts | 25 ++++ .../services/ausruestungsanfrage.service.ts | 56 ++++++-- backend/src/services/bestellung.service.ts | 52 +++++++- .../src/pages/AusruestungsanfrageDetail.tsx | 59 +++++++-- .../pages/AusruestungsanfrageZuBestellung.tsx | 4 + frontend/src/pages/BestellungDetail.tsx | 124 +++++++++++++++++- frontend/src/pages/Bestellungen.tsx | 104 ++++++++++++++- frontend/src/pages/LieferantDetail.tsx | 6 +- frontend/src/services/ausruestungsanfrage.ts | 4 + frontend/src/services/bestellung.ts | 18 +++ .../src/types/ausruestungsanfrage.types.ts | 5 + frontend/src/types/bestellung.types.ts | 3 + 16 files changed, 506 insertions(+), 32 deletions(-) create mode 100644 backend/src/database/migrations/065_shared_catalog_and_request_type.sql diff --git a/backend/src/controllers/ausruestungsanfrage.controller.ts b/backend/src/controllers/ausruestungsanfrage.controller.ts index 425ad56..598c11f 100644 --- a/backend/src/controllers/ausruestungsanfrage.controller.ts +++ b/backend/src/controllers/ausruestungsanfrage.controller.ts @@ -460,6 +460,29 @@ class AusruestungsanfrageController { } } + async updatePositionZurueckgegeben(req: Request, res: Response): Promise { + try { + const positionId = Number(req.params.positionId); + const { altes_geraet_zurueckgegeben } = req.body as { altes_geraet_zurueckgegeben?: boolean }; + + if (typeof altes_geraet_zurueckgegeben !== 'boolean') { + res.status(400).json({ success: false, message: 'altes_geraet_zurueckgegeben (boolean) ist erforderlich' }); + return; + } + + const position = await ausruestungsanfrageService.updatePositionZurueckgegeben(positionId, altes_geraet_zurueckgegeben); + if (!position) { + res.status(404).json({ success: false, message: 'Position nicht gefunden' }); + return; + } + + res.status(200).json({ success: true, data: position }); + } catch (error) { + logger.error('AusruestungsanfrageController.updatePositionZurueckgegeben error', { error }); + res.status(500).json({ success: false, message: 'Rückgabestatus konnte nicht aktualisiert werden' }); + } + } + // ------------------------------------------------------------------------- // Overview // ------------------------------------------------------------------------- diff --git a/backend/src/controllers/bestellung.controller.ts b/backend/src/controllers/bestellung.controller.ts index 110e37a..1558493 100644 --- a/backend/src/controllers/bestellung.controller.ts +++ b/backend/src/controllers/bestellung.controller.ts @@ -8,6 +8,51 @@ import fs from 'fs'; const param = (req: Request, key: string): string => req.params[key] as string; class BestellungController { + // --------------------------------------------------------------------------- + // Catalog (shared ausruestung_artikel) + // --------------------------------------------------------------------------- + + async listKatalogItems(req: Request, res: Response): Promise { + try { + const search = req.query.search as string | undefined; + const kategorie = req.query.kategorie as string | undefined; + const items = await bestellungService.getKatalogItems({ search, kategorie }); + res.status(200).json({ success: true, data: items }); + } catch (error) { + logger.error('BestellungController.listKatalogItems error', { error }); + res.status(500).json({ success: false, message: 'Katalogartikel konnten nicht geladen werden' }); + } + } + + async getKatalogItem(req: Request, res: Response): Promise { + const id = parseInt(param(req, 'itemId'), 10); + if (isNaN(id)) { + res.status(400).json({ success: false, message: 'Ungültige ID' }); + return; + } + try { + const item = await bestellungService.getKatalogItem(id); + if (!item) { + res.status(404).json({ success: false, message: 'Katalogartikel nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: item }); + } catch (error) { + logger.error('BestellungController.getKatalogItem error', { error }); + res.status(500).json({ success: false, message: 'Katalogartikel konnte nicht geladen werden' }); + } + } + + async listKatalogKategorien(_req: Request, res: Response): Promise { + try { + const kategorien = await bestellungService.getKatalogKategorien(); + res.status(200).json({ success: true, data: kategorien }); + } catch (error) { + logger.error('BestellungController.listKatalogKategorien error', { error }); + res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' }); + } + } + // --------------------------------------------------------------------------- // Vendors // --------------------------------------------------------------------------- diff --git a/backend/src/database/migrations/065_shared_catalog_and_request_type.sql b/backend/src/database/migrations/065_shared_catalog_and_request_type.sql new file mode 100644 index 0000000..058634f --- /dev/null +++ b/backend/src/database/migrations/065_shared_catalog_and_request_type.sql @@ -0,0 +1,9 @@ +-- Migration 065: Shared catalog + request type fields +-- Link bestellpositionen to ausruestung_artikel (shared catalog) +ALTER TABLE bestellpositionen + ADD COLUMN IF NOT EXISTS artikel_id INT REFERENCES ausruestung_artikel(id) ON DELETE SET NULL; + +-- Add replacement/return fields to ausruestung_anfrage_positionen +ALTER TABLE ausruestung_anfrage_positionen + ADD COLUMN IF NOT EXISTS ist_ersatz BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS altes_geraet_zurueckgegeben BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/src/routes/ausruestungsanfrage.routes.ts b/backend/src/routes/ausruestungsanfrage.routes.ts index 0e0c90d..5bf3c10 100644 --- a/backend/src/routes/ausruestungsanfrage.routes.ts +++ b/backend/src/routes/ausruestungsanfrage.routes.ts @@ -62,6 +62,7 @@ router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfr // --------------------------------------------------------------------------- router.patch('/positionen/:positionId/geliefert', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionGeliefert.bind(ausruestungsanfrageController)); +router.patch('/positionen/:positionId/zurueckgegeben', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionZurueckgegeben.bind(ausruestungsanfrageController)); // --------------------------------------------------------------------------- // Linking requests to orders diff --git a/backend/src/routes/bestellung.routes.ts b/backend/src/routes/bestellung.routes.ts index fb30cbb..82cc56e 100644 --- a/backend/src/routes/bestellung.routes.ts +++ b/backend/src/routes/bestellung.routes.ts @@ -52,6 +52,31 @@ router.delete( bestellungController.deleteVendor.bind(bestellungController) ); +// --------------------------------------------------------------------------- +// Catalog (shared ausruestung_artikel, accessed via bestellungen context) +// --------------------------------------------------------------------------- + +router.get( + '/katalog/items', + authenticate, + requirePermission('bestellungen:view'), + bestellungController.listKatalogItems.bind(bestellungController) +); + +router.get( + '/katalog/items/:itemId', + authenticate, + requirePermission('bestellungen:view'), + bestellungController.getKatalogItem.bind(bestellungController) +); + +router.get( + '/katalog/kategorien', + authenticate, + requirePermission('bestellungen:view'), + bestellungController.listKatalogKategorien.bind(bestellungController) +); + // --------------------------------------------------------------------------- // Orders (Bestellungen) // --------------------------------------------------------------------------- diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index ef95d86..feee808 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -422,7 +422,7 @@ async function getRequestById(id: number) { async function createRequest( userId: string, - items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[], + items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; ist_ersatz?: boolean; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[], notizen?: string, bezeichnung?: string, fuerBenutzerName?: string, @@ -476,9 +476,9 @@ async function createRequest( } 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, itemBezeichnung, item.menge, item.notizen || null], + `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, ist_ersatz) + VALUES ($1, $2, $3, $4, $5, $6)`, + [anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, item.ist_ersatz ?? false], ); // NOTE: eigenschaft values are NOT saved in the transaction to avoid @@ -527,7 +527,7 @@ async function updateRequest( data: { bezeichnung?: string; notizen?: string; - items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[]; + items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; ist_ersatz?: boolean; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[]; }, ) { const client = await pool.connect(); @@ -581,9 +581,9 @@ async function updateRequest( } const prevGeliefert = geliefertMap.get(`${item.artikel_id ?? 0}:${itemBezeichnung}`) ?? false; await client.query( - `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, geliefert) - VALUES ($1, $2, $3, $4, $5, $6)`, - [id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, prevGeliefert], + `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, geliefert, ist_ersatz) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, prevGeliefert, item.ist_ersatz ?? false], ); } } @@ -652,6 +652,14 @@ async function updatePositionGeliefert(positionId: number, geliefert: boolean) { return position; } +async function updatePositionZurueckgegeben(positionId: number, zurueckgegeben: boolean) { + const result = await pool.query( + `UPDATE ausruestung_anfrage_positionen SET altes_geraet_zurueckgegeben = $1 WHERE id = $2 RETURNING *`, + [zurueckgegeben, positionId], + ); + return result.rows[0] || null; +} + async function updateRequestStatus( id: number, status: string, @@ -768,10 +776,35 @@ async function createOrdersFromRequest( const bestellung = bestellungResult.rows[0]; for (const pos of orderData.positionen) { + // Look up the anfrage position to get artikel_id and eigenschaften + let artikelId: number | null = null; + let spezifikationen: string[] = []; + if (pos.position_id) { + const posResult = await client.query( + `SELECT p.artikel_id FROM ausruestung_anfrage_positionen p WHERE p.id = $1`, + [pos.position_id] + ); + if (posResult.rows.length > 0) { + artikelId = posResult.rows[0].artikel_id || null; + } + // Load eigenschaften and map to spezifikationen strings + try { + const eigResult = await client.query( + `SELECT 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 = $1 + ORDER BY ae.sort_order, ae.id`, + [pos.position_id] + ); + spezifikationen = eigResult.rows.map((e: { eigenschaft_name: string; wert: string }) => `${e.eigenschaft_name}: ${e.wert}`); + } catch { /* table may not exist */ } + } + await client.query( - `INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit, notizen) - VALUES ($1, $2, $3, $4, $5)`, - [bestellung.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk', pos.notizen || null] + `INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit, notizen, artikel_id, spezifikationen) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`, + [bestellung.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk', pos.notizen || null, artikelId, JSON.stringify(spezifikationen)] ); } @@ -875,6 +908,7 @@ export default { getMyRequests, getRequestById, updatePositionGeliefert, + updatePositionZurueckgegeben, createRequest, updateRequest, updateRequestStatus, diff --git a/backend/src/services/bestellung.service.ts b/backend/src/services/bestellung.service.ts index c924df5..e9ce2ae 100644 --- a/backend/src/services/bestellung.service.ts +++ b/backend/src/services/bestellung.service.ts @@ -7,6 +7,38 @@ import logger from '../utils/logger'; import fs from 'fs'; import notificationService from './notification.service'; import { permissionService } from './permission.service'; +import ausruestungsanfrageService from './ausruestungsanfrage.service'; + +// --------------------------------------------------------------------------- +// Catalog (shared ausruestung_artikel via ausruestungsanfrageService) +// --------------------------------------------------------------------------- + +async function getKatalogItems(filters?: { search?: string; kategorie?: string }) { + try { + return await ausruestungsanfrageService.getItems({ search: filters?.search, kategorie: filters?.kategorie, aktiv: true }); + } catch (error) { + logger.error('BestellungService.getKatalogItems failed', { error }); + throw new Error('Katalogartikel konnten nicht geladen werden'); + } +} + +async function getKatalogItem(id: number) { + try { + return await ausruestungsanfrageService.getItemById(id); + } catch (error) { + logger.error('BestellungService.getKatalogItem failed', { error, id }); + throw new Error('Katalogartikel konnte nicht geladen werden'); + } +} + +async function getKatalogKategorien() { + try { + return await ausruestungsanfrageService.getKategorien(); + } catch (error) { + logger.error('BestellungService.getKatalogKategorien failed', { error }); + throw new Error('Katalogkategorien konnten nicht geladen werden'); + } +} // --------------------------------------------------------------------------- // Vendors (Lieferanten) @@ -168,7 +200,13 @@ async function getOrderById(id: number) { if (orderResult.rows.length === 0) return null; const [positionen, dateien, erinnerungen, historie] = await Promise.all([ - pool.query(`SELECT * FROM bestellpositionen WHERE bestellung_id = $1 ORDER BY id`, [id]), + pool.query( + `SELECT bp.*, aa.bezeichnung AS artikel_bezeichnung + FROM bestellpositionen bp + LEFT JOIN ausruestung_artikel aa ON aa.id = bp.artikel_id + WHERE bp.bestellung_id = $1 ORDER BY bp.id`, + [id] + ), pool.query(`SELECT * FROM bestellung_dateien WHERE bestellung_id = $1 ORDER BY hochgeladen_am DESC`, [id]), pool.query(`SELECT * FROM bestellung_erinnerungen WHERE bestellung_id = $1 ORDER BY faellig_am`, [id]), pool.query(`SELECT h.*, COALESCE(u.name, u.preferred_username, u.email) AS benutzer_name FROM bestellung_historie h LEFT JOIN users u ON u.id = h.erstellt_von WHERE h.bestellung_id = $1 ORDER BY h.erstellt_am DESC`, [id]), @@ -425,13 +463,13 @@ async function updateOrderStatus(id: number, status: string, userId: string, for // Line Items (Bestellpositionen) // --------------------------------------------------------------------------- -async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string; spezifikationen?: string[] }, userId: string) { +async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string; spezifikationen?: string[]; artikel_id?: number }, userId: string) { try { const result = await pool.query( - `INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen, spezifikationen) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + `INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen, spezifikationen, artikel_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9) RETURNING *`, - [bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null, JSON.stringify(data.spezifikationen || [])] + [bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null, JSON.stringify(data.spezifikationen || []), data.artikel_id || null] ); await logAction(bestellungId, 'Position hinzugefügt', `"${data.bezeichnung}" x${data.menge}`, userId); return result.rows[0]; @@ -693,6 +731,10 @@ async function getHistory(bestellungId: number) { } export default { + // Catalog + getKatalogItems, + getKatalogItem, + getKatalogKategorien, // Vendors getVendors, getVendorById, diff --git a/frontend/src/pages/AusruestungsanfrageDetail.tsx b/frontend/src/pages/AusruestungsanfrageDetail.tsx index 6cf0d27..cdfc3f1 100644 --- a/frontend/src/pages/AusruestungsanfrageDetail.tsx +++ b/frontend/src/pages/AusruestungsanfrageDetail.tsx @@ -4,7 +4,7 @@ import { Table, TableBody, TableCell, TableHead, TableRow, Dialog, DialogTitle, DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, InputLabel, Autocomplete, - Checkbox, LinearProgress, + Checkbox, LinearProgress, Switch, FormControlLabel, Alert, } from '@mui/material'; import { ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, @@ -123,6 +123,15 @@ export default function AusruestungsanfrageDetail() { onError: () => showError('Fehler beim Aktualisieren'), }); + const zurueckgegebenMut = useMutation({ + mutationFn: ({ positionId, zurueckgegeben }: { positionId: number; zurueckgegeben: boolean }) => + ausruestungsanfrageApi.updatePositionZurueckgegeben(positionId, zurueckgegeben), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + // ── Edit helpers ── const startEditing = () => { if (!detail) return; @@ -134,6 +143,7 @@ export default function AusruestungsanfrageDetail() { menge: p.menge, notizen: p.notizen, eigenschaften: p.eigenschaften?.map(e => ({ eigenschaft_id: e.eigenschaft_id, wert: e.wert })), + ist_ersatz: p.ist_ersatz || false, }))); const initVals: Record> = {}; detail.positionen.forEach((p, idx) => { @@ -290,6 +300,23 @@ export default function AusruestungsanfrageDetail() { ))} )} + + updateEditItem(idx, 'ist_ersatz', e.target.checked)} + /> + } + label="Ersatzbeschaffung" + /> + {item.ist_ersatz && ( + + Altes Gerät muss zurückgegeben werden + + )} + ))}