From 633a75cb0b4075dd72f712c42ac76defaacd886e Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 14 Apr 2026 16:49:20 +0200 Subject: [PATCH] feat(ausruestungsanfrage): add personal item tracking, catalog enforcement, and detail pages --- .../ausruestungsanfrage.controller.ts | 38 ++- .../085_personal_equipment_eigenschaften.sql | 26 ++ .../src/routes/ausruestungsanfrage.routes.ts | 7 + .../services/ausruestungsanfrage.service.ts | 92 +++++- .../src/services/personalEquipment.service.ts | 104 +++++- frontend/src/App.tsx | 18 ++ frontend/src/pages/AusruestungsanfrageNeu.tsx | 63 +++- .../pages/AusruestungsanfrageZuweisung.tsx | 69 +++- frontend/src/pages/MitgliedDetail.tsx | 19 +- .../src/pages/PersoenlicheAusruestung.tsx | 117 ++++++- .../pages/PersoenlicheAusruestungDetail.tsx | 166 ++++++++++ .../src/pages/PersoenlicheAusruestungEdit.tsx | 302 ++++++++++++++++++ frontend/src/services/ausruestungsanfrage.ts | 15 +- .../src/types/ausruestungsanfrage.types.ts | 17 + frontend/src/types/personalEquipment.types.ts | 4 + 15 files changed, 1031 insertions(+), 26 deletions(-) create mode 100644 backend/src/database/migrations/085_personal_equipment_eigenschaften.sql create mode 100644 frontend/src/pages/PersoenlicheAusruestungDetail.tsx create mode 100644 frontend/src/pages/PersoenlicheAusruestungEdit.tsx diff --git a/backend/src/controllers/ausruestungsanfrage.controller.ts b/backend/src/controllers/ausruestungsanfrage.controller.ts index c4e64d9..907adb4 100644 --- a/backend/src/controllers/ausruestungsanfrage.controller.ts +++ b/backend/src/controllers/ausruestungsanfrage.controller.ts @@ -267,7 +267,7 @@ class AusruestungsanfrageController { async createRequest(req: Request, res: Response): Promise { try { const { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name } = req.body as { - 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; eigenschaften?: { eigenschaft_id: number; wert: string }[]; persoenlich_id?: string; neuer_zustand?: string }[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; @@ -280,7 +280,7 @@ class AusruestungsanfrageController { } for (const item of items) { - if (!item.bezeichnung || item.bezeichnung.trim().length === 0) { + if (!item.persoenlich_id && (!item.bezeichnung || item.bezeichnung.trim().length === 0)) { res.status(400).json({ success: false, message: 'Bezeichnung ist für alle Positionen erforderlich' }); return; } @@ -616,6 +616,40 @@ class AusruestungsanfrageController { res.status(500).json({ success: false, message: 'Widget-Daten konnten nicht geladen werden' }); } } + + // ------------------------------------------------------------------------- + // Unassigned positions + // ------------------------------------------------------------------------- + + async getUnassignedPositions(_req: Request, res: Response): Promise { + try { + const data = await ausruestungsanfrageService.getUnassignedPositions(); + res.status(200).json({ success: true, data }); + } catch (error) { + logger.error('AusruestungsanfrageController.getUnassignedPositions error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Laden' }); + } + } + + async updatePositionArtikelId(req: Request, res: Response): Promise { + try { + const positionId = Number(req.params.positionId); + const artikelId = Number(req.body.artikel_id); + if (isNaN(positionId) || isNaN(artikelId) || artikelId <= 0) { + res.status(400).json({ success: false, message: 'Ungültige IDs' }); + return; + } + const updated = await ausruestungsanfrageService.updatePositionArtikelId(positionId, artikelId); + if (!updated) { + res.status(404).json({ success: false, message: 'Position nicht gefunden' }); + return; + } + res.status(200).json({ success: true, data: updated }); + } catch (error) { + logger.error('AusruestungsanfrageController.updatePositionArtikelId error', { error }); + res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' }); + } + } } export default new AusruestungsanfrageController(); diff --git a/backend/src/database/migrations/085_personal_equipment_eigenschaften.sql b/backend/src/database/migrations/085_personal_equipment_eigenschaften.sql new file mode 100644 index 0000000..1290ab4 --- /dev/null +++ b/backend/src/database/migrations/085_personal_equipment_eigenschaften.sql @@ -0,0 +1,26 @@ +-- Migration: 085_personal_equipment_eigenschaften +-- Adds eigenschaften (characteristics) storage for persoenliche_ausruestung +-- and extends ausruestung_anfrage_positionen for status change requests. + +-- 1. Characteristics table for personal equipment items +CREATE TABLE IF NOT EXISTS persoenliche_ausruestung_eigenschaften ( + id SERIAL PRIMARY KEY, + persoenlich_id UUID NOT NULL REFERENCES persoenliche_ausruestung(id) ON DELETE CASCADE, + eigenschaft_id INT REFERENCES ausruestung_artikel_eigenschaften(id) ON DELETE SET NULL, + name TEXT NOT NULL, + wert TEXT NOT NULL, + UNIQUE(persoenlich_id, eigenschaft_id) +); + +CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_eigenschaften_persoenlich + ON persoenliche_ausruestung_eigenschaften(persoenlich_id); + +-- 2. Add status-change request columns to anfrage positions +ALTER TABLE ausruestung_anfrage_positionen + ADD COLUMN IF NOT EXISTS persoenlich_id UUID REFERENCES persoenliche_ausruestung(id) ON DELETE SET NULL; + +ALTER TABLE ausruestung_anfrage_positionen + ADD COLUMN IF NOT EXISTS aktueller_zustand TEXT; + +ALTER TABLE ausruestung_anfrage_positionen + ADD COLUMN IF NOT EXISTS neuer_zustand TEXT CHECK (neuer_zustand IN ('gut','beschaedigt','abgaengig','verloren')); diff --git a/backend/src/routes/ausruestungsanfrage.routes.ts b/backend/src/routes/ausruestungsanfrage.routes.ts index e2de5a1..15330b0 100644 --- a/backend/src/routes/ausruestungsanfrage.routes.ts +++ b/backend/src/routes/ausruestungsanfrage.routes.ts @@ -57,10 +57,17 @@ router.patch('/requests/:id', authenticate, ausruestungsanfrageController.update router.patch('/requests/:id/status', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updateRequestStatus.bind(ausruestungsanfrageController)); router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.deleteRequest.bind(ausruestungsanfrageController)); +// --------------------------------------------------------------------------- +// Unassigned positions +// --------------------------------------------------------------------------- + +router.get('/nicht-zugewiesen', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.getUnassignedPositions.bind(ausruestungsanfrageController)); + // --------------------------------------------------------------------------- // Position delivery tracking // --------------------------------------------------------------------------- +router.patch('/positionen/:positionId/artikel', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.updatePositionArtikelId.bind(ausruestungsanfrageController)); 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)); diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index 68f9847..87944a9 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -428,7 +428,7 @@ async function getRequestById(id: number) { async function createRequest( userId: string, - items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; ist_ersatz?: boolean; 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 }[]; persoenlich_id?: string; neuer_zustand?: string }[], notizen?: string, bezeichnung?: string, fuerBenutzerName?: string, @@ -471,7 +471,26 @@ async function createRequest( for (const item of items) { let itemBezeichnung = item.bezeichnung; - if (item.artikel_id) { + let itemArtikelId = item.artikel_id || null; + let itemPersoenlichId: string | null = null; + let itemAktuellerZustand: string | null = null; + let itemNeuerZustand: string | null = null; + + if (item.persoenlich_id) { + // Load personal item details + const persResult = await client.query( + 'SELECT bezeichnung, zustand, artikel_id FROM persoenliche_ausruestung WHERE id = $1', + [item.persoenlich_id], + ); + if (persResult.rows.length > 0) { + const pers = persResult.rows[0]; + itemBezeichnung = pers.bezeichnung; + itemArtikelId = pers.artikel_id || itemArtikelId; + itemPersoenlichId = item.persoenlich_id; + itemAktuellerZustand = pers.zustand || null; + itemNeuerZustand = item.neuer_zustand || null; + } + } else if (item.artikel_id) { const artikelResult = await client.query( 'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1', [item.artikel_id], @@ -482,9 +501,9 @@ async function createRequest( } await client.query( - `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], + `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, ist_ersatz, persoenlich_id, aktueller_zustand, neuer_zustand) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [anfrage.id, itemArtikelId, itemBezeichnung, item.menge, item.notizen || null, item.ist_ersatz ?? false, itemPersoenlichId, itemAktuellerZustand, itemNeuerZustand], ); // NOTE: eigenschaft values are NOT saved in the transaction to avoid @@ -710,6 +729,19 @@ async function updateRequestStatus( [id], ); } catch { /* column may not exist yet */ } + + // Apply personal equipment status changes + const statusChangePositionen = await pool.query( + `SELECT persoenlich_id, neuer_zustand FROM ausruestung_anfrage_positionen + WHERE anfrage_id = $1 AND persoenlich_id IS NOT NULL AND neuer_zustand IS NOT NULL`, + [id], + ); + for (const row of statusChangePositionen.rows) { + await pool.query( + `UPDATE persoenliche_ausruestung SET zustand = $1 WHERE id = $2`, + [row.neuer_zustand, row.persoenlich_id], + ); + } } return updated; @@ -1006,6 +1038,22 @@ async function assignDeliveredItems( ); const newId = insertResult.rows[0].id; + // Copy position eigenschaften to persoenliche_ausruestung_eigenschaften + const posEigResult = await client.query( + `SELECT poe.eigenschaft_id, aae.name, poe.wert + FROM ausruestung_position_eigenschaften poe + JOIN ausruestung_artikel_eigenschaften aae ON aae.id = poe.eigenschaft_id + WHERE poe.position_id = $1`, + [a.positionId], + ); + for (const row of posEigResult.rows) { + await client.query( + `INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert) + VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`, + [newId, row.eigenschaft_id, row.name, row.wert], + ); + } + await client.query( `UPDATE ausruestung_anfrage_positionen SET zuweisung_typ = 'persoenlich', zuweisung_persoenlich_id = $1 @@ -1034,6 +1082,38 @@ async function assignDeliveredItems( } } +// --------------------------------------------------------------------------- +// Unassigned positions + position artikel update +// --------------------------------------------------------------------------- + +async function getUnassignedPositions() { + const result = await pool.query(` + SELECT + p.id, p.bezeichnung, p.menge, p.artikel_id, + a.id AS anfrage_id, + a.bezeichnung AS anfrage_bezeichnung, + a.bestell_nummer, + a.bestell_jahr, + COALESCE(a.fuer_benutzer_name, + NULLIF(TRIM(COALESCE(u.given_name,'') || ' ' || COALESCE(u.family_name,'')), ''), + u.name) AS fuer_wen + FROM ausruestung_anfrage_positionen p + JOIN ausruestung_anfragen a ON a.id = p.anfrage_id + LEFT JOIN users u ON u.id = a.anfrager_id + WHERE p.geliefert = true AND p.zuweisung_typ IS NULL + ORDER BY a.bestell_jahr DESC NULLS LAST, a.bestell_nummer DESC NULLS LAST, p.bezeichnung + `); + return result.rows; +} + +async function updatePositionArtikelId(positionId: number, artikelId: number) { + const result = await pool.query( + `UPDATE ausruestung_anfrage_positionen SET artikel_id = $1 WHERE id = $2 RETURNING *`, + [artikelId, positionId], + ); + return result.rows[0] || null; +} + export default { getAllUsers, getKategorien, @@ -1065,4 +1145,6 @@ export default { getOverview, getWidgetOverview, assignDeliveredItems, + getUnassignedPositions, + updatePositionArtikelId, }; diff --git a/backend/src/services/personalEquipment.service.ts b/backend/src/services/personalEquipment.service.ts index 3fd4ec8..68e6386 100644 --- a/backend/src/services/personalEquipment.service.ts +++ b/backend/src/services/personalEquipment.service.ts @@ -19,6 +19,7 @@ interface CreatePersonalEquipmentData { anschaffung_datum?: string; zustand?: string; notizen?: string; + eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[]; } interface UpdatePersonalEquipmentData { @@ -33,6 +34,7 @@ interface UpdatePersonalEquipmentData { anschaffung_datum?: string | null; zustand?: string; notizen?: string | null; + eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[] | null; } const BASE_SELECT = ` @@ -45,6 +47,23 @@ const BASE_SELECT = ` WHERE pa.geloescht_am IS NULL `; +async function loadEigenschaften(ids: string[]) { + if (ids.length === 0) return new Map(); + const result = await pool.query( + `SELECT pae.id, pae.persoenlich_id, pae.eigenschaft_id, pae.name, pae.wert + FROM persoenliche_ausruestung_eigenschaften pae + WHERE pae.persoenlich_id = ANY($1)`, + [ids], + ); + const map = new Map(); + for (const row of result.rows) { + const arr = map.get(row.persoenlich_id) || []; + arr.push(row); + map.set(row.persoenlich_id, arr); + } + return map; +} + async function getAll(filters: PersonalEquipmentFilters = {}) { try { const conditions: string[] = []; @@ -68,6 +87,13 @@ async function getAll(filters: PersonalEquipmentFilters = {}) { `${BASE_SELECT}${where} ORDER BY pa.bezeichnung`, params, ); + + const ids = result.rows.map((r: { id: string }) => r.id); + const eigMap = await loadEigenschaften(ids); + for (const row of result.rows) { + row.eigenschaften = eigMap.get(row.id) || []; + } + return result.rows; } catch (error) { logger.error('personalEquipmentService.getAll failed', { error }); @@ -81,6 +107,13 @@ async function getByUserId(userId: string) { `${BASE_SELECT} AND pa.user_id = $1 ORDER BY pa.bezeichnung`, [userId], ); + + const ids = result.rows.map((r: { id: string }) => r.id); + const eigMap = await loadEigenschaften(ids); + for (const row of result.rows) { + row.eigenschaften = eigMap.get(row.id) || []; + } + return result.rows; } catch (error) { logger.error('personalEquipmentService.getByUserId failed', { error, userId }); @@ -94,7 +127,17 @@ async function getById(id: string) { `${BASE_SELECT} AND pa.id = $1`, [id], ); - return result.rows[0] || null; + if (!result.rows[0]) return null; + + const eigResult = await pool.query( + `SELECT pae.id, pae.persoenlich_id, pae.eigenschaft_id, pae.name, pae.wert + FROM persoenliche_ausruestung_eigenschaften pae + WHERE pae.persoenlich_id = $1`, + [id], + ); + result.rows[0].eigenschaften = eigResult.rows; + + return result.rows[0]; } catch (error) { logger.error('personalEquipmentService.getById failed', { error, id }); throw new Error('Failed to fetch personal equipment item'); @@ -125,8 +168,20 @@ async function create(data: CreatePersonalEquipmentData, requestingUserId: strin requestingUserId, ], ); - logger.info('Personal equipment created', { id: result.rows[0].id, by: requestingUserId }); - return result.rows[0]; + const created = result.rows[0]; + logger.info('Personal equipment created', { id: created.id, by: requestingUserId }); + + if (data.eigenschaften && data.eigenschaften.length > 0) { + for (const e of data.eigenschaften) { + await pool.query( + `INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert) + VALUES ($1, $2, $3, $4)`, + [created.id, e.eigenschaft_id ?? null, e.name, e.wert], + ); + } + } + + return created; } catch (error) { logger.error('personalEquipmentService.create failed', { error }); throw new Error('Failed to create personal equipment'); @@ -156,19 +211,46 @@ async function update(id: string, data: UpdatePersonalEquipmentData) { if (data.zustand !== undefined) addField('zustand', data.zustand); if (data.notizen !== undefined) addField('notizen', data.notizen); - if (fields.length === 0) { + if (fields.length === 0 && data.eigenschaften === undefined) { throw new Error('No fields to update'); } - values.push(id); - const result = await pool.query( - `UPDATE persoenliche_ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND geloescht_am IS NULL RETURNING *`, - values, - ); + let updated = null; + if (fields.length > 0) { + values.push(id); + const result = await pool.query( + `UPDATE persoenliche_ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND geloescht_am IS NULL RETURNING *`, + values, + ); + if (result.rows.length === 0) return null; + updated = result.rows[0]; + } + + if (data.eigenschaften !== undefined) { + await pool.query('DELETE FROM persoenliche_ausruestung_eigenschaften WHERE persoenlich_id = $1', [id]); + if (data.eigenschaften && data.eigenschaften.length > 0) { + for (const e of data.eigenschaften) { + await pool.query( + `INSERT INTO persoenliche_ausruestung_eigenschaften (persoenlich_id, eigenschaft_id, name, wert) + VALUES ($1, $2, $3, $4)`, + [id, e.eigenschaft_id ?? null, e.name, e.wert], + ); + } + } + } + + if (!updated) { + // Only eigenschaften were updated, fetch the row + const result = await pool.query( + `SELECT * FROM persoenliche_ausruestung WHERE id = $1 AND geloescht_am IS NULL`, + [id], + ); + if (result.rows.length === 0) return null; + updated = result.rows[0]; + } - if (result.rows.length === 0) return null; logger.info('Personal equipment updated', { id }); - return result.rows[0]; + return updated; } catch (error) { logger.error('personalEquipmentService.update failed', { error, id }); throw error; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f9216f7..271729d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,8 @@ import AusruestungDetail from './pages/AusruestungDetail'; import AusruestungEinstellungen from './pages/AusruestungEinstellungen'; import PersoenlicheAusruestung from './pages/PersoenlicheAusruestung'; import PersoenlicheAusruestungNeu from './pages/PersoenlicheAusruestungNeu'; +import PersoenlicheAusruestungDetail from './pages/PersoenlicheAusruestungDetail'; +import PersoenlicheAusruestungEdit from './pages/PersoenlicheAusruestungEdit'; import Atemschutz from './pages/Atemschutz'; import Mitglieder from './pages/Mitglieder'; import MitgliedDetail from './pages/MitgliedDetail'; @@ -204,6 +206,22 @@ function App() { } /> + + + + } + /> + + + + } + /> (null); const [catalogItems, setCatalogItems] = useState([{ bezeichnung: '', menge: 1 }]); const [freeItems, setFreeItems] = useState<{ bezeichnung: string; menge: number }[]>([]); + const [assignedSelections, setAssignedSelections] = useState>({}); // Eigenschaften state const [itemEigenschaften, setItemEigenschaften] = useState>({}); @@ -97,10 +101,16 @@ export default function AusruestungsanfrageNeu() { enabled: canOrderForUser, }); + const { data: myPersonalItems = [] } = useQuery({ + queryKey: ['persoenliche-ausruestung', 'my-for-request'], + queryFn: () => personalEquipmentApi.getMy(), + staleTime: 2 * 60 * 1000, + }); + // ── Mutations ── const createMut = useMutation({ - mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; fuer_benutzer_name?: string }) => - ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name), + mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string; fuer_benutzer_name?: string; assignedItems?: { persoenlich_id: string; neuer_zustand: string }[] }) => + ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id, args.fuer_benutzer_name, args.assignedItems), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Anfrage erstellt'); @@ -161,10 +171,13 @@ export default function AusruestungsanfrageNeu() { bezeichnung: bezeichnung || undefined, fuer_benutzer_id: typeof fuerBenutzer === 'object' && fuerBenutzer ? fuerBenutzer.id : undefined, fuer_benutzer_name: typeof fuerBenutzer === 'string' ? fuerBenutzer : undefined, + assignedItems: Object.entries(assignedSelections) + .filter(([, v]) => !!v) + .map(([id, neuer_zustand]) => ({ persoenlich_id: id, neuer_zustand })), }); }; - const hasValidItems = catalogItems.some(i => !!i.artikel_id) || freeItems.some(i => i.bezeichnung.trim()); + const hasValidItems = catalogItems.some(i => !!i.artikel_id) || freeItems.some(i => i.bezeichnung.trim()) || Object.keys(assignedSelections).length > 0; return ( @@ -300,6 +313,48 @@ export default function AusruestungsanfrageNeu() { Freitext-Position hinzufügen + + Zugewiesene Gegenstände + {myPersonalItems.length === 0 ? ( + Keine zugewiesenen Gegenstände vorhanden. + ) : ( + myPersonalItems.map((item) => ( + + { + if (e.target.checked) { + setAssignedSelections(prev => ({ ...prev, [item.id]: item.zustand })); + } else { + setAssignedSelections(prev => { const n = { ...prev }; delete n[item.id]; return n; }); + } + }} + /> + {item.bezeichnung} + + {!!assignedSelections[item.id] && ( + setAssignedSelections(prev => ({ ...prev, [item.id]: e.target.value }))} + > + {Object.entries(ZUSTAND_LABELS).map(([key, label]) => ( + {label} + ))} + + )} + + )) + )} + diff --git a/frontend/src/pages/AusruestungsanfrageZuweisung.tsx b/frontend/src/pages/AusruestungsanfrageZuweisung.tsx index 40cdc97..c0bdf9c 100644 --- a/frontend/src/pages/AusruestungsanfrageZuweisung.tsx +++ b/frontend/src/pages/AusruestungsanfrageZuweisung.tsx @@ -5,11 +5,12 @@ import { Stack, Divider, LinearProgress, } from '@mui/material'; import { Assignment as AssignmentIcon } from '@mui/icons-material'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { PageHeader } from '../components/templates'; import { useNotification } from '../contexts/NotificationContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { vehiclesApi } from '../services/vehicles'; import { membersService } from '../services/members'; @@ -36,6 +37,13 @@ export default function AusruestungsanfrageZuweisung() { const navigate = useNavigate(); const { showSuccess, showError } = useNotification(); const anfrageId = Number(id); + const queryClient = useQueryClient(); + const { hasPermission } = usePermissionContext(); + const canManageCatalog = hasPermission('ausruestungsanfrage:manage_catalog'); + + const [createArtikelFor, setCreateArtikelFor] = useState(null); + const [newArtikelBezeichnung, setNewArtikelBezeichnung] = useState(''); + const [newArtikelSubmitting, setNewArtikelSubmitting] = useState(false); const { data: detail, isLoading, isError } = useQuery({ queryKey: ['ausruestungsanfrage', 'request', anfrageId], @@ -101,6 +109,22 @@ export default function AusruestungsanfrageZuweisung() { setAssignments(updated); }; + const handleCreateArtikel = async (posId: number) => { + if (!newArtikelBezeichnung.trim()) return; + setNewArtikelSubmitting(true); + try { + const newArtikel = await ausruestungsanfrageApi.createItem({ bezeichnung: newArtikelBezeichnung.trim(), aktiv: true }); + await ausruestungsanfrageApi.linkPositionToArtikel(posId, newArtikel.id); + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', 'request', anfrageId] }); + setCreateArtikelFor(null); + showSuccess('Katalogartikel erstellt und Position verknüpft'); + } catch { + showError('Fehler beim Erstellen des Katalogartikels'); + } finally { + setNewArtikelSubmitting(false); + } + }; + const handleSubmit = async () => { if (!detail) return; setSubmitting(true); @@ -161,12 +185,55 @@ export default function AusruestungsanfrageZuweisung() { + {!pos.artikel_id && ( + + + {canManageCatalog && createArtikelFor !== pos.id && ( + + )} + {createArtikelFor === pos.id && ( + + setNewArtikelBezeichnung(e.target.value)} + sx={{ flex: 1 }} + /> + + + + )} + + )} + val && updateAssignment(pos.id, { typ: val })} sx={{ mb: 1.5 }} + disabled={!pos.artikel_id} > Ausrüstung Persönlich diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx index 7827b1e..b187d99 100644 --- a/frontend/src/pages/MitgliedDetail.tsx +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -869,12 +869,29 @@ function MitgliedDetail() { ) : ( personalEquipment.map((item) => ( - + navigate(`/persoenliche-ausruestung/${item.id}`)} + > {item.bezeichnung} {item.kategorie && ( {item.kategorie} )} + {item.eigenschaften && item.eigenschaften.length > 0 && ( + + {item.eigenschaften.map((e) => ( + + ))} + + )} (''); @@ -55,6 +58,13 @@ function PersoenlicheAusruestungPage() { enabled: canViewAll, }); + const { data: unassignedPositions, isLoading: unassignedLoading } = useQuery({ + queryKey: ['ausruestungsanfrage', 'nicht-zugewiesen'], + queryFn: () => ausruestungsanfrageApi.getUnassignedPositions(), + staleTime: 2 * 60 * 1000, + enabled: canApprove && activeTab === 2, + }); + const memberOptions = useMemo(() => { return (membersList?.items ?? []).map((m) => ({ id: m.id, @@ -92,6 +102,7 @@ function PersoenlicheAusruestungPage() { setActiveTab(v)} sx={{ mb: 3 }}> + {canApprove && } {activeTab === 0 && ( @@ -193,7 +204,7 @@ function PersoenlicheAusruestungPage() { ) : ( filtered.map((item) => ( - + navigate(`/persoenliche-ausruestung/${item.id}`)}> {item.bezeichnung} @@ -203,6 +214,19 @@ function PersoenlicheAusruestungPage() { {item.artikel_bezeichnung} )} + {item.eigenschaften && item.eigenschaften.length > 0 && ( + + {item.eigenschaften.map((e) => ( + + ))} + + )} {item.kategorie ?? '—'} @@ -242,6 +266,97 @@ function PersoenlicheAusruestungPage() { )} {activeTab === 1 && } + + {activeTab === 2 && canApprove && ( + + + + + Bezeichnung + Anfrage + Für wen + Im Katalog + Aktion + + + + {unassignedLoading ? ( + + + + Lade Daten… + + + + ) : !unassignedPositions || unassignedPositions.length === 0 ? ( + + + + + + Alle Positionen sind zugewiesen + + + + + ) : ( + unassignedPositions.map((pos) => ( + + + {pos.bezeichnung} + + + + navigate(`/ausruestungsanfrage/${pos.anfrage_id}`)} + > + {pos.anfrage_bezeichnung || (pos.bestell_jahr && pos.bestell_nummer ? `${pos.bestell_jahr}/${String(pos.bestell_nummer).padStart(3, '0')}` : `#${pos.anfrage_id}`)} + + + + {pos.fuer_wen || '—'} + + + + + + + + + )) + )} + + + + )} {/* FAB */} diff --git a/frontend/src/pages/PersoenlicheAusruestungDetail.tsx b/frontend/src/pages/PersoenlicheAusruestungDetail.tsx new file mode 100644 index 0000000..cdc01d9 --- /dev/null +++ b/frontend/src/pages/PersoenlicheAusruestungDetail.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react'; +import { + Box, Typography, Container, Chip, Button, Paper, Divider, + Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, + LinearProgress, +} from '@mui/material'; +import { Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { PageHeader } from '../components/templates'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { useNotification } from '../contexts/NotificationContext'; +import { personalEquipmentApi } from '../services/personalEquipment'; +import { ZUSTAND_LABELS, ZUSTAND_COLORS } from '../types/personalEquipment.types'; +import type { PersoenlicheAusruestungZustand } from '../types/personalEquipment.types'; + +export default function PersoenlicheAusruestungDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { hasPermission } = usePermissionContext(); + const { showSuccess, showError } = useNotification(); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + + const { data: item, isLoading, isError } = useQuery({ + queryKey: ['persoenliche-ausruestung', 'detail', id], + queryFn: () => personalEquipmentApi.getById(id!), + enabled: !!id, + }); + + const canEdit = hasPermission('persoenliche_ausruestung:edit'); + const canDelete = hasPermission('persoenliche_ausruestung:delete'); + + const handleDelete = async () => { + if (!id) return; + setDeleting(true); + try { + await personalEquipmentApi.delete(id); + queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] }); + showSuccess('Persönliche Ausrüstung gelöscht'); + navigate('/persoenliche-ausruestung'); + } catch { + showError('Fehler beim Löschen'); + } finally { + setDeleting(false); + setDeleteOpen(false); + } + }; + + return ( + + + {isLoading ? ( + + ) : isError || !item ? ( + Fehler beim Laden. + ) : ( + <> + + + {/* Status + actions row */} + + + + {canEdit && ( + + )} + {canDelete && ( + + )} + + + {/* Info card */} + + Details + + {([ + ['Benutzer', item.user_display_name || item.benutzer_name], + ['Größe', item.groesse], + ['Seriennummer', item.seriennummer], + ['Inventarnummer', item.inventarnummer], + ['Anschaffungsdatum', item.anschaffung_datum ? new Date(item.anschaffung_datum).toLocaleDateString('de-AT') : null], + ['Erstellt am', new Date(item.erstellt_am).toLocaleDateString('de-AT')], + ] as [string, string | null | undefined][]).map(([label, value]) => value ? ( + + {label} + {value} + + ) : null)} + + + {item.anfrage_id && ( + <> + + Aus Anfrage + navigate(`/ausruestungsanfrage/${item.anfrage_id}`)} + > + Anfrage #{item.anfrage_id} + + + )} + + + {/* Eigenschaften */} + {item.eigenschaften && item.eigenschaften.length > 0 && ( + + Eigenschaften + + {item.eigenschaften.map((e) => ( + + {e.name} + {e.wert} + + ))} + + + )} + + {/* Notizen */} + {item.notizen && ( + + Notizen + {item.notizen} + + )} + + )} + + + {/* Delete confirmation */} + setDeleteOpen(false)}> + Persönliche Ausrüstung löschen? + + + Dieser Eintrag wird dauerhaft gelöscht und kann nicht wiederhergestellt werden. + + + + + + + + + ); +} diff --git a/frontend/src/pages/PersoenlicheAusruestungEdit.tsx b/frontend/src/pages/PersoenlicheAusruestungEdit.tsx new file mode 100644 index 0000000..713c8fa --- /dev/null +++ b/frontend/src/pages/PersoenlicheAusruestungEdit.tsx @@ -0,0 +1,302 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Autocomplete, + Box, + Button, + Container, + IconButton, + LinearProgress, + MenuItem, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { personalEquipmentApi } from '../services/personalEquipment'; +import { membersService } from '../services/members'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { useNotification } from '../contexts/NotificationContext'; +import { PageHeader } from '../components/templates'; +import { ZUSTAND_LABELS } from '../types/personalEquipment.types'; +import type { + PersoenlicheAusruestungZustand, + UpdatePersoenlicheAusruestungPayload, +} from '../types/personalEquipment.types'; + +const ZUSTAND_OPTIONS = Object.entries(ZUSTAND_LABELS) as [PersoenlicheAusruestungZustand, string][]; + +interface EigenschaftRow { + id?: number; + eigenschaft_id?: number | null; + name: string; + wert: string; +} + +export default function PersoenlicheAusruestungEdit() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { hasPermission } = usePermissionContext(); + const { showSuccess, showError } = useNotification(); + + const canViewAll = hasPermission('persoenliche_ausruestung:view_all'); + + const { data: item, isLoading, isError } = useQuery({ + queryKey: ['persoenliche-ausruestung', 'detail', id], + queryFn: () => personalEquipmentApi.getById(id!), + enabled: !!id, + }); + + const { data: membersList } = useQuery({ + queryKey: ['members-list-compact'], + queryFn: () => membersService.getMembers({ pageSize: 500 }), + staleTime: 5 * 60 * 1000, + enabled: canViewAll, + }); + + const memberOptions = useMemo(() => { + return (membersList?.items ?? []).map((m) => ({ + id: m.id, + name: [m.given_name, m.family_name].filter(Boolean).join(' ') || m.email, + })); + }, [membersList]); + + // Form state + const [bezeichnung, setBezeichnung] = useState(''); + const [kategorie, setKategorie] = useState(''); + const [groesse, setGroesse] = useState(''); + const [seriennummer, setSeriennummer] = useState(''); + const [inventarnummer, setInventarnummer] = useState(''); + const [anschaffungDatum, setAnschaffungDatum] = useState(''); + const [zustand, setZustand] = useState('gut'); + const [notizen, setNotizen] = useState(''); + const [userId, setUserId] = useState<{ id: string; name: string } | null>(null); + const [eigenschaften, setEigenschaften] = useState([]); + + // Initialize form from loaded item + useEffect(() => { + if (!item) return; + setBezeichnung(item.bezeichnung); + setKategorie(item.kategorie ?? ''); + setGroesse(item.groesse ?? ''); + setSeriennummer(item.seriennummer ?? ''); + setInventarnummer(item.inventarnummer ?? ''); + setAnschaffungDatum(item.anschaffung_datum ? item.anschaffung_datum.split('T')[0] : ''); + setZustand(item.zustand); + setNotizen(item.notizen ?? ''); + if (item.eigenschaften) { + setEigenschaften(item.eigenschaften.map(e => ({ + id: e.id, + eigenschaft_id: e.eigenschaft_id, + name: e.name, + wert: e.wert, + }))); + } + }, [item]); + + // Set userId when item + memberOptions are ready + useEffect(() => { + if (!item?.user_id || memberOptions.length === 0) return; + const match = memberOptions.find(m => m.id === item.user_id); + if (match) setUserId(match); + }, [item, memberOptions]); + + const updateMutation = useMutation({ + mutationFn: (data: UpdatePersoenlicheAusruestungPayload) => personalEquipmentApi.update(id!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['persoenliche-ausruestung'] }); + showSuccess('Persönliche Ausrüstung aktualisiert'); + navigate(`/persoenliche-ausruestung/${id}`); + }, + onError: () => { + showError('Fehler beim Speichern'); + }, + }); + + const handleSave = () => { + if (!bezeichnung.trim()) return; + + const payload: UpdatePersoenlicheAusruestungPayload = { + bezeichnung: bezeichnung.trim(), + kategorie: kategorie || null, + user_id: userId?.id || null, + groesse: groesse || null, + seriennummer: seriennummer || null, + inventarnummer: inventarnummer || null, + anschaffung_datum: anschaffungDatum || null, + zustand, + notizen: notizen || null, + eigenschaften: eigenschaften + .filter(e => e.name.trim() && e.wert.trim()) + .map(e => ({ eigenschaft_id: e.eigenschaft_id, name: e.name, wert: e.wert })), + }; + updateMutation.mutate(payload); + }; + + const addEigenschaft = () => { + setEigenschaften(prev => [...prev, { name: '', wert: '' }]); + }; + + const updateEigenschaft = (idx: number, field: 'name' | 'wert', value: string) => { + setEigenschaften(prev => prev.map((e, i) => i === idx ? { ...e, [field]: value } : e)); + }; + + const removeEigenschaft = (idx: number) => { + setEigenschaften(prev => prev.filter((_, i) => i !== idx)); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + if (isError || !item) { + return ( + + + Fehler beim Laden. + + + ); + } + + return ( + + + + + + setBezeichnung(e.target.value)} + /> + + {canViewAll && ( + o.name} + value={userId} + onChange={(_e, v) => setUserId(v)} + renderInput={(params) => ( + + )} + size="small" + /> + )} + + setKategorie(e.target.value)} + /> + + setGroesse(e.target.value)} + /> + + setSeriennummer(e.target.value)} + /> + + setInventarnummer(e.target.value)} + /> + + setAnschaffungDatum(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + setZustand(e.target.value as PersoenlicheAusruestungZustand)} + > + {ZUSTAND_OPTIONS.map(([key, label]) => ( + {label} + ))} + + + setNotizen(e.target.value)} + /> + + {/* Eigenschaften */} + Eigenschaften + {eigenschaften.map((e, idx) => ( + + updateEigenschaft(idx, 'name', ev.target.value)} + sx={{ flex: 1 }} + /> + updateEigenschaft(idx, 'wert', ev.target.value)} + sx={{ flex: 1 }} + /> + removeEigenschaft(idx)}> + + + + ))} + + + + + + + + + + ); +} diff --git a/frontend/src/services/ausruestungsanfrage.ts b/frontend/src/services/ausruestungsanfrage.ts index 045354c..8bb3b3d 100644 --- a/frontend/src/services/ausruestungsanfrage.ts +++ b/frontend/src/services/ausruestungsanfrage.ts @@ -11,6 +11,7 @@ import type { AusruestungWidgetOverview, CreateOrdersRequest, CreateOrdersResponse, + UnassignedPosition, } from '../types/ausruestungsanfrage.types'; export const ausruestungsanfrageApi = { @@ -98,8 +99,9 @@ export const ausruestungsanfrageApi = { bezeichnung?: string, fuer_benutzer_id?: string, fuer_benutzer_name?: string, + assignedItems?: { persoenlich_id: string; neuer_zustand: string }[], ): Promise => { - const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name }); + const r = await api.post('/api/ausruestungsanfragen/requests', { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name, assignedItems }); return r.data.data; }, updateRequest: async ( @@ -174,4 +176,15 @@ export const ausruestungsanfrageApi = { const r = await api.get('/api/ausruestungsanfragen/users'); return r.data.data; }, + + // ── Position linking ── + linkPositionToArtikel: async (positionId: number, artikelId: number): Promise => { + await api.patch(`/api/ausruestungsanfrage/positionen/${positionId}/artikel`, { artikel_id: artikelId }); + }, + + // ── Unassigned positions ── + getUnassignedPositions: async (): Promise => { + const r = await api.get<{ success: boolean; data: UnassignedPosition[] }>('/api/ausruestungsanfrage/nicht-zugewiesen'); + return r.data.data; + }, }; diff --git a/frontend/src/types/ausruestungsanfrage.types.ts b/frontend/src/types/ausruestungsanfrage.types.ts index 3b1e038..e2caf4b 100644 --- a/frontend/src/types/ausruestungsanfrage.types.ts +++ b/frontend/src/types/ausruestungsanfrage.types.ts @@ -114,6 +114,9 @@ export interface AusruestungAnfragePosition { zuweisung_typ?: 'ausruestung' | 'persoenlich' | 'keine' | null; zuweisung_ausruestung_id?: string | null; zuweisung_persoenlich_id?: string | null; + persoenlich_id?: string | null; + aktueller_zustand?: string | null; + neuer_zustand?: string | null; } export interface AusruestungAnfrageFormItem { @@ -123,6 +126,8 @@ export interface AusruestungAnfrageFormItem { notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[]; ist_ersatz?: boolean; + persoenlich_id?: string; + neuer_zustand?: string; } // ── API Response Types ── @@ -159,6 +164,18 @@ export interface AusruestungWidgetOverview { // ── Create-Orders Wizard ── +export interface UnassignedPosition { + id: number; + bezeichnung: string; + menge: number; + artikel_id: number | null; + anfrage_id: number; + anfrage_bezeichnung: string | null; + bestell_nummer: number | null; + bestell_jahr: number | null; + fuer_wen: string | null; +} + export interface CreateOrderPositionPayload { position_id: number; bezeichnung: string; diff --git a/frontend/src/types/personalEquipment.types.ts b/frontend/src/types/personalEquipment.types.ts index 01ea8c4..11c5b77 100644 --- a/frontend/src/types/personalEquipment.types.ts +++ b/frontend/src/types/personalEquipment.types.ts @@ -32,6 +32,8 @@ export interface PersoenlicheAusruestung { zustand: PersoenlicheAusruestungZustand; notizen?: string; anfrage_id?: number; + anfrage_position_id?: number; + eigenschaften?: { id: number; eigenschaft_id?: number | null; name: string; wert: string }[]; erstellt_am: string; aktualisiert_am: string; } @@ -48,6 +50,7 @@ export interface CreatePersoenlicheAusruestungPayload { anschaffung_datum?: string; zustand?: PersoenlicheAusruestungZustand; notizen?: string; + eigenschaften?: { eigenschaft_id?: number; name: string; wert: string }[]; } export interface UpdatePersoenlicheAusruestungPayload { @@ -62,4 +65,5 @@ export interface UpdatePersoenlicheAusruestungPayload { anschaffung_datum?: string | null; zustand?: PersoenlicheAusruestungZustand; notizen?: string | null; + eigenschaften?: { eigenschaft_id?: number | null; name: string; wert: string }[] | null; }