diff --git a/backend/src/controllers/ausruestungsanfrage.controller.ts b/backend/src/controllers/ausruestungsanfrage.controller.ts index d9035aa..14e183f 100644 --- a/backend/src/controllers/ausruestungsanfrage.controller.ts +++ b/backend/src/controllers/ausruestungsanfrage.controller.ts @@ -507,6 +507,30 @@ class AusruestungsanfrageController { } } + async createOrders(req: Request, res: Response): Promise { + try { + const anfrageId = Number(req.params.id); + const { orders } = req.body as { + orders: Array<{ + lieferant_id: number; + bezeichnung: string; + positionen: Array<{ position_id: number; bezeichnung: string; menge: number; einheit?: string; notizen?: string }>; + }>; + }; + + if (!orders || orders.length === 0) { + res.status(400).json({ success: false, message: 'Mindestens eine Bestellung ist erforderlich' }); + return; + } + + const created = await ausruestungsanfrageService.createOrdersFromRequest(anfrageId, orders, req.user!.id); + res.status(201).json({ success: true, data: { created_bestellungen: created } }); + } catch (error) { + logger.error('AusruestungsanfrageController.createOrders error', { error }); + res.status(500).json({ success: false, message: 'Bestellungen konnten nicht erstellt werden' }); + } + } + // ------------------------------------------------------------------------- // Widget overview (lightweight, for dashboard widget) // ------------------------------------------------------------------------- diff --git a/backend/src/routes/ausruestungsanfrage.routes.ts b/backend/src/routes/ausruestungsanfrage.routes.ts index cfd3cc7..0e0c90d 100644 --- a/backend/src/routes/ausruestungsanfrage.routes.ts +++ b/backend/src/routes/ausruestungsanfrage.routes.ts @@ -69,5 +69,6 @@ router.patch('/positionen/:positionId/geliefert', authenticate, requirePermissio router.post('/requests/:id/link', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.linkToOrder.bind(ausruestungsanfrageController)); router.delete('/requests/:id/link/:bestellungId', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.unlinkFromOrder.bind(ausruestungsanfrageController)); +router.post('/requests/:id/create-orders', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.createOrders.bind(ausruestungsanfrageController)); export default router; diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index 7aa1097..be54fb8 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -716,6 +716,73 @@ async function getLinkedOrders(anfrageId: number) { return result.rows; } +async function createOrdersFromRequest( + anfrageId: number, + orders: Array<{ + lieferant_id: number; + bezeichnung: string; + positionen: Array<{ position_id: number; bezeichnung: string; menge: number; einheit?: string; notizen?: string }>; + }>, + userId: string, +) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const createdBestellungen: Array<{ id: number; bezeichnung: string; lieferant_name: string }> = []; + + for (const orderData of orders) { + const nrResult = await client.query( + `SELECT COALESCE(MAX(laufende_nummer), 0) + 1 AS next_nr + FROM bestellungen + WHERE EXTRACT(YEAR FROM erstellt_am) = EXTRACT(YEAR FROM NOW())` + ); + const laufendeNummer = nrResult.rows[0].next_nr; + + const bestellungResult = await client.query( + `INSERT INTO bestellungen (bezeichnung, lieferant_id, status, laufende_nummer, erstellt_von) + VALUES ($1, $2, 'wartet_auf_genehmigung', $3, $4) + RETURNING id, bezeichnung`, + [orderData.bezeichnung, orderData.lieferant_id, laufendeNummer, userId] + ); + const bestellung = bestellungResult.rows[0]; + + for (const pos of orderData.positionen) { + 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] + ); + } + + await client.query( + `INSERT INTO ausruestung_anfrage_bestellung (anfrage_id, bestellung_id) + VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [anfrageId, bestellung.id] + ); + + const vendorResult = await client.query('SELECT name FROM lieferanten WHERE id = $1', [orderData.lieferant_id]); + const lieferantName = vendorResult.rows[0]?.name ?? ''; + + createdBestellungen.push({ id: bestellung.id, bezeichnung: bestellung.bezeichnung, lieferant_name: lieferantName }); + } + + await client.query( + `UPDATE ausruestung_anfragen SET status = 'bestellt', aktualisiert_am = NOW() WHERE id = $1`, + [anfrageId] + ); + + await client.query('COMMIT'); + return createdBestellungen; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('AusruestungsanfrageService.createOrdersFromRequest failed', { error }); + throw new Error('Bestellungen konnten nicht erstellt werden'); + } finally { + client.release(); + } +} + // --------------------------------------------------------------------------- // Overview (aggregated) // --------------------------------------------------------------------------- @@ -795,6 +862,7 @@ export default { linkToOrder, unlinkFromOrder, getLinkedOrders, + createOrdersFromRequest, getOverview, getWidgetOverview, }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ea33eb..fa80b44 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ import BestellungNeu from './pages/BestellungNeu'; import LieferantDetail from './pages/LieferantDetail'; import Ausruestungsanfrage from './pages/Ausruestungsanfrage'; import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail'; +import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestellung'; import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail'; import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu'; import Issues from './pages/Issues'; @@ -324,6 +325,14 @@ function App() { } /> + + + + } + /> (null); const [adminNotizen, setAdminNotizen] = useState(''); const [statusChangeValue, setStatusChangeValue] = useState(''); - const [linkDialog, setLinkDialog] = useState(false); - const [selectedBestellung, setSelectedBestellung] = useState(null); // Permissions const showAdminActions = hasPermission('ausruestungsanfrage:approve'); @@ -80,12 +76,6 @@ export default function AusruestungsanfrageDetail() { enabled: editing, }); - const { data: bestellungen = [] } = useQuery({ - queryKey: ['bestellungen'], - queryFn: () => bestellungApi.getOrders(), - enabled: linkDialog, - }); - // ── Mutations ── const updateMut = useMutation({ mutationFn: (data: { bezeichnung?: string; notizen?: string; items?: AusruestungAnfrageFormItem[] }) => @@ -111,17 +101,6 @@ export default function AusruestungsanfrageDetail() { 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 geliefertMut = useMutation({ mutationFn: ({ positionId, geliefert }: { positionId: number; geliefert: boolean }) => ausruestungsanfrageApi.updatePositionGeliefert(positionId, geliefert), @@ -414,11 +393,12 @@ export default function AusruestungsanfrageDetail() { )} {showAdminActions && anfrage && anfrage.status === 'genehmigt' && canLink && ( )} {canEdit && !editing && ( @@ -459,29 +439,6 @@ export default function AusruestungsanfrageDetail() { - {/* 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 => } - /> - - - - - - ); } diff --git a/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx b/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx new file mode 100644 index 0000000..7178c9f --- /dev/null +++ b/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx @@ -0,0 +1,426 @@ +import { useState, useMemo, useEffect } from 'react'; +import { + Box, Typography, Paper, Button, IconButton, Chip, + Table, TableBody, TableCell, TableHead, TableRow, + Autocomplete, TextField, Alert, CircularProgress, + Dialog, DialogTitle, DialogContent, DialogActions, + LinearProgress, +} from '@mui/material'; +import { + ArrowBack, + Add as AddIcon, + ShoppingCart as ShoppingCartIcon, +} 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 { useNotification } from '../contexts/NotificationContext'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; +import { bestellungApi } from '../services/bestellung'; +import { AUSRUESTUNG_STATUS_LABELS } from '../types/ausruestungsanfrage.types'; +import type { + AusruestungAnfrage, + AusruestungAnfragePosition, + CreateOrdersRequest, +} from '../types/ausruestungsanfrage.types'; +import type { Lieferant } from '../types/bestellung.types'; + +// ── Helpers ── + +function formatOrderId(r: AusruestungAnfrage): string { + if (r.bestell_jahr && r.bestell_nummer) { + return `${r.bestell_jahr}/${String(r.bestell_nummer).padStart(3, '0')}`; + } + return `#${r.id}`; +} + +interface VendorAssignment { + lieferantId: number; + lieferantName: string; +} + +interface VendorGroup { + lieferantId: number; + lieferantName: string; + positionen: AusruestungAnfragePosition[]; +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Component +// ══════════════════════════════════════════════════════════════════════════════ + +export default function AusruestungsanfrageZuBestellung() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + const { hasPermission } = usePermissionContext(); + + const requestId = Number(id); + const canManageVendors = hasPermission('bestellungen:manage_vendors'); + + // ── Data ── + const { data: detail, isLoading, isError } = useQuery({ + queryKey: ['ausruestungsanfrage', requestId], + queryFn: () => ausruestungsanfrageApi.getRequest(requestId), + enabled: !!requestId, + }); + + const { data: vendors = [] } = useQuery({ + queryKey: ['bestellung-vendors'], + queryFn: () => bestellungApi.getVendors(), + }); + + const anfrage = detail?.anfrage; + const positionen: AusruestungAnfragePosition[] = detail?.positionen ?? []; + + // ── State ── + const [assignments, setAssignments] = useState>({}); + const [orderNames, setOrderNames] = useState>({}); + + // New vendor dialog + const [newVendorDialog, setNewVendorDialog] = useState(false); + const [newVendorName, setNewVendorName] = useState(''); + const [newVendorKontakt, setNewVendorKontakt] = useState(''); + const [newVendorEmail, setNewVendorEmail] = useState(''); + const [newVendorTelefon, setNewVendorTelefon] = useState(''); + // Track which position triggered the new-vendor dialog + const [newVendorTargetPosId, setNewVendorTargetPosId] = useState(null); + + // ── Derived: vendor groups ── + const vendorGroups: VendorGroup[] = useMemo(() => { + const map = new Map(); + positionen.forEach(pos => { + const a = assignments[pos.id]; + if (!a) return; + if (!map.has(a.lieferantId)) { + map.set(a.lieferantId, { lieferantId: a.lieferantId, lieferantName: a.lieferantName, positionen: [] }); + } + map.get(a.lieferantId)!.positionen.push(pos); + }); + return [...map.values()]; + }, [assignments, positionen]); + + // ── Auto-fill order names when a new vendor group appears ── + useEffect(() => { + if (!anfrage) return; + const label = formatOrderId(anfrage); + vendorGroups.forEach(g => { + setOrderNames(prev => { + if (prev[g.lieferantId]) return prev; + return { ...prev, [g.lieferantId]: `Anfrage ${label} – ${g.lieferantName}` }; + }); + }); + }, [vendorGroups, anfrage]); + + // ── Derived: progress ── + const assignedCount = positionen.filter(p => assignments[p.id] != null).length; + const allAssigned = positionen.length > 0 && assignedCount === positionen.length; + + // ── Mutations ── + const createOrdersMut = useMutation({ + mutationFn: (payload: CreateOrdersRequest) => + ausruestungsanfrageApi.createOrders(requestId, payload), + onSuccess: (result) => { + showSuccess(`${result.created_bestellungen.length} Bestellung(en) erfolgreich erstellt`); + queryClient.invalidateQueries({ queryKey: ['ausruestungsanfrage', requestId] }); + navigate('/bestellungen'); + }, + onError: () => showError('Bestellungen konnten nicht erstellt werden'), + }); + + const createVendorMut = useMutation({ + mutationFn: (data: { name: string; kontakt_name?: string; email?: string; telefon?: string }) => + bestellungApi.createVendor(data), + onSuccess: (newVendor: Lieferant) => { + queryClient.invalidateQueries({ queryKey: ['bestellung-vendors'] }); + // Auto-assign to the position that triggered the dialog + if (newVendorTargetPosId != null) { + setAssignments(prev => ({ + ...prev, + [newVendorTargetPosId]: { lieferantId: newVendor.id, lieferantName: newVendor.name }, + })); + } + setNewVendorDialog(false); + setNewVendorName(''); + setNewVendorKontakt(''); + setNewVendorEmail(''); + setNewVendorTelefon(''); + setNewVendorTargetPosId(null); + showSuccess(`Lieferant "${newVendor.name}" erstellt`); + }, + onError: () => showError('Lieferant konnte nicht erstellt werden'), + }); + + // ── Handlers ── + const handleSubmit = () => { + if (!allAssigned) return; + const orders = vendorGroups.map(g => ({ + lieferant_id: g.lieferantId, + bezeichnung: orderNames[g.lieferantId] || `Anfrage – ${g.lieferantName}`, + positionen: g.positionen.map(p => ({ + position_id: p.id, + bezeichnung: p.bezeichnung, + menge: p.menge, + einheit: p.einheit, + notizen: p.notizen, + })), + })); + createOrdersMut.mutate({ orders }); + }; + + const handleCreateVendor = () => { + if (!newVendorName.trim()) return; + createVendorMut.mutate({ + name: newVendorName.trim(), + kontakt_name: newVendorKontakt.trim() || undefined, + email: newVendorEmail.trim() || undefined, + telefon: newVendorTelefon.trim() || undefined, + }); + }; + + // ── Loading / error states ── + if (isLoading) { + return ( + + + + + + ); + } + + if (isError || !anfrage) { + return ( + + Anfrage konnte nicht geladen werden. + + ); + } + + // ── Render ── + return ( + + + + {/* ── Header ── */} + + navigate(`/ausruestungsanfrage/${requestId}`)} size="small"> + + + + + Bestellungen erstellen + + + {anfrage.bezeichnung || `Anfrage ${formatOrderId(anfrage)}`} + {' · '} + + + + + + + {/* ── Progress bar ── */} + 0 ? (assignedCount / positionen.length) * 100 : 0} + sx={{ mb: 3, borderRadius: 2, height: 6 }} + color={allAssigned ? 'success' : 'primary'} + /> + + {/* ── Items table ── */} + + + Lieferanten zuweisen + + + + + Artikel + Menge + Lieferant + + + + {positionen.map(pos => { + const assignment = assignments[pos.id]; + const selectedVendor = assignment + ? vendors.find(v => v.id === assignment.lieferantId) ?? null + : null; + return ( + + + {pos.bezeichnung} + {pos.notizen && ( + {pos.notizen} + )} + + + {pos.menge} {pos.einheit ?? 'Stk'} + + + + + size="small" + sx={{ flex: 1 }} + options={vendors} + getOptionLabel={o => o.name} + value={selectedVendor} + onChange={(_, v) => { + setAssignments(prev => ({ + ...prev, + [pos.id]: v ? { lieferantId: v.id, lieferantName: v.name } : null, + })); + }} + renderInput={params => ( + + )} + isOptionEqualToValue={(o, v) => o.id === v.id} + /> + {canManageVendors && ( + { + setNewVendorTargetPosId(pos.id); + setNewVendorDialog(true); + }} + > + + + )} + + + + ); + })} + +
+
+ + {/* ── Preview section ── */} + {vendorGroups.length > 0 && ( + + + Vorschau — {vendorGroups.length} Bestellung{vendorGroups.length !== 1 ? 'en' : ''} werden erstellt + + + {vendorGroups.map(g => ( + + + {g.lieferantName} + + + {g.positionen.length} Artikel + + + {g.positionen.map(p => ( + + · {p.bezeichnung} ×{p.menge} + + ))} + + + setOrderNames(prev => ({ ...prev, [g.lieferantId]: e.target.value })) + } + /> + + ))} + + + )} + + {/* ── Bottom action bar ── */} + + {!allAssigned && positionen.length > 0 && ( + + {positionen.length - assignedCount} Artikel {positionen.length - assignedCount === 1 ? 'hat' : 'haben'} noch keinen Lieferanten. + + )} + + +
+ + {/* ── New vendor dialog ── */} + { setNewVendorDialog(false); setNewVendorTargetPosId(null); }} + maxWidth="sm" + fullWidth + > + Neuen Lieferanten anlegen + + setNewVendorName(e.target.value)} + autoFocus + fullWidth + /> + setNewVendorKontakt(e.target.value)} + fullWidth + /> + setNewVendorEmail(e.target.value)} + fullWidth + type="email" + /> + setNewVendorTelefon(e.target.value)} + fullWidth + /> + + + + + + +
+ ); +} diff --git a/frontend/src/services/ausruestungsanfrage.ts b/frontend/src/services/ausruestungsanfrage.ts index 8a5c000..3502379 100644 --- a/frontend/src/services/ausruestungsanfrage.ts +++ b/frontend/src/services/ausruestungsanfrage.ts @@ -9,6 +9,8 @@ import type { AusruestungEigenschaft, AusruestungAnfrage, AusruestungWidgetOverview, + CreateOrdersRequest, + CreateOrdersResponse, } from '../types/ausruestungsanfrage.types'; export const ausruestungsanfrageApi = { @@ -127,6 +129,12 @@ export const ausruestungsanfrageApi = { await api.delete(`/api/ausruestungsanfragen/requests/${anfrageId}/link/${bestellungId}`); }, + // ── Create orders from request ── + createOrders: async (anfrageId: number, payload: CreateOrdersRequest): Promise => { + const r = await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/create-orders`, payload); + return r.data.data; + }, + // ── Overview ── getOverview: async (): Promise => { const r = await api.get('/api/ausruestungsanfragen/overview'); diff --git a/frontend/src/types/ausruestungsanfrage.types.ts b/frontend/src/types/ausruestungsanfrage.types.ts index bef0eb8..a40b9e7 100644 --- a/frontend/src/types/ausruestungsanfrage.types.ts +++ b/frontend/src/types/ausruestungsanfrage.types.ts @@ -101,6 +101,7 @@ export interface AusruestungAnfragePosition { artikel_id?: number; bezeichnung: string; menge: number; + einheit?: string; notizen?: string; geliefert: boolean; erstellt_am: string; @@ -145,3 +146,33 @@ export interface AusruestungWidgetOverview { approved_count: number; unhandled_count: number; } + +// ── Create-Orders Wizard ── + +export interface CreateOrderPositionPayload { + position_id: number; + bezeichnung: string; + menge: number; + einheit?: string; + notizen?: string; +} + +export interface CreateOrderPayload { + lieferant_id: number; + bezeichnung: string; + positionen: CreateOrderPositionPayload[]; +} + +export interface CreateOrdersRequest { + orders: CreateOrderPayload[]; +} + +export interface CreatedBestellung { + id: number; + bezeichnung: string; + lieferant_name: string; +} + +export interface CreateOrdersResponse { + created_bestellungen: CreatedBestellung[]; +}