Files
dashboard/frontend/src/pages/AusruestungsanfrageZuBestellung.tsx

461 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { data: catalogItems = [] } = useQuery({
queryKey: ['ausruestungsanfrage', 'items-for-edit'],
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
staleTime: 5 * 60 * 1000,
});
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);
// ── Pre-populate vendor assignments from catalog items' preferred vendor ──
useEffect(() => {
if (positionen.length === 0 || catalogItems.length === 0 || vendors.length === 0) return;
setAssignments(prev => {
const next = { ...prev };
let changed = false;
positionen.forEach(pos => {
// Don't overwrite existing manual assignments
if (next[pos.id] != null) return;
if (!pos.artikel_id) return;
const artikel = catalogItems.find(a => a.id === pos.artikel_id);
if (!artikel?.bevorzugter_lieferant_id) return;
const vendor = vendors.find(v => v.id === artikel.bevorzugter_lieferant_id);
if (!vendor) return;
next[pos.id] = { lieferantId: vendor.id, lieferantName: vendor.name };
changed = true;
});
return changed ? next : prev;
});
}, [positionen, catalogItems, vendors]);
// ── 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 skippedCount = positionen.length - assignedCount;
// ── 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 canSubmit = vendorGroups.length > 0 && !createOrdersMut.isPending;
const handleSubmit = () => {
if (!canSubmit) 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={assignedCount > 0 ? 'primary' : 'default'}
variant="outlined"
/>
</Box>
{/* ── Progress bar ── */}
<LinearProgress
variant="determinate"
value={positionen.length > 0 ? (assignedCount / positionen.length) * 100 : 0}
sx={{ mb: 3, borderRadius: 2, height: 6 }}
color="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}>
<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' }}>
{skippedCount > 0 && assignedCount > 0 && (
<Alert severity="info" sx={{ flex: 1, py: 0.5 }}>
{skippedCount} Artikel {skippedCount === 1 ? 'ist' : 'sind'} auf Lager und {skippedCount === 1 ? 'wird' : 'werden'} nicht bestellt.
</Alert>
)}
{assignedCount === 0 && positionen.length > 0 && (
<Alert severity="info" sx={{ flex: 1, py: 0.5 }}>
Mindestens einem Artikel einen Lieferanten zuweisen, um Bestellungen zu erstellen.
</Alert>
)}
<Button
variant="contained"
size="large"
startIcon={<ShoppingCartIcon />}
disabled={!canSubmit}
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>
);
}