diff --git a/backend/src/controllers/ausruestungsanfrage.controller.ts b/backend/src/controllers/ausruestungsanfrage.controller.ts index 358fe61..c37597a 100644 --- a/backend/src/controllers/ausruestungsanfrage.controller.ts +++ b/backend/src/controllers/ausruestungsanfrage.controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import ausruestungsanfrageService from '../services/ausruestungsanfrage.service'; import notificationService from '../services/notification.service'; +import { permissionService } from '../services/permission.service'; import logger from '../utils/logger'; class AusruestungsanfrageController { @@ -129,9 +130,11 @@ class AusruestungsanfrageController { async createRequest(req: Request, res: Response): Promise { try { - const { items, notizen } = req.body as { + const { items, notizen, bezeichnung, fuer_benutzer_id } = req.body as { items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[]; notizen?: string; + bezeichnung?: string; + fuer_benutzer_id?: string; }; if (!items || items.length === 0) { @@ -150,7 +153,19 @@ class AusruestungsanfrageController { } } - const request = await ausruestungsanfrageService.createRequest(req.user!.id, items, notizen); + // Determine anfrager: self or on behalf of another user + let anfragerId = req.user!.id; + if (fuer_benutzer_id && fuer_benutzer_id !== req.user!.id) { + const groups = req.user?.groups ?? []; + const canOrderForUser = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:order_for_user'); + if (!canOrderForUser) { + res.status(403).json({ success: false, message: 'Keine Berechtigung für Bestellung im Auftrag' }); + return; + } + anfragerId = fuer_benutzer_id; + } + + const request = await ausruestungsanfrageService.createRequest(anfragerId, items, notizen, bezeichnung); res.status(201).json({ success: true, data: request }); } catch (error) { logger.error('AusruestungsanfrageController.createRequest error', { error }); @@ -158,6 +173,56 @@ class AusruestungsanfrageController { } } + async updateRequest(req: Request, res: Response): Promise { + try { + const id = Number(req.params.id); + const { bezeichnung, notizen, items } = req.body as { + bezeichnung?: string; + notizen?: string; + items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[]; + }; + + // Validate items if provided + if (items) { + if (items.length === 0) { + res.status(400).json({ success: false, message: 'Mindestens eine Position ist erforderlich' }); + return; + } + for (const item of items) { + if (!item.bezeichnung || item.bezeichnung.trim().length === 0) { + res.status(400).json({ success: false, message: 'Bezeichnung ist für alle Positionen erforderlich' }); + return; + } + if (!item.menge || item.menge < 1) { + res.status(400).json({ success: false, message: 'Menge muss mindestens 1 sein' }); + return; + } + } + } + + const existing = await ausruestungsanfrageService.getRequestById(id); + if (!existing) { + res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' }); + return; + } + + // 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')) { + res.status(403).json({ success: false, message: 'Keine Berechtigung zum Bearbeiten dieser Anfrage' }); + return; + } + + const updated = await ausruestungsanfrageService.updateRequest(id, { bezeichnung, notizen, items }); + res.status(200).json({ success: true, data: updated }); + } catch (error) { + logger.error('AusruestungsanfrageController.updateRequest error', { error }); + res.status(500).json({ success: false, message: 'Anfrage konnte nicht aktualisiert werden' }); + } + } + async updateRequestStatus(req: Request, res: Response): Promise { try { const id = Number(req.params.id); diff --git a/backend/src/database/migrations/047_update_ausruestungsanfrage.sql b/backend/src/database/migrations/047_update_ausruestungsanfrage.sql new file mode 100644 index 0000000..5995d55 --- /dev/null +++ b/backend/src/database/migrations/047_update_ausruestungsanfrage.sql @@ -0,0 +1,26 @@ +-- Migration 047: Update Ausrüstungsanfrage (Internal Orders) system +-- - Add bezeichnung column to ausruestung_anfragen +-- - Rename permissions: approve_requests → approve, view_overview → view_all +-- - Add new permission: ausruestungsanfrage:edit + +-- 1. Add bezeichnung column to anfragen table +ALTER TABLE ausruestung_anfragen ADD COLUMN IF NOT EXISTS bezeichnung TEXT; + +-- 2. Rename permissions +UPDATE permissions SET name = 'ausruestungsanfrage:approve' + WHERE name = 'ausruestungsanfrage:approve_requests'; + +UPDATE permissions SET name = 'ausruestungsanfrage:view_all' + WHERE name = 'ausruestungsanfrage:view_overview'; + +-- 3. Add new edit permission +INSERT INTO permissions (name, beschreibung, feature_group) +VALUES ('ausruestungsanfrage:edit', 'Alle Anfragen bearbeiten (unabhängig von Status/Besitzer)', 'ausruestungsanfrage') +ON CONFLICT (name) DO NOTHING; + +-- 4. Grant new edit permission to groups that had approve_requests (now approve) +INSERT INTO group_permissions (group_name, permission_name) +SELECT gp.group_name, 'ausruestungsanfrage:edit' +FROM group_permissions gp +WHERE gp.permission_name = 'ausruestungsanfrage:approve' +ON CONFLICT DO NOTHING; diff --git a/backend/src/routes/ausruestungsanfrage.routes.ts b/backend/src/routes/ausruestungsanfrage.routes.ts index c9336a2..e7c74b5 100644 --- a/backend/src/routes/ausruestungsanfrage.routes.ts +++ b/backend/src/routes/ausruestungsanfrage.routes.ts @@ -21,18 +21,19 @@ router.get('/categories', authenticate, requirePermission('ausruestungsanfrage:v // Overview // --------------------------------------------------------------------------- -router.get('/overview', authenticate, requirePermission('ausruestungsanfrage:view_overview'), ausruestungsanfrageController.getOverview.bind(ausruestungsanfrageController)); +router.get('/overview', authenticate, requirePermission('ausruestungsanfrage:view_all'), ausruestungsanfrageController.getOverview.bind(ausruestungsanfrageController)); // --------------------------------------------------------------------------- // Requests // --------------------------------------------------------------------------- -router.get('/requests', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.getRequests.bind(ausruestungsanfrageController)); +router.get('/requests', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.getRequests.bind(ausruestungsanfrageController)); router.get('/requests/my', authenticate, ausruestungsanfrageController.getMyRequests.bind(ausruestungsanfrageController)); router.get('/requests/:id', authenticate, ausruestungsanfrageController.getRequestById.bind(ausruestungsanfrageController)); router.post('/requests', authenticate, requirePermission('ausruestungsanfrage:create_request'), ausruestungsanfrageController.createRequest.bind(ausruestungsanfrageController)); -router.patch('/requests/:id/status', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.updateRequestStatus.bind(ausruestungsanfrageController)); -router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.deleteRequest.bind(ausruestungsanfrageController)); +router.patch('/requests/:id', authenticate, ausruestungsanfrageController.updateRequest.bind(ausruestungsanfrageController)); +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)); // --------------------------------------------------------------------------- // Linking requests to orders diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index d55cca7..6518fb8 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -189,7 +189,7 @@ async function getRequestById(id: number) { return { ...reqResult.rows[0], positionen: positionen.rows, - bestellungen: bestellungen.rows, + linked_bestellungen: bestellungen.rows, }; } @@ -197,6 +197,7 @@ async function createRequest( userId: string, items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[], notizen?: string, + bezeichnung?: string, ) { const client = await pool.connect(); try { @@ -213,10 +214,10 @@ async function createRequest( const nextNr = maxResult.rows[0].next_nr; const anfrageResult = await client.query( - `INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bestell_nummer, bestell_jahr) - VALUES ($1, $2, $3, $4) + `INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bezeichnung, bestell_nummer, bestell_jahr) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, - [userId, notizen || null, nextNr, currentYear], + [userId, notizen || null, bezeichnung || null, nextNr, currentYear], ); const anfrage = anfrageResult.rows[0]; @@ -252,6 +253,74 @@ async function createRequest( } } +async function updateRequest( + id: number, + data: { + bezeichnung?: string; + notizen?: string; + items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string }[]; + }, +) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Update anfrage fields + const fields: string[] = []; + const params: unknown[] = []; + + if (data.bezeichnung !== undefined) { + params.push(data.bezeichnung || null); + fields.push(`bezeichnung = $${params.length}`); + } + if (data.notizen !== undefined) { + params.push(data.notizen || null); + fields.push(`notizen = $${params.length}`); + } + + if (fields.length > 0) { + params.push(new Date()); + fields.push(`aktualisiert_am = $${params.length}`); + params.push(id); + await client.query( + `UPDATE ausruestung_anfragen SET ${fields.join(', ')} WHERE id = $${params.length}`, + params, + ); + } + + // Replace items if provided + 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; + 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; + } + } + 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], + ); + } + } + + await client.query('COMMIT'); + return getRequestById(id); + } catch (error) { + await client.query('ROLLBACK'); + logger.error('ausruestungsanfrageService.updateRequest failed', { error }); + throw error; + } finally { + client.release(); + } +} + async function updateRequestStatus( id: number, status: string, @@ -353,6 +422,7 @@ export default { getMyRequests, getRequestById, createRequest, + updateRequest, updateRequestStatus, deleteRequest, linkToOrder, diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index 60e4c0e..a796e4a 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -101,7 +101,7 @@ const PERMISSION_SUB_GROUPS: Record> = { }, ausruestungsanfrage: { 'Katalog': ['view', 'manage_catalog'], - 'Anfragen': ['create_request', 'approve_requests', 'link_orders', 'view_overview', 'order_for_user'], + 'Anfragen': ['create_request', 'approve', 'link_orders', 'view_all', 'order_for_user', 'edit'], 'Widget': ['widget'], }, admin: { diff --git a/frontend/src/components/dashboard/AusruestungsanfrageWidget.tsx b/frontend/src/components/dashboard/AusruestungsanfrageWidget.tsx index d506136..3665a83 100644 --- a/frontend/src/components/dashboard/AusruestungsanfrageWidget.tsx +++ b/frontend/src/components/dashboard/AusruestungsanfrageWidget.tsx @@ -20,7 +20,7 @@ function AusruestungsanfrageWidget() { return ( - Ausrüstungsanfragen + Interne Bestellungen @@ -31,7 +31,7 @@ function AusruestungsanfrageWidget() { return ( - Ausrüstungsanfragen + Interne Bestellungen Anfragen konnten nicht geladen werden. @@ -46,7 +46,7 @@ function AusruestungsanfrageWidget() { return ( - Ausrüstungsanfragen + Interne Bestellungen Keine offenen Anfragen @@ -60,7 +60,7 @@ function AusruestungsanfrageWidget() { - Ausrüstungsanfragen + Interne Bestellungen diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index a179f8d..5e182e0 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -121,7 +121,7 @@ const baseNavigationItems: NavigationItem[] = [ permission: 'bestellungen:view', }, { - text: 'Ausrüstungsanfragen', + text: 'Interne Bestellungen', icon: , path: '/ausruestungsanfrage', // subItems computed dynamically in navigationItems useMemo @@ -189,8 +189,8 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { const ausruestungSubItems: SubItem[] = []; let ausruestungTabIdx = 0; if (hasPermission('ausruestungsanfrage:create_request')) { ausruestungSubItems.push({ text: 'Meine Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; } - if (hasPermission('ausruestungsanfrage:approve_requests')) { ausruestungSubItems.push({ text: 'Alle Anfragen', path: `/ausruestungsanfrage?tab=${ausruestungTabIdx}` }); ausruestungTabIdx++; } - if (hasPermission('ausruestungsanfrage:view_overview')) { ausruestungSubItems.push({ text: 'Übersicht', 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/constants/widgets.ts b/frontend/src/constants/widgets.ts index 902ef14..a2d178f 100644 --- a/frontend/src/constants/widgets.ts +++ b/frontend/src/constants/widgets.ts @@ -14,7 +14,7 @@ export const WIDGETS = [ { key: 'adminStatus', label: 'Admin Status', defaultVisible: true }, { key: 'links', label: 'Links', defaultVisible: true }, { key: 'bestellungen', label: 'Bestellungen', defaultVisible: true }, - { key: 'ausruestungsanfragen', label: 'Ausrüstungsanfragen', defaultVisible: true }, + { key: 'ausruestungsanfragen', label: 'Interne Bestellungen', defaultVisible: true }, ] as const; export type WidgetKey = typeof WIDGETS[number]['key']; diff --git a/frontend/src/pages/Ausruestungsanfrage.tsx b/frontend/src/pages/Ausruestungsanfrage.tsx index ff1036c..0694f75 100644 --- a/frontend/src/pages/Ausruestungsanfrage.tsx +++ b/frontend/src/pages/Ausruestungsanfrage.tsx @@ -1,15 +1,14 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { Box, Tab, Tabs, Typography, Card, CardContent, CardActions, Grid, Button, Chip, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, - Badge, MenuItem, Select, FormControl, InputLabel, Autocomplete, Collapse, - Divider, Tooltip, + MenuItem, Select, FormControl, InputLabel, Autocomplete, + Divider, } from '@mui/material'; import { Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, ShoppingCart, Check as CheckIcon, Close as CloseIcon, Link as LinkIcon, - ExpandMore, ExpandLess, } from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useSearchParams } from 'react-router-dom'; @@ -32,28 +31,394 @@ function formatOrderId(r: AusruestungAnfrage): string { return `#${r.id}`; } -// ─── Catalog Tab ──────────────────────────────────────────────────────────── +const ACTIVE_STATUSES: AusruestungAnfrageStatus[] = ['offen', 'genehmigt', 'bestellt']; -interface DraftItem { - artikel_id?: number; - bezeichnung: string; - menge: number; - notizen?: string; +// ─── Detail Modal ───────────────────────────────────────────────────────────── + +interface DetailModalProps { + requestId: number | null; + onClose: () => void; + showAdminActions?: boolean; + showEditButton?: boolean; + canEditAny?: boolean; + currentUserId?: string; } +function DetailModal({ requestId, onClose, showAdminActions, showEditButton, canEditAny, currentUserId }: DetailModalProps) { + const { showSuccess, showError } = useNotification(); + const queryClient = useQueryClient(); + const { hasPermission } = usePermissionContext(); + + const [editing, setEditing] = useState(false); + const [editBezeichnung, setEditBezeichnung] = useState(''); + const [editNotizen, setEditNotizen] = useState(''); + const [editItems, setEditItems] = useState([]); + + // Admin action state + const [actionDialog, setActionDialog] = useState<{ action: 'genehmigt' | 'abgelehnt' } | null>(null); + const [adminNotizen, setAdminNotizen] = useState(''); + const [statusChangeValue, setStatusChangeValue] = useState(''); + const [linkDialog, setLinkDialog] = useState(false); + const [selectedBestellung, setSelectedBestellung] = useState(null); + + const { data: detail, isLoading } = useQuery({ + queryKey: ['ausruestungsanfrage', 'request', requestId], + queryFn: () => ausruestungsanfrageApi.getRequest(requestId!), + enabled: requestId != null, + }); + + const { data: catalogItems = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'items-for-edit'], + queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }), + enabled: editing, + }); + + const { data: bestellungen = [] } = useQuery({ + queryKey: ['bestellungen'], + queryFn: () => bestellungApi.getOrders(), + enabled: linkDialog, + }); + + const updateMut = useMutation({ + mutationFn: (data: { bezeichnung?: string; notizen?: string; items?: AusruestungAnfrageFormItem[] }) => + ausruestungsanfrageApi.updateRequest(requestId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); + showSuccess('Anfrage aktualisiert'); + setEditing(false); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const statusMut = useMutation({ + mutationFn: ({ status, notes }: { status: string; notes?: string }) => + ausruestungsanfrageApi.updateRequestStatus(requestId!, status, notes), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); + showSuccess('Status aktualisiert'); + setActionDialog(null); + setAdminNotizen(''); + setStatusChangeValue(''); + }, + onError: () => showError('Fehler beim Aktualisieren'), + }); + + const linkMut = useMutation({ + mutationFn: (bestellungId: number) => ausruestungsanfrageApi.linkToOrder(requestId!, bestellungId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); + showSuccess('Verknüpfung erstellt'); + setLinkDialog(false); + setSelectedBestellung(null); + }, + onError: () => showError('Fehler beim Verknüpfen'), + }); + + const startEditing = () => { + if (!detail) return; + setEditBezeichnung(detail.anfrage.bezeichnung || ''); + setEditNotizen(detail.anfrage.notizen || ''); + setEditItems(detail.positionen.map(p => ({ + artikel_id: p.artikel_id, + bezeichnung: p.bezeichnung, + menge: p.menge, + notizen: p.notizen, + }))); + setEditing(true); + }; + + const handleSaveEdit = () => { + if (editItems.length === 0) return; + updateMut.mutate({ + bezeichnung: editBezeichnung || undefined, + notizen: editNotizen || undefined, + items: editItems, + }); + }; + + const addEditItem = () => { + setEditItems(prev => [...prev, { bezeichnung: '', menge: 1 }]); + }; + + const removeEditItem = (idx: number) => { + setEditItems(prev => prev.filter((_, i) => i !== idx)); + }; + + const updateEditItem = (idx: number, field: string, value: unknown) => { + setEditItems(prev => prev.map((item, i) => i === idx ? { ...item, [field]: value } : item)); + }; + + if (!requestId) return null; + + const anfrage = detail?.anfrage; + const canEdit = anfrage && ( + canEditAny || + (anfrage.anfrager_id === currentUserId && anfrage.status === 'offen') + ); + + return ( + <> + + + + Anfrage {anfrage ? formatOrderId(anfrage) : '...'} + {anfrage?.bezeichnung && ` — ${anfrage.bezeichnung}`} + + {anfrage && ( + + )} + + + {isLoading ? ( + Lade Details... + ) : !detail ? ( + Anfrage nicht gefunden. + ) : editing ? ( + /* ── Edit Mode ── */ + <> + setEditBezeichnung(e.target.value)} + fullWidth + /> + setEditNotizen(e.target.value)} + multiline + rows={2} + fullWidth + /> + Positionen + {editItems.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') { + updateEditItem(idx, 'bezeichnung', v); + updateEditItem(idx, 'artikel_id', undefined); + } else if (v) { + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it)); + } + }} + onInputChange={(_, val, reason) => { + if (reason === 'input') { + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: val, artikel_id: undefined } : it)); + } + }} + renderInput={params => } + sx={{ flexGrow: 1 }} + /> + updateEditItem(idx, 'menge', Math.max(1, Number(e.target.value)))} + sx={{ width: 90 }} + inputProps={{ min: 1 }} + /> + removeEditItem(idx)}> + + + + ))} + + + ) : ( + /* ── View Mode ── */ + <> + {anfrage!.anfrager_name && ( + + Anfrager: {anfrage!.anfrager_name} + + )} + {anfrage!.notizen && ( + Notizen: {anfrage!.notizen} + )} + {anfrage!.admin_notizen && ( + Admin Notizen: {anfrage!.admin_notizen} + )} + + Erstellt am: {new Date(anfrage!.erstellt_am).toLocaleDateString('de-AT')} + + + + Positionen + {detail.positionen.map(p => ( + + - {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''} + + ))} + + {detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && ( + <> + + Verknüpfte Bestellungen + + {detail.linked_bestellungen.map(b => ( + + ))} + + + )} + + )} + + + {editing ? ( + <> + + + + ) : ( + <> + {/* Admin actions */} + {showAdminActions && anfrage && anfrage.status === 'offen' && ( + <> + + + + )} + {showAdminActions && anfrage && hasPermission('ausruestungsanfrage:approve') && ( + + + Status ändern + + + + )} + {showAdminActions && anfrage && anfrage.status === 'genehmigt' && hasPermission('ausruestungsanfrage:link_orders') && ( + + )} + {(showEditButton || canEditAny) && canEdit && !editing && ( + + )} + + + )} + + + + {/* Approve/Reject sub-dialog */} + setActionDialog(null)} maxWidth="sm" fullWidth> + {actionDialog?.action === 'genehmigt' ? 'Anfrage genehmigen' : 'Anfrage ablehnen'} + + setAdminNotizen(e.target.value)} + multiline + rows={2} + /> + + + + + + + + {/* Link to order sub-dialog */} + { setLinkDialog(false); setSelectedBestellung(null); }} maxWidth="sm" fullWidth> + Mit Bestellung verknüpfen + + `#${o.id} – ${o.bezeichnung}`} + value={selectedBestellung} + onChange={(_, v) => setSelectedBestellung(v)} + renderInput={params => } + /> + + + + + + + + ); +} + +// ─── Catalog Tab ──────────────────────────────────────────────────────────── + function KatalogTab() { const { showSuccess, showError } = useNotification(); const { hasPermission } = usePermissionContext(); const queryClient = useQueryClient(); const canManage = hasPermission('ausruestungsanfrage:manage_catalog'); - const canCreate = hasPermission('ausruestungsanfrage:create_request'); const [filterKategorie, setFilterKategorie] = useState(''); - const [draft, setDraft] = useState([]); - const [customText, setCustomText] = useState(''); - const [submitOpen, setSubmitOpen] = useState(false); - const [submitNotizen, setSubmitNotizen] = useState(''); const [artikelDialogOpen, setArtikelDialogOpen] = useState(false); const [editArtikel, setEditArtikel] = useState(null); const [artikelForm, setArtikelForm] = useState({ bezeichnung: '' }); @@ -83,38 +448,6 @@ function KatalogTab() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Artikel gelöscht'); }, onError: () => showError('Fehler beim Löschen'), }); - const createRequestMut = useMutation({ - mutationFn: ({ items, notizen }: { items: AusruestungAnfrageFormItem[]; notizen?: string }) => ausruestungsanfrageApi.createRequest(items, notizen), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); - showSuccess('Anfrage gesendet'); - setDraft([]); - setSubmitOpen(false); - setSubmitNotizen(''); - }, - onError: () => showError('Fehler beim Senden der Anfrage'), - }); - - const addToDraft = (item: AusruestungArtikel) => { - setDraft(prev => { - const existing = prev.find(d => d.artikel_id === item.id); - if (existing) return prev.map(d => d.artikel_id === item.id ? { ...d, menge: d.menge + 1 } : d); - return [...prev, { artikel_id: item.id, bezeichnung: item.bezeichnung, menge: 1 }]; - }); - }; - - const addCustomToDraft = () => { - const text = customText.trim(); - if (!text) return; - setDraft(prev => [...prev, { bezeichnung: text, menge: 1 }]); - setCustomText(''); - }; - - const removeDraftItem = (idx: number) => setDraft(prev => prev.filter((_, i) => i !== idx)); - const updateDraftMenge = (idx: number, menge: number) => { - if (menge < 1) return; - setDraft(prev => prev.map((d, i) => i === idx ? { ...d, menge } : d)); - }; const openNewArtikel = () => { setEditArtikel(null); @@ -132,14 +465,6 @@ function KatalogTab() { else createItemMut.mutate(artikelForm); }; - const handleSubmitRequest = () => { - if (draft.length === 0) return; - createRequestMut.mutate({ - items: draft.map(d => ({ artikel_id: d.artikel_id, bezeichnung: d.bezeichnung, menge: d.menge, notizen: d.notizen })), - notizen: submitNotizen || undefined, - }); - }; - return ( {/* Filter */} @@ -151,13 +476,6 @@ function KatalogTab() { {categories.map(c => {c})} - {canCreate && ( - - - - )} {/* Catalog grid */} @@ -186,67 +504,22 @@ function KatalogTab() { )} - - {canCreate && ( - - )} - {canManage && ( - - openEditArtikel(item)}> - deleteItemMut.mutate(item.id)}> - - )} - + {canManage && ( + + openEditArtikel(item)}> + deleteItemMut.mutate(item.id)}> + + )} ))} )} - {/* Free-text item + draft summary */} - {canCreate && ( - - - {draft.length > 0 ? 'Anfrage-Entwurf' : 'Freitext-Position hinzufügen'} - - {draft.map((d, idx) => ( - - {d.bezeichnung} - updateDraftMenge(idx, Number(e.target.value))} sx={{ width: 70 }} inputProps={{ min: 1 }} /> - removeDraftItem(idx)}> - - ))} - {draft.length > 0 && } - - setCustomText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addCustomToDraft(); }} sx={{ flexGrow: 1 }} /> - - - {draft.length > 0 && ( - - )} - - )} - - {/* Submit dialog */} - setSubmitOpen(false)} maxWidth="sm" fullWidth> - Anfrage absenden - - Folgende Positionen werden angefragt: - {draft.map((d, idx) => ( - - {d.menge}x {d.bezeichnung} - ))} - setSubmitNotizen(e.target.value)} multiline rows={2} sx={{ mt: 2 }} /> - - - - - - - {/* Artikel create/edit dialog */} setArtikelDialogOpen(false)} maxWidth="sm" fullWidth> {editArtikel ? 'Artikel bearbeiten' : 'Neuer Artikel'} - + setArtikelForm(f => ({ ...f, bezeichnung: e.target.value }))} fullWidth /> setArtikelForm(f => ({ ...f, beschreibung: e.target.value }))} /> (null); const { hasPermission } = usePermissionContext(); const queryClient = useQueryClient(); const { showSuccess, showError } = useNotification(); const canCreate = hasPermission('ausruestungsanfrage:create_request'); + const canOrderForUser = hasPermission('ausruestungsanfrage:order_for_user'); + const canEditAny = hasPermission('ausruestungsanfrage:edit'); + const [detailId, setDetailId] = useState(null); + const [statusFilter, setStatusFilter] = useState(ACTIVE_STATUSES); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newBezeichnung, setNewBezeichnung] = useState(''); - const [newBemerkung, setNewBemerkung] = useState(''); - const [selectedArtikel, setSelectedArtikel] = useState([]); + const [newNotizen, setNewNotizen] = useState(''); + const [newFuerBenutzer, setNewFuerBenutzer] = useState<{ id: string; name: string } | null>(null); + const [newItems, setNewItems] = useState([{ bezeichnung: '', menge: 1 }]); const { data: requests = [], isLoading } = useQuery({ queryKey: ['ausruestungsanfrage', 'myRequests'], queryFn: () => ausruestungsanfrageApi.getMyRequests(), }); - const { data: detail } = useQuery({ - queryKey: ['ausruestungsanfrage', 'request', expandedId], - queryFn: () => ausruestungsanfrageApi.getRequest(expandedId!), - enabled: expandedId != null, - }); - const { data: catalogItems = [] } = useQuery({ queryKey: ['ausruestungsanfrage', 'items-for-create'], queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }), enabled: createDialogOpen, }); + const { data: orderUsers = [] } = useQuery({ + queryKey: ['ausruestungsanfrage', 'orderUsers'], + queryFn: () => ausruestungsanfrageApi.getOrderUsers(), + enabled: createDialogOpen && canOrderForUser, + }); + const createMut = useMutation({ - mutationFn: ({ items, notizen }: { items: AusruestungAnfrageFormItem[]; notizen?: string }) => - ausruestungsanfrageApi.createRequest(items, notizen), + mutationFn: (args: { items: AusruestungAnfrageFormItem[]; notizen?: string; bezeichnung?: string; fuer_benutzer_id?: string }) => + ausruestungsanfrageApi.createRequest(args.items, args.notizen, args.bezeichnung, args.fuer_benutzer_id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); showSuccess('Anfrage erstellt'); setCreateDialogOpen(false); - setNewBezeichnung(''); - setNewBemerkung(''); - setSelectedArtikel([]); + resetCreateForm(); }, onError: () => showError('Fehler beim Erstellen der Anfrage'), }); - const handleCreateSubmit = () => { - const items: AusruestungAnfrageFormItem[] = []; - - // Add selected catalog items - for (const a of selectedArtikel) { - items.push({ artikel_id: a.id, bezeichnung: a.bezeichnung, menge: 1 }); - } - - // Add free-text item if provided - const text = newBezeichnung.trim(); - if (text) { - items.push({ bezeichnung: text, menge: 1 }); - } - - if (items.length === 0) return; - - createMut.mutate({ items, notizen: newBemerkung || undefined }); + const resetCreateForm = () => { + setNewBezeichnung(''); + setNewNotizen(''); + setNewFuerBenutzer(null); + setNewItems([{ bezeichnung: '', menge: 1 }]); }; + const handleCreateSubmit = () => { + const validItems = newItems.filter(i => i.bezeichnung.trim()); + if (validItems.length === 0) return; + createMut.mutate({ + items: validItems, + notizen: newNotizen || undefined, + bezeichnung: newBezeichnung || undefined, + fuer_benutzer_id: newFuerBenutzer?.id, + }); + }; + + const filteredRequests = useMemo(() => { + if (statusFilter.length === 0) return requests; + return requests.filter(r => statusFilter.includes(r.status)); + }, [requests, statusFilter]); + + const handleStatusFilterChange = (value: string) => { + if (value === 'all') { + setStatusFilter([]); + } else if (value === 'active') { + setStatusFilter(ACTIVE_STATUSES); + } else { + setStatusFilter([value as AusruestungAnfrageStatus]); + } + }; + + const currentFilterValue = useMemo(() => { + if (statusFilter.length === 0) return 'all'; + if (statusFilter.length === ACTIVE_STATUSES.length && ACTIVE_STATUSES.every(s => statusFilter.includes(s))) return 'active'; + return statusFilter[0] || 'all'; + }, [statusFilter]); + if (isLoading) return Lade Anfragen...; - if (requests.length === 0 && !canCreate) return Keine Anfragen vorhanden.; return ( - {requests.length === 0 ? ( + + + Status + + + + + {filteredRequests.length === 0 ? ( Keine Anfragen vorhanden. ) : ( - Anfrage + Bezeichnung Status Positionen Erstellt am - Admin Notizen - {requests.map(r => ( - <> - setExpandedId(prev => prev === r.id ? null : r.id)}> - {expandedId === r.id ? : } - {formatOrderId(r)} - - {r.positionen_count ?? r.items_count ?? '-'} - {new Date(r.erstellt_am).toLocaleDateString('de-AT')} - {r.admin_notizen || '-'} - - {expandedId === r.id && ( - - - - - {r.notizen && Notizen: {r.notizen}} - {detail ? ( - <> - {detail.positionen.map(p => ( - - {p.menge}x {p.bezeichnung}{p.notizen ? ` (${p.notizen})` : ''} - ))} - {detail.linked_bestellungen && detail.linked_bestellungen.length > 0 && ( - - Verknüpfte Bestellungen: - {detail.linked_bestellungen.map(b => ( - - ))} - - )} - - ) : ( - Lade Details... - )} - - - - - )} - + {filteredRequests.map(r => ( + setDetailId(r.id)}> + {formatOrderId(r)} + {r.bezeichnung || '-'} + + {r.positionen_count ?? r.items_count ?? '-'} + {new Date(r.erstellt_am).toLocaleDateString('de-AT')} + ))}
)} + {/* Detail Modal */} + setDetailId(null)} + showEditButton + canEditAny={canEditAny} + /> + {/* Create Request Dialog */} - setCreateDialogOpen(false)} maxWidth="sm" fullWidth> - Neue Ausrüstungsanfrage - + { setCreateDialogOpen(false); resetCreateForm(); }} maxWidth="sm" fullWidth> + Neue Bestellung + setNewBezeichnung(e.target.value)} fullWidth /> - o.bezeichnung} - value={selectedArtikel} - onChange={(_, v) => setSelectedArtikel(v)} - renderInput={params => } - /> + {canOrderForUser && ( + o.name} + value={newFuerBenutzer} + onChange={(_, v) => setNewFuerBenutzer(v)} + renderInput={params => } + /> + )} setNewBemerkung(e.target.value)} + label="Notizen (optional)" + value={newNotizen} + onChange={e => setNewNotizen(e.target.value)} multiline rows={2} fullWidth /> + + 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}> + + + + ))} + - + @@ -459,64 +783,47 @@ function MeineAnfragenTab() { // ─── Admin All Requests Tab ───────────────────────────────────────────────── function AlleAnfragenTab() { - const { showSuccess, showError } = useNotification(); - const queryClient = useQueryClient(); - + const { hasPermission } = usePermissionContext(); const [statusFilter, setStatusFilter] = useState(''); - const [expandedId, setExpandedId] = useState(null); - const [actionDialog, setActionDialog] = useState<{ id: number; action: 'genehmigt' | 'abgelehnt' } | null>(null); - const [adminNotizen, setAdminNotizen] = useState(''); - const [linkDialog, setLinkDialog] = useState(null); - const [selectedBestellung, setSelectedBestellung] = useState(null); + const [detailId, setDetailId] = useState(null); + + const canEditAny = hasPermission('ausruestungsanfrage:edit'); const { data: requests = [], isLoading } = useQuery({ queryKey: ['ausruestungsanfrage', 'requests', statusFilter], queryFn: () => ausruestungsanfrageApi.getRequests(statusFilter ? { status: statusFilter } : undefined), }); - const { data: detail } = useQuery({ - queryKey: ['ausruestungsanfrage', 'request', expandedId], - queryFn: () => ausruestungsanfrageApi.getRequest(expandedId!), - enabled: expandedId != null, - }); - - const { data: bestellungen = [] } = useQuery({ - queryKey: ['bestellungen'], - queryFn: () => bestellungApi.getOrders(), - enabled: linkDialog != null, - }); - - const statusMut = useMutation({ - mutationFn: ({ id, status, notes }: { id: number; status: string; notes?: string }) => ausruestungsanfrageApi.updateRequestStatus(id, status, notes), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); - showSuccess('Status aktualisiert'); - setActionDialog(null); - setAdminNotizen(''); - }, - onError: () => showError('Fehler beim Aktualisieren'), - }); - - const linkMut = useMutation({ - mutationFn: ({ anfrageId, bestellungId }: { anfrageId: number; bestellungId: number }) => ausruestungsanfrageApi.linkToOrder(anfrageId, bestellungId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage'] }); - showSuccess('Verknüpfung erstellt'); - setLinkDialog(null); - setSelectedBestellung(null); - }, - onError: () => showError('Fehler beim Verknüpfen'), - }); - - const handleAction = () => { - if (!actionDialog) return; - statusMut.mutate({ id: actionDialog.id, status: actionDialog.action, notes: adminNotizen || undefined }); - }; + // Summary counts + const openCount = useMemo(() => requests.filter(r => r.status === 'offen').length, [requests]); + const approvedCount = useMemo(() => requests.filter(r => r.status === 'genehmigt').length, [requests]); if (isLoading) return Lade Anfragen...; return ( + {/* Summary cards */} + + + + {openCount} + Offene Anfragen + + + + + {approvedCount} + Genehmigte Anfragen + + + + + {requests.length} + Alle Anfragen + + + + Status Filter