add linking between internal and external orders

This commit is contained in:
Matthias Hochmeister
2026-03-27 11:18:06 +01:00
parent 75e533c1fc
commit 90f691d607
8 changed files with 573 additions and 49 deletions

View File

@@ -507,6 +507,30 @@ class AusruestungsanfrageController {
} }
} }
async createOrders(req: Request, res: Response): Promise<void> {
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) // Widget overview (lightweight, for dashboard widget)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -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.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.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; export default router;

View File

@@ -716,6 +716,73 @@ async function getLinkedOrders(anfrageId: number) {
return result.rows; 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) // Overview (aggregated)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -795,6 +862,7 @@ export default {
linkToOrder, linkToOrder,
unlinkFromOrder, unlinkFromOrder,
getLinkedOrders, getLinkedOrders,
createOrdersFromRequest,
getOverview, getOverview,
getWidgetOverview, getWidgetOverview,
}; };

View File

@@ -33,6 +33,7 @@ import BestellungNeu from './pages/BestellungNeu';
import LieferantDetail from './pages/LieferantDetail'; import LieferantDetail from './pages/LieferantDetail';
import Ausruestungsanfrage from './pages/Ausruestungsanfrage'; import Ausruestungsanfrage from './pages/Ausruestungsanfrage';
import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail'; import AusruestungsanfrageDetail from './pages/AusruestungsanfrageDetail';
import AusruestungsanfrageZuBestellung from './pages/AusruestungsanfrageZuBestellung';
import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail'; import AusruestungsanfrageArtikelDetail from './pages/AusruestungsanfrageArtikelDetail';
import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu'; import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
import Issues from './pages/Issues'; import Issues from './pages/Issues';
@@ -324,6 +325,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/ausruestungsanfragen/:id/bestellen"
element={
<ProtectedRoute>
<AusruestungsanfrageZuBestellung />
</ProtectedRoute>
}
/>
<Route <Route
path="/issues" path="/issues"
element={ element={

View File

@@ -8,7 +8,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, ArrowBack, Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon,
Check as CheckIcon, Close as CloseIcon, Link as LinkIcon, Check as CheckIcon, Close as CloseIcon, ShoppingCart as ShoppingCartIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
@@ -17,13 +17,11 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { bestellungApi } from '../services/bestellung';
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types'; import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
import type { import type {
AusruestungAnfrage, AusruestungAnfrageDetailResponse, AusruestungAnfrage, AusruestungAnfrageDetailResponse,
AusruestungAnfrageFormItem, AusruestungAnfrageStatus, AusruestungAnfrageFormItem, AusruestungAnfrageStatus,
} from '../types/ausruestungsanfrage.types'; } from '../types/ausruestungsanfrage.types';
import type { Bestellung } from '../types/bestellung.types';
// ── Helpers ── // ── Helpers ──
@@ -58,8 +56,6 @@ export default function AusruestungsanfrageDetail() {
const [actionDialog, setActionDialog] = useState<{ action: 'genehmigt' | 'abgelehnt' } | null>(null); const [actionDialog, setActionDialog] = useState<{ action: 'genehmigt' | 'abgelehnt' } | null>(null);
const [adminNotizen, setAdminNotizen] = useState(''); const [adminNotizen, setAdminNotizen] = useState('');
const [statusChangeValue, setStatusChangeValue] = useState(''); const [statusChangeValue, setStatusChangeValue] = useState('');
const [linkDialog, setLinkDialog] = useState(false);
const [selectedBestellung, setSelectedBestellung] = useState<Bestellung | null>(null);
// Permissions // Permissions
const showAdminActions = hasPermission('ausruestungsanfrage:approve'); const showAdminActions = hasPermission('ausruestungsanfrage:approve');
@@ -80,12 +76,6 @@ export default function AusruestungsanfrageDetail() {
enabled: editing, enabled: editing,
}); });
const { data: bestellungen = [] } = useQuery({
queryKey: ['bestellungen'],
queryFn: () => bestellungApi.getOrders(),
enabled: linkDialog,
});
// ── Mutations ── // ── Mutations ──
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: (data: { bezeichnung?: string; notizen?: string; items?: AusruestungAnfrageFormItem[] }) => mutationFn: (data: { bezeichnung?: string; notizen?: string; items?: AusruestungAnfrageFormItem[] }) =>
@@ -111,17 +101,6 @@ export default function AusruestungsanfrageDetail() {
onError: () => showError('Fehler beim Aktualisieren'), 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({ const geliefertMut = useMutation({
mutationFn: ({ positionId, geliefert }: { positionId: number; geliefert: boolean }) => mutationFn: ({ positionId, geliefert }: { positionId: number; geliefert: boolean }) =>
ausruestungsanfrageApi.updatePositionGeliefert(positionId, geliefert), ausruestungsanfrageApi.updatePositionGeliefert(positionId, geliefert),
@@ -414,11 +393,12 @@ export default function AusruestungsanfrageDetail() {
)} )}
{showAdminActions && anfrage && anfrage.status === 'genehmigt' && canLink && ( {showAdminActions && anfrage && anfrage.status === 'genehmigt' && canLink && (
<Button <Button
variant="outlined" variant="contained"
startIcon={<LinkIcon />} color="primary"
onClick={() => setLinkDialog(true)} startIcon={<ShoppingCartIcon />}
onClick={() => navigate(`/ausruestungsanfragen/${requestId}/bestellen`)}
> >
Verknüpfen Bestellungen erstellen
</Button> </Button>
)} )}
{canEdit && !editing && ( {canEdit && !editing && (
@@ -459,29 +439,6 @@ export default function AusruestungsanfrageDetail() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Link to order sub-dialog */}
<Dialog open={linkDialog} onClose={() => { setLinkDialog(false); setSelectedBestellung(null); }} maxWidth="sm" fullWidth>
<DialogTitle>Mit Bestellung verknüpfen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<Autocomplete
options={bestellungen}
getOptionLabel={(o) => `#${o.id} ${o.bezeichnung}`}
value={selectedBestellung}
onChange={(_, v) => setSelectedBestellung(v)}
renderInput={params => <TextField {...params} label="Bestellung auswählen" />}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setLinkDialog(false); setSelectedBestellung(null); }}>Abbrechen</Button>
<Button
variant="contained"
disabled={!selectedBestellung || linkMut.isPending}
onClick={() => { if (selectedBestellung) linkMut.mutate(selectedBestellung.id); }}
>
Verknüpfen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -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<Record<number, VendorAssignment | null>>({});
const [orderNames, setOrderNames] = useState<Record<number, string>>({});
// 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<number | null>(null);
// ── Derived: vendor groups ──
const vendorGroups: VendorGroup[] = useMemo(() => {
const map = new Map<number, VendorGroup>();
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 (
<DashboardLayout>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
<CircularProgress />
</Box>
</DashboardLayout>
);
}
if (isError || !anfrage) {
return (
<DashboardLayout>
<Alert severity="error" sx={{ m: 3 }}>Anfrage konnte nicht geladen werden.</Alert>
</DashboardLayout>
);
}
// ── Render ──
return (
<DashboardLayout>
<Box sx={{ maxWidth: 960, mx: 'auto', px: 2, py: 3 }}>
{/* ── Header ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
<IconButton onClick={() => navigate(`/ausruestungsanfrage/${requestId}`)} size="small">
<ArrowBack />
</IconButton>
<Box sx={{ flex: 1 }}>
<Typography variant="h5" fontWeight={600}>
Bestellungen erstellen
</Typography>
<Typography variant="body2" color="text.secondary">
{anfrage.bezeichnung || `Anfrage ${formatOrderId(anfrage)}`}
{' · '}
<Chip
label={AUSRUESTUNG_STATUS_LABELS[anfrage.status]}
size="small"
sx={{ height: 18, fontSize: 11 }}
/>
</Typography>
</Box>
<Chip
label={`${assignedCount} / ${positionen.length} zugewiesen`}
color={allAssigned ? 'success' : 'default'}
variant={allAssigned ? 'filled' : 'outlined'}
/>
</Box>
{/* ── Progress bar ── */}
<LinearProgress
variant="determinate"
value={positionen.length > 0 ? (assignedCount / positionen.length) * 100 : 0}
sx={{ mb: 3, borderRadius: 2, height: 6 }}
color={allAssigned ? 'success' : 'primary'}
/>
{/* ── Items table ── */}
<Paper sx={{ mb: 3 }}>
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2">Lieferanten zuweisen</Typography>
</Box>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Artikel</TableCell>
<TableCell align="right" sx={{ width: 80 }}>Menge</TableCell>
<TableCell sx={{ width: 320 }}>Lieferant</TableCell>
</TableRow>
</TableHead>
<TableBody>
{positionen.map(pos => {
const assignment = assignments[pos.id];
const selectedVendor = assignment
? vendors.find(v => v.id === assignment.lieferantId) ?? null
: null;
return (
<TableRow key={pos.id} sx={assignment ? {} : { bgcolor: 'warning.light', opacity: 0.85 }}>
<TableCell>
<Typography variant="body2" fontWeight={500}>{pos.bezeichnung}</Typography>
{pos.notizen && (
<Typography variant="caption" color="text.secondary">{pos.notizen}</Typography>
)}
</TableCell>
<TableCell align="right">
<Typography variant="body2">{pos.menge} {pos.einheit ?? 'Stk'}</Typography>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Autocomplete<Lieferant>
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 => (
<TextField
{...params}
placeholder="Lieferant wählen…"
size="small"
/>
)}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
{canManageVendors && (
<IconButton
size="small"
title="Neuen Lieferanten anlegen"
onClick={() => {
setNewVendorTargetPosId(pos.id);
setNewVendorDialog(true);
}}
>
<AddIcon fontSize="small" />
</IconButton>
)}
</Box>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Paper>
{/* ── Preview section ── */}
{vendorGroups.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
Vorschau {vendorGroups.length} Bestellung{vendorGroups.length !== 1 ? 'en' : ''} werden erstellt
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{vendorGroups.map(g => (
<Paper
key={g.lieferantId}
variant="outlined"
sx={{ p: 2, minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}
>
<Typography variant="subtitle2" color="primary" sx={{ mb: 0.5 }}>
{g.lieferantName}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1.5, display: 'block' }}>
{g.positionen.length} Artikel
</Typography>
<Box sx={{ mb: 1.5 }}>
{g.positionen.map(p => (
<Typography key={p.id} variant="body2" sx={{ py: 0.25 }}>
· {p.bezeichnung} ×{p.menge}
</Typography>
))}
</Box>
<TextField
size="small"
fullWidth
label="Bezeichnung der Bestellung"
value={orderNames[g.lieferantId] ?? ''}
onChange={e =>
setOrderNames(prev => ({ ...prev, [g.lieferantId]: e.target.value }))
}
/>
</Paper>
))}
</Box>
</Box>
)}
{/* ── Bottom action bar ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, justifyContent: 'flex-end' }}>
{!allAssigned && positionen.length > 0 && (
<Alert severity="warning" sx={{ flex: 1, py: 0.5 }}>
{positionen.length - assignedCount} Artikel {positionen.length - assignedCount === 1 ? 'hat' : 'haben'} noch keinen Lieferanten.
</Alert>
)}
<Button
variant="contained"
size="large"
startIcon={<ShoppingCartIcon />}
disabled={!allAssigned || createOrdersMut.isPending}
onClick={handleSubmit}
>
{createOrdersMut.isPending ? 'Erstelle…' : `${vendorGroups.length || ''} Bestellung${vendorGroups.length !== 1 ? 'en' : ''} erstellen`}
</Button>
</Box>
</Box>
{/* ── New vendor dialog ── */}
<Dialog
open={newVendorDialog}
onClose={() => { setNewVendorDialog(false); setNewVendorTargetPosId(null); }}
maxWidth="sm"
fullWidth
>
<DialogTitle>Neuen Lieferanten anlegen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
<TextField
label="Name *"
value={newVendorName}
onChange={e => setNewVendorName(e.target.value)}
autoFocus
fullWidth
/>
<TextField
label="Kontaktperson"
value={newVendorKontakt}
onChange={e => setNewVendorKontakt(e.target.value)}
fullWidth
/>
<TextField
label="E-Mail"
value={newVendorEmail}
onChange={e => setNewVendorEmail(e.target.value)}
fullWidth
type="email"
/>
<TextField
label="Telefon"
value={newVendorTelefon}
onChange={e => setNewVendorTelefon(e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setNewVendorDialog(false); setNewVendorTargetPosId(null); }}>
Abbrechen
</Button>
<Button
variant="contained"
disabled={!newVendorName.trim() || createVendorMut.isPending}
onClick={handleCreateVendor}
>
Anlegen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}

View File

@@ -9,6 +9,8 @@ import type {
AusruestungEigenschaft, AusruestungEigenschaft,
AusruestungAnfrage, AusruestungAnfrage,
AusruestungWidgetOverview, AusruestungWidgetOverview,
CreateOrdersRequest,
CreateOrdersResponse,
} from '../types/ausruestungsanfrage.types'; } from '../types/ausruestungsanfrage.types';
export const ausruestungsanfrageApi = { export const ausruestungsanfrageApi = {
@@ -127,6 +129,12 @@ export const ausruestungsanfrageApi = {
await api.delete(`/api/ausruestungsanfragen/requests/${anfrageId}/link/${bestellungId}`); await api.delete(`/api/ausruestungsanfragen/requests/${anfrageId}/link/${bestellungId}`);
}, },
// ── Create orders from request ──
createOrders: async (anfrageId: number, payload: CreateOrdersRequest): Promise<CreateOrdersResponse> => {
const r = await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/create-orders`, payload);
return r.data.data;
},
// ── Overview ── // ── Overview ──
getOverview: async (): Promise<AusruestungOverview> => { getOverview: async (): Promise<AusruestungOverview> => {
const r = await api.get('/api/ausruestungsanfragen/overview'); const r = await api.get('/api/ausruestungsanfragen/overview');

View File

@@ -101,6 +101,7 @@ export interface AusruestungAnfragePosition {
artikel_id?: number; artikel_id?: number;
bezeichnung: string; bezeichnung: string;
menge: number; menge: number;
einheit?: string;
notizen?: string; notizen?: string;
geliefert: boolean; geliefert: boolean;
erstellt_am: string; erstellt_am: string;
@@ -145,3 +146,33 @@ export interface AusruestungWidgetOverview {
approved_count: number; approved_count: number;
unhandled_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[];
}