add linking between internal and external orders
This commit is contained in:
@@ -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)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
426
frontend/src/pages/AusruestungsanfrageZuBestellung.tsx
Normal file
426
frontend/src/pages/AusruestungsanfrageZuBestellung.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user