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

@@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/ausruestungsanfragen/:id/bestellen"
element={
<ProtectedRoute>
<AusruestungsanfrageZuBestellung />
</ProtectedRoute>
}
/>
<Route
path="/issues"
element={

View File

@@ -8,7 +8,7 @@ import {
} from '@mui/material';
import {
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';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
@@ -17,13 +17,11 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext';
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
import { bestellungApi } from '../services/bestellung';
import { AUSRUESTUNG_STATUS_LABELS, AUSRUESTUNG_STATUS_COLORS } from '../types/ausruestungsanfrage.types';
import type {
AusruestungAnfrage, AusruestungAnfrageDetailResponse,
AusruestungAnfrageFormItem, AusruestungAnfrageStatus,
} from '../types/ausruestungsanfrage.types';
import type { Bestellung } from '../types/bestellung.types';
// ── Helpers ──
@@ -58,8 +56,6 @@ export default function AusruestungsanfrageDetail() {
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<Bestellung | null>(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 && (
<Button
variant="outlined"
startIcon={<LinkIcon />}
onClick={() => setLinkDialog(true)}
variant="contained"
color="primary"
startIcon={<ShoppingCartIcon />}
onClick={() => navigate(`/ausruestungsanfragen/${requestId}/bestellen`)}
>
Verknüpfen
Bestellungen erstellen
</Button>
)}
{canEdit && !editing && (
@@ -459,29 +439,6 @@ export default function AusruestungsanfrageDetail() {
</DialogActions>
</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>
);
}

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,
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<CreateOrdersResponse> => {
const r = await api.post(`/api/ausruestungsanfragen/requests/${anfrageId}/create-orders`, payload);
return r.data.data;
},
// ── Overview ──
getOverview: async (): Promise<AusruestungOverview> => {
const r = await api.get('/api/ausruestungsanfragen/overview');

View File

@@ -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[];
}