Files
dashboard/frontend/src/pages/BestellungDetail.tsx
Matthias Hochmeister 507111e8e8 update
2026-03-26 12:12:18 +01:00

1158 lines
50 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, useRef } from 'react';
import {
Box,
Typography,
Paper,
Button,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
Card,
CardContent,
LinearProgress,
Checkbox,
Menu,
MenuItem,
Accordion,
AccordionSummary,
AccordionDetails,
Autocomplete,
Tooltip,
} from '@mui/material';
import {
ArrowBack,
Add as AddIcon,
Delete as DeleteIcon,
Edit as EditIcon,
AttachFile,
Alarm,
History,
Upload as UploadIcon,
ArrowDropDown,
ExpandMore as ExpandMoreIcon,
Save as SaveIcon,
PictureAsPdf as PdfIcon,
} from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import GermanDateField from '../components/shared/GermanDateField';
import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext';
import { bestellungApi } from '../services/bestellung';
import { configApi } from '../services/config';
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types';
// ── Helpers ──
const DIENSTGRAD_KURZ: Record<string, string> = {
'Feuerwehranwärter': 'FA', 'Jugendfeuerwehrmann': 'JFM', 'Probefeuerwehrmann': 'PFM',
'Feuerwehrmann': 'FM', 'Feuerwehrfrau': 'FF',
'Oberfeuerwehrmann': 'OFM', 'Oberfeuerwehrfrau': 'OFF',
'Hauptfeuerwehrmann': 'HFM', 'Hauptfeuerwehrfrau': 'HFF',
'Löschmeister': 'LM', 'Oberlöschmeister': 'OLM', 'Hauptlöschmeister': 'HLM',
'Brandmeister': 'BM', 'Oberbrandmeister': 'OBM', 'Hauptbrandmeister': 'HBM',
'Brandinspektor': 'BI', 'Oberbrandinspektor': 'OBI', 'Brandoberinspektor': 'BOI',
'Brandamtmann': 'BAM',
'Verwaltungsmeister': 'VM', 'Oberverwaltungsmeister': 'OVM',
'Hauptverwaltungsmeister': 'HVM', 'Verwalter': 'V',
'Ehren-Feuerwehrmann': 'E-FM', 'Ehren-Feuerwehrfrau': 'E-FF',
'Ehren-Oberfeuerwehrmann': 'E-OFM', 'Ehren-Oberfeuerwehrfrau': 'E-OFF',
'Ehren-Hauptfeuerwehrmann': 'E-HFM', 'Ehren-Hauptfeuerwehrfrau': 'E-HFF',
'Ehren-Löschmeister': 'E-LM', 'Ehren-Oberlöschmeister': 'E-OLM', 'Ehren-Hauptlöschmeister': 'E-HLM',
'Ehren-Brandmeister': 'E-BM', 'Ehren-Oberbrandmeister': 'E-OBM', 'Ehren-Hauptbrandmeister': 'E-HBM',
'Ehren-Brandinspektor': 'E-BI', 'Ehren-Oberbrandinspektor': 'E-OBI', 'Ehren-Brandoberinspektor': 'E-BOI',
'Ehren-Brandamtmann': 'E-BAM',
'Ehren-Verwaltungsmeister': 'E-VM', 'Ehren-Oberverwaltungsmeister': 'E-OVM',
'Ehren-Hauptverwaltungsmeister': 'E-HVM', 'Ehren-Verwalter': 'E-V',
};
const kurzDienstgrad = (d?: string) => (d ? (DIENSTGRAD_KURZ[d] ?? d) : undefined);
const formatCurrency = (value?: number) =>
value != null ? new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(value) : '';
const formatDate = (iso?: string) =>
iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '';
const formatDateTime = (iso?: string) =>
iso ? new Date(iso).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
const formatFileSize = (bytes?: number) => {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// Valid status transitions (must match backend VALID_STATUS_TRANSITIONS)
const STATUS_TRANSITIONS: Record<BestellungStatus, BestellungStatus[]> = {
entwurf: ['erstellt', 'bestellt'],
erstellt: ['bestellt'],
bestellt: ['teillieferung', 'vollstaendig'],
teillieferung: ['vollstaendig'],
vollstaendig: ['abgeschlossen'],
abgeschlossen: [],
};
// Empty line item form
const emptyItem: BestellpositionFormData = { bezeichnung: '', artikelnummer: '', menge: 1, einheit: 'Stk', einzelpreis: undefined };
// ══════════════════════════════════════════════════════════════════════════════
// Component
// ══════════════════════════════════════════════════════════════════════════════
export default function BestellungDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showSuccess, showError } = useNotification();
const { hasPermission } = usePermissionContext();
const fileInputRef = useRef<HTMLInputElement>(null);
const orderId = Number(id);
// ── State ──
const [newItem, setNewItem] = useState<BestellpositionFormData>({ ...emptyItem });
const [statusConfirmTarget, setStatusConfirmTarget] = useState<BestellungStatus | null>(null);
const [statusForce, setStatusForce] = useState(false);
const [statusMenuAnchor, setStatusMenuAnchor] = useState<null | HTMLElement>(null);
const [overrideMenuAnchor, setOverrideMenuAnchor] = useState<null | HTMLElement>(null);
const [deleteItemTarget, setDeleteItemTarget] = useState<number | null>(null);
const [deleteFileTarget, setDeleteFileTarget] = useState<number | null>(null);
const [editMode, setEditMode] = useState(false);
const [editOrderData, setEditOrderData] = useState<{
bezeichnung: string;
lieferant_id?: number;
besteller_id?: string;
notizen: string;
steuersatz: number;
}>({ bezeichnung: '', notizen: '', steuersatz: 20 });
const [editItemsData, setEditItemsData] = useState<Record<number, {
bezeichnung: string;
artikelnummer: string;
menge: number;
einheit: string;
einzelpreis?: number;
}>>({});
const [isSavingAll, setIsSavingAll] = useState(false);
const [reminderForm, setReminderForm] = useState<ErinnerungFormData>({ faellig_am: '', nachricht: '' });
const [reminderFormOpen, setReminderFormOpen] = useState(false);
const [deleteReminderTarget, setDeleteReminderTarget] = useState<number | null>(null);
// ── Query ──
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['bestellung', orderId],
queryFn: () => bestellungApi.getOrder(orderId),
enabled: !!orderId,
});
const bestellung = data?.bestellung;
const positionen = data?.positionen ?? [];
const dateien = data?.dateien ?? [];
const erinnerungen = data?.erinnerungen ?? [];
const historie = data?.historie ?? [];
const { data: vendors = [] } = useQuery({
queryKey: ['lieferanten'],
queryFn: bestellungApi.getVendors,
enabled: editMode,
});
const { data: orderUsers = [] } = useQuery({
queryKey: ['bestellungen', 'order-users'],
queryFn: bestellungApi.getOrderUsers,
enabled: editMode,
});
const canCreate = hasPermission('bestellungen:create');
const canDelete = hasPermission('bestellungen:delete');
const canManageReminders = hasPermission('bestellungen:manage_reminders');
const canManageOrders = hasPermission('bestellungen:manage_orders');
const canExport = hasPermission('bestellungen:export');
const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
// All statuses except current, for force override
const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'erstellt', 'bestellt', 'teillieferung', 'vollstaendig', 'abgeschlossen'];
const overrideStatuses = bestellung ? ALL_STATUSES.filter((s) => s !== bestellung.status) : [];
// ── Mutations ──
const updateStatus = useMutation({
mutationFn: ({ status, force }: { status: string; force?: boolean }) => bestellungApi.updateStatus(orderId, status, force),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
showSuccess('Status aktualisiert');
setStatusConfirmTarget(null);
setStatusForce(false);
},
onError: () => showError('Fehler beim Aktualisieren des Status'),
});
const addItem = useMutation({
mutationFn: (data: BestellpositionFormData) => bestellungApi.addLineItem(orderId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
setNewItem({ ...emptyItem });
showSuccess('Position hinzugefügt');
},
onError: () => showError('Fehler beim Hinzufügen der Position'),
});
const deleteItem = useMutation({
mutationFn: (itemId: number) => bestellungApi.deleteLineItem(itemId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
setDeleteItemTarget(null);
showSuccess('Position gelöscht');
},
onError: () => showError('Fehler beim Löschen der Position'),
});
const updateReceived = useMutation({
mutationFn: ({ itemId, menge }: { itemId: number; menge: number }) =>
bestellungApi.updateReceivedQty(itemId, menge),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const uploadFile = useMutation({
mutationFn: (file: File) => bestellungApi.uploadFile(orderId, file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
showSuccess('Datei hochgeladen');
},
onError: () => showError('Fehler beim Hochladen der Datei'),
});
const deleteFile = useMutation({
mutationFn: (fileId: number) => bestellungApi.deleteFile(fileId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
setDeleteFileTarget(null);
showSuccess('Datei gelöscht');
},
onError: () => showError('Fehler beim Löschen der Datei'),
});
const addReminder = useMutation({
mutationFn: (data: ErinnerungFormData) => bestellungApi.addReminder(orderId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
setReminderForm({ faellig_am: '', nachricht: '' });
setReminderFormOpen(false);
showSuccess('Erinnerung erstellt');
},
onError: () => showError('Fehler beim Erstellen der Erinnerung'),
});
const markReminderDone = useMutation({
mutationFn: (reminderId: number) => bestellungApi.markReminderDone(reminderId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
},
onError: () => showError('Fehler beim Aktualisieren'),
});
const deleteReminder = useMutation({
mutationFn: (reminderId: number) => bestellungApi.deleteReminder(reminderId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
setDeleteReminderTarget(null);
showSuccess('Erinnerung gelöscht');
},
onError: () => showError('Fehler beim Löschen'),
});
// ── Handlers ──
function enterEditMode() {
if (!bestellung) return;
setEditOrderData({
bezeichnung: bestellung.bezeichnung,
lieferant_id: bestellung.lieferant_id,
besteller_id: bestellung.besteller_id || '',
notizen: bestellung.notizen || '',
steuersatz: parseFloat(String(bestellung.steuersatz ?? 20)),
});
setEditItemsData(
Object.fromEntries(positionen.map(p => [p.id, {
bezeichnung: p.bezeichnung,
artikelnummer: p.artikelnummer || '',
menge: parseFloat(String(p.menge)) || 1,
einheit: p.einheit,
einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined,
}]))
);
setEditMode(true);
}
function cancelEditMode() {
setEditMode(false);
setEditItemsData({});
}
async function handleSaveAll() {
if (!bestellung) return;
setIsSavingAll(true);
try {
await bestellungApi.updateOrder(orderId, {
bezeichnung: editOrderData.bezeichnung,
lieferant_id: editOrderData.lieferant_id,
besteller_id: editOrderData.besteller_id || undefined,
notizen: editOrderData.notizen,
steuersatz: editOrderData.steuersatz,
});
for (const item of positionen) {
const itemEdit = editItemsData[item.id];
if (itemEdit) {
await bestellungApi.updateLineItem(item.id, itemEdit);
}
}
await queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] });
showSuccess('Änderungen gespeichert');
setEditMode(false);
setEditItemsData({});
} catch {
showError('Fehler beim Speichern');
} finally {
setIsSavingAll(false);
}
}
function handleAddItem() {
if (!newItem.bezeichnung.trim()) return;
addItem.mutate(newItem);
}
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) uploadFile.mutate(file);
e.target.value = '';
}
// Compute totals (NUMERIC columns come as strings from PostgreSQL — parse to float)
const steuersatz = editMode ? editOrderData.steuersatz : parseFloat(String(bestellung?.steuersatz ?? 20));
const totalCost = editMode
? positionen.reduce((sum, p) => {
const d = editItemsData[p.id];
const einzelpreis = d?.einzelpreis != null ? d.einzelpreis : (parseFloat(String(p.einzelpreis)) || 0);
const menge = d?.menge != null ? d.menge : (parseFloat(String(p.menge)) || 0);
return sum + einzelpreis * menge;
}, 0)
: positionen.reduce((sum, p) => sum + (parseFloat(String(p.einzelpreis)) || 0) * (parseFloat(String(p.menge)) || 0), 0);
const taxAmount = totalCost * (steuersatz / 100);
const totalBrutto = totalCost + taxAmount;
const totalReceived = positionen.length > 0
? positionen.reduce((sum, p) => sum + (parseFloat(String(p.erhalten_menge)) || 0), 0)
: 0;
const totalOrdered = positionen.reduce((sum, p) => sum + (parseFloat(String(p.menge)) || 0), 0);
const receivedPercent = totalOrdered > 0 ? Math.round((totalReceived / totalOrdered) * 100) : 0;
// ── Loading / Error ──
// ── PDF Export ──
async function generateBestellungDetailPdf() {
if (!bestellung) return;
const { jsPDF } = await import('jspdf');
const autoTable = (await import('jspdf-autotable')).default;
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
let settings;
try { settings = await configApi.getPdfSettings(); } catch { settings = { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' }; }
const kennung = bestellung.laufende_nummer
? `${new Date(bestellung.erstellt_am).getFullYear()}/${bestellung.laufende_nummer}`
: String(bestellung.id);
const title = `Bestellung #${kennung}`;
let curY = await addPdfHeader(doc, settings, 210);
// ── Document title ──
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text(title, 10, curY);
curY += 10;
// ── Helper: two-column label/value row ──
const row = (label: string, value: string, col2X = 70) => {
doc.setFontSize(9);
doc.setFont('helvetica', 'bold');
doc.text(label + ':', 10, curY);
doc.setFont('helvetica', 'normal');
doc.text(value, col2X, curY);
curY += 5;
};
// ── Vendor block ──
if (bestellung.lieferant_name) {
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Lieferant', 10, curY);
curY += 5;
row('Name', bestellung.lieferant_name);
if (bestellung.lieferant_kontakt_name) row('Ansprechpartner', bestellung.lieferant_kontakt_name);
if (bestellung.lieferant_adresse) row('Adresse', bestellung.lieferant_adresse);
if (bestellung.lieferant_email) row('E-Mail', bestellung.lieferant_email);
if (bestellung.lieferant_telefon) row('Telefon', bestellung.lieferant_telefon);
curY += 3;
}
// ── Kontaktperson (Besteller) block ──
if (bestellung.besteller_name) {
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Kontaktperson', 10, curY);
curY += 5;
const nameWithRank = bestellung.besteller_dienstgrad
? `${kurzDienstgrad(bestellung.besteller_dienstgrad)} ${bestellung.besteller_name}`
: bestellung.besteller_name;
row('Name', nameWithRank);
if (bestellung.besteller_email) row('E-Mail', bestellung.besteller_email);
curY += 3;
}
// ── Order info block ──
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Bestellinformationen', 10, curY);
curY += 5;
row('Bezeichnung', bestellung.bezeichnung);
row('Status', BESTELLUNG_STATUS_LABELS[bestellung.status]);
row('Erstellt am', formatDate(bestellung.erstellt_am));
if (bestellung.bestellt_am) row('Bestelldatum', formatDate(bestellung.bestellt_am));
curY += 5;
// ── Line items table ──
const steuersatz = (parseFloat(String(bestellung.steuersatz)) || 20) / 100;
const hasPrices = positionen.some((p) => p.einzelpreis != null);
const totalNetto = positionen.reduce((sum, p) => {
const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : 0;
const m = parseFloat(String(p.menge)) || 0;
return sum + ep * m;
}, 0);
const totalBrutto = totalNetto * (1 + steuersatz);
if (hasPrices) {
const rows = positionen.map((p) => {
const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined;
const menge = parseFloat(String(p.menge)) || 0;
const gesamt = ep != null ? ep * menge : undefined;
return [
p.bezeichnung,
p.artikelnummer || '',
`${menge} ${p.einheit}`,
ep != null ? formatCurrency(ep) : '',
gesamt != null ? formatCurrency(gesamt) : '',
];
});
autoTable(doc, {
head: [['Bezeichnung', 'Art.-Nr.', 'Menge', 'Einzelpreis', 'Gesamt']],
body: rows,
startY: curY,
theme: 'grid',
headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' },
alternateRowStyles: { fillColor: [245, 245, 245] },
margin: { left: 10, right: 10 },
styles: { fontSize: 9, cellPadding: 2 },
columnStyles: {
0: { cellWidth: 60 },
1: { cellWidth: 28 },
2: { cellWidth: 25, halign: 'right' },
3: { cellWidth: 30, halign: 'right' },
4: { cellWidth: 30, halign: 'right' },
},
foot: [
['', '', '', 'Netto:', formatCurrency(totalNetto)],
['', '', '', `Brutto (${bestellung.steuersatz ?? 20}% USt.):`, formatCurrency(totalBrutto)],
],
footStyles: { fillColor: [255, 255, 255], textColor: [0, 0, 0], fontStyle: 'bold' },
didDrawPage: addPdfFooter(doc, settings),
});
} else {
const rows = positionen.map((p) => {
const menge = parseFloat(String(p.menge)) || 0;
return [p.bezeichnung, p.artikelnummer || '', `${menge} ${p.einheit}`];
});
autoTable(doc, {
head: [['Bezeichnung', 'Art.-Nr.', 'Menge']],
body: rows,
startY: curY,
theme: 'grid',
headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' },
alternateRowStyles: { fillColor: [245, 245, 245] },
margin: { left: 10, right: 10 },
styles: { fontSize: 9, cellPadding: 2 },
columnStyles: {
0: { cellWidth: 100 },
1: { cellWidth: 40 },
2: { cellWidth: 33, halign: 'right' },
},
didDrawPage: addPdfFooter(doc, settings),
});
}
// ── Signature section ──
const signY = (doc as any).lastAutoTable?.finalY ?? curY;
const sigStartY = signY + 15;
// Ensure there's enough space; add a page if needed
if (sigStartY + 25 > doc.internal.pageSize.height - 20) {
doc.addPage();
curY = 20;
} else {
curY = sigStartY;
}
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
// Ort/Datum line (left)
doc.line(10, curY, 90, curY);
// Signature line (right)
doc.line(120, curY, 200, curY);
curY += 4;
doc.text('Ort, Datum', 10, curY);
const sigName = bestellung.besteller_dienstgrad
? `${kurzDienstgrad(bestellung.besteller_dienstgrad)} ${bestellung.besteller_name || ''}`
: (bestellung.besteller_name || '');
doc.text(sigName, 120, curY);
doc.save(`bestellung_${kennung.replace('/', '-')}.pdf`);
}
if (isLoading) {
return (
<DashboardLayout>
<Box sx={{ p: 4, textAlign: 'center' }}><Typography>Laden...</Typography></Box>
</DashboardLayout>
);
}
if (isError || !bestellung) {
const is404 = (error as any)?.response?.status === 404 || !bestellung;
return (
<DashboardLayout>
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography color="error">
{is404 ? 'Bestellung nicht gefunden.' : 'Fehler beim Laden der Bestellung.'}
</Typography>
{!is404 && (
<Button sx={{ mt: 2 }} variant="outlined" onClick={() => refetch()}>Erneut versuchen</Button>
)}
<Button sx={{ mt: 2, ml: !is404 ? 1 : 0 }} onClick={() => navigate('/bestellungen')}>Zurück</Button>
</Box>
</DashboardLayout>
);
}
return (
<DashboardLayout>
{/* ── Header ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<IconButton onClick={() => navigate('/bestellungen')}>
<ArrowBack />
</IconButton>
<Typography variant="h4" sx={{ flexGrow: 1 }}>{bestellung.bezeichnung}</Typography>
{canExport && !editMode && (
<Tooltip title="PDF Export">
<IconButton onClick={generateBestellungDetailPdf} color="primary">
<PdfIcon />
</IconButton>
</Tooltip>
)}
{canCreate && !editMode && (
<Button startIcon={<EditIcon />} onClick={enterEditMode}>Bearbeiten</Button>
)}
{editMode && (
<>
<Button variant="contained" startIcon={<SaveIcon />} onClick={handleSaveAll} disabled={isSavingAll}>
Speichern
</Button>
<Button onClick={cancelEditMode} disabled={isSavingAll}>Abbrechen</Button>
</>
)}
<Chip
label={BESTELLUNG_STATUS_LABELS[bestellung.status]}
color={BESTELLUNG_STATUS_COLORS[bestellung.status]}
/>
</Box>
{/* ── Info Cards ── */}
{editMode ? (
<Paper sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField label="Bezeichnung" required fullWidth size="small"
value={editOrderData.bezeichnung}
onChange={(e) => setEditOrderData(d => ({ ...d, bezeichnung: e.target.value }))} />
</Grid>
<Grid item xs={12} sm={6}>
<Autocomplete
options={vendors}
getOptionLabel={(o) => o.name}
value={vendors.find(v => v.id === editOrderData.lieferant_id) ?? null}
onChange={(_, v) => setEditOrderData(d => ({ ...d, lieferant_id: v?.id }))}
renderInput={(params) => <TextField {...params} label="Lieferant" size="small" />}
/>
</Grid>
<Grid item xs={12} sm={6}>
<Autocomplete
options={orderUsers}
getOptionLabel={(o) => o.name}
value={orderUsers.find(u => u.id === editOrderData.besteller_id) ?? null}
onChange={(_, v) => setEditOrderData(d => ({ ...d, besteller_id: v?.id || '' }))}
renderInput={(params) => <TextField {...params} label="Besteller" size="small" />}
/>
</Grid>
<Grid item xs={12}>
<TextField label="Notizen" fullWidth multiline rows={3} size="small"
value={editOrderData.notizen}
onChange={(e) => setEditOrderData(d => ({ ...d, notizen: e.target.value }))} />
</Grid>
</Grid>
</Paper>
) : (
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Lieferant</Typography>
<Typography>{bestellung.lieferant_name || ''}</Typography>
</CardContent></Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Besteller</Typography>
<Typography>{bestellung.besteller_name || ''}</Typography>
</CardContent></Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Erstellt am</Typography>
<Typography>{formatDate(bestellung.erstellt_am)}</Typography>
</CardContent></Card>
</Grid>
</Grid>
)}
{/* ── Status Action ── */}
{canManageOrders && (
<Box sx={{ mb: 3, display: 'flex', gap: 1, alignItems: 'center' }}>
{validTransitions.length === 1 ? (
<Button variant="contained" onClick={() => { setStatusForce(false); setStatusConfirmTarget(validTransitions[0]); }}>
Status ändern: {BESTELLUNG_STATUS_LABELS[validTransitions[0]]}
</Button>
) : validTransitions.length > 1 ? (
<>
<Button
variant="contained"
endIcon={<ArrowDropDown />}
onClick={(e) => setStatusMenuAnchor(e.currentTarget)}
>
Status ändern
</Button>
<Menu
anchorEl={statusMenuAnchor}
open={Boolean(statusMenuAnchor)}
onClose={() => setStatusMenuAnchor(null)}
>
{validTransitions.map((s) => (
<MenuItem
key={s}
onClick={() => {
setStatusMenuAnchor(null);
setStatusForce(false);
setStatusConfirmTarget(s);
}}
>
{BESTELLUNG_STATUS_LABELS[s]}
</MenuItem>
))}
</Menu>
</>
) : null}
{/* Manual override menu */}
{overrideStatuses.length > 0 && canManageOrders && (
<>
<Button
variant="outlined"
size="small"
endIcon={<ArrowDropDown />}
onClick={(e) => setOverrideMenuAnchor(e.currentTarget)}
>
Status manuell setzen
</Button>
<Menu
anchorEl={overrideMenuAnchor}
open={Boolean(overrideMenuAnchor)}
onClose={() => setOverrideMenuAnchor(null)}
>
{overrideStatuses.map((s) => (
<MenuItem
key={s}
onClick={() => {
setOverrideMenuAnchor(null);
setStatusForce(true);
setStatusConfirmTarget(s);
}}
>
{BESTELLUNG_STATUS_LABELS[s]}
</MenuItem>
))}
</Menu>
</>
)}
</Box>
)}
{/* ── Delivery Progress ── */}
{positionen.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
Lieferfortschritt: {totalReceived} / {totalOrdered} ({receivedPercent}%)
</Typography>
<LinearProgress
variant="determinate"
value={receivedPercent}
color={receivedPercent >= 100 ? 'success' : 'primary'}
sx={{ height: 8, borderRadius: 4 }}
/>
</Box>
)}
{/* ══════════════════════════════════════════════════════════════════════ */}
{/* Positionen */}
{/* ══════════════════════════════════════════════════════════════════════ */}
<Paper sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>Positionen</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Bezeichnung</TableCell>
<TableCell>Artikelnr.</TableCell>
<TableCell align="right">Menge</TableCell>
<TableCell>Einheit</TableCell>
<TableCell align="right">Einzelpreis</TableCell>
<TableCell align="right">Gesamt</TableCell>
<TableCell align="right">Erhalten</TableCell>
{(editMode && (canCreate || canDelete)) && <TableCell align="right">Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{positionen.map((p) =>
editMode ? (
<TableRow key={p.id}>
<TableCell>
<TextField size="small" value={editItemsData[p.id]?.bezeichnung ?? p.bezeichnung}
onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), bezeichnung: e.target.value } }))} />
</TableCell>
<TableCell>
<TextField size="small" value={editItemsData[p.id]?.artikelnummer ?? p.artikelnummer ?? ''}
onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), artikelnummer: e.target.value } }))} />
</TableCell>
<TableCell>
<TextField size="small" type="number" sx={{ width: 80 }}
value={editItemsData[p.id]?.menge ?? p.menge}
onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), menge: Number(e.target.value) } }))} />
</TableCell>
<TableCell>
<TextField size="small" sx={{ width: 80 }}
value={editItemsData[p.id]?.einheit ?? p.einheit}
onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), einheit: e.target.value } }))} />
</TableCell>
<TableCell>
<TextField size="small" type="number" sx={{ width: 100 }}
value={editItemsData[p.id]?.einzelpreis ?? p.einzelpreis ?? ''}
onChange={(e) => setEditItemsData(d => ({ ...d, [p.id]: { ...(d[p.id] || { bezeichnung: p.bezeichnung, artikelnummer: p.artikelnummer || '', menge: parseFloat(String(p.menge)) || 1, einheit: p.einheit, einzelpreis: p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined }), einzelpreis: e.target.value ? Number(e.target.value) : undefined } }))} />
</TableCell>
<TableCell align="right">
{formatCurrency((editItemsData[p.id]?.einzelpreis ?? parseFloat(String(p.einzelpreis ?? 0))) * (editItemsData[p.id]?.menge ?? parseFloat(String(p.menge))))}
</TableCell>
<TableCell align="right">
{canManageOrders ? (
<TextField size="small" type="number" sx={{ width: 70 }} value={p.erhalten_menge}
inputProps={{ min: 0, max: p.menge }}
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} />
) : p.erhalten_menge}
</TableCell>
{(canCreate || canDelete) && (
<TableCell align="right">
{canDelete && (
<IconButton size="small" color="error" onClick={() => setDeleteItemTarget(p.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</TableCell>
)}
</TableRow>
) : (
<TableRow key={p.id}>
<TableCell>{p.bezeichnung}</TableCell>
<TableCell>{p.artikelnummer || ''}</TableCell>
<TableCell align="right">{p.menge}</TableCell>
<TableCell>{p.einheit}</TableCell>
<TableCell align="right">{formatCurrency(p.einzelpreis)}</TableCell>
<TableCell align="right">{formatCurrency((p.einzelpreis ?? 0) * p.menge)}</TableCell>
<TableCell align="right">
{canManageOrders ? (
<TextField
size="small"
type="number"
sx={{ width: 70 }}
value={p.erhalten_menge}
inputProps={{ min: 0, max: p.menge }}
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })}
/>
) : (
p.erhalten_menge
)}
</TableCell>
</TableRow>
),
)}
{/* ── Add Item Row ── */}
{editMode && canCreate && (
<TableRow>
<TableCell>
<TextField size="small" placeholder="Bezeichnung" value={newItem.bezeichnung} onChange={(e) => setNewItem((f) => ({ ...f, bezeichnung: e.target.value }))} />
</TableCell>
<TableCell>
<TextField size="small" placeholder="Artikelnr." value={newItem.artikelnummer || ''} onChange={(e) => setNewItem((f) => ({ ...f, artikelnummer: e.target.value }))} />
</TableCell>
<TableCell>
<TextField size="small" type="number" sx={{ width: 80 }} value={newItem.menge} onChange={(e) => setNewItem((f) => ({ ...f, menge: Number(e.target.value) }))} />
</TableCell>
<TableCell>
<TextField size="small" sx={{ width: 80 }} value={newItem.einheit || 'Stk'} onChange={(e) => setNewItem((f) => ({ ...f, einheit: e.target.value }))} />
</TableCell>
<TableCell>
<TextField size="small" type="number" sx={{ width: 100 }} placeholder="Preis" value={newItem.einzelpreis ?? ''} onChange={(e) => setNewItem((f) => ({ ...f, einzelpreis: e.target.value ? Number(e.target.value) : undefined }))} />
</TableCell>
<TableCell align="right">{formatCurrency((newItem.einzelpreis ?? 0) * newItem.menge)}</TableCell>
<TableCell />
<TableCell align="right">
<IconButton size="small" color="primary" onClick={handleAddItem} disabled={!newItem.bezeichnung.trim() || addItem.isPending}>
<AddIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
)}
{/* ── Totals Rows (Netto / MwSt / Brutto) ── */}
{positionen.length > 0 && (
<>
<TableRow>
<TableCell colSpan={5} align="right">Netto</TableCell>
<TableCell align="right">{formatCurrency(totalCost)}</TableCell>
<TableCell colSpan={(editMode && (canCreate || canDelete)) ? 2 : 1} />
</TableRow>
<TableRow>
<TableCell colSpan={5} align="right">
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
MwSt.
{editMode && canCreate ? (
<TextField
size="small"
type="number"
sx={{ width: 70 }}
value={editOrderData.steuersatz}
inputProps={{ min: 0, max: 100, step: 0.5 }}
onChange={(e) => {
const val = parseFloat(e.target.value);
if (!isNaN(val) && val >= 0 && val <= 100) {
setEditOrderData(d => ({ ...d, steuersatz: val }));
}
}}
/>
) : (
<span>{steuersatz}</span>
)}
%
</Box>
</TableCell>
<TableCell align="right">{formatCurrency(taxAmount)}</TableCell>
<TableCell colSpan={(editMode && (canCreate || canDelete)) ? 2 : 1} />
</TableRow>
<TableRow>
<TableCell colSpan={5} align="right"><strong>Brutto</strong></TableCell>
<TableCell align="right"><strong>{formatCurrency(totalBrutto)}</strong></TableCell>
<TableCell colSpan={(editMode && (canCreate || canDelete)) ? 2 : 1} />
</TableRow>
</>
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
{/* ══════════════════════════════════════════════════════════════════════ */}
{/* Dateien */}
{/* ══════════════════════════════════════════════════════════════════════ */}
<Paper sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<AttachFile sx={{ mr: 1 }} />
<Typography variant="h6" sx={{ flexGrow: 1 }}>Dateien</Typography>
{canCreate && (
<>
<input ref={fileInputRef} type="file" hidden onChange={handleFileSelect} />
<Button size="small" startIcon={<UploadIcon />} onClick={() => fileInputRef.current?.click()} disabled={uploadFile.isPending}>
Hochladen
</Button>
</>
)}
</Box>
{dateien.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine Dateien vorhanden</Typography>
) : (
<Grid container spacing={2}>
{dateien.map((d) => (
<Grid item xs={12} sm={6} md={4} key={d.id}>
<Card variant="outlined">
{d.thumbnail_pfad && (
<Box
component="img"
src={`/api/bestellungen/files/${d.id}/thumbnail`}
alt={d.dateiname}
sx={{ width: '100%', height: 120, objectFit: 'cover' }}
/>
)}
<CardContent sx={{ py: 1, '&:last-child': { pb: 1 } }}>
<Typography variant="body2" noWrap>{d.dateiname}</Typography>
<Typography variant="caption" color="text.secondary">
{formatFileSize(d.dateigroesse)} &middot; {formatDate(d.hochgeladen_am)}
</Typography>
{canDelete && (
<Box sx={{ mt: 0.5 }}>
<IconButton size="small" color="error" onClick={() => setDeleteFileTarget(d.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</Paper>
{/* ══════════════════════════════════════════════════════════════════════ */}
{/* Erinnerungen */}
{/* ══════════════════════════════════════════════════════════════════════ */}
<Paper sx={{ p: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Alarm sx={{ mr: 1 }} />
<Typography variant="h6" sx={{ flexGrow: 1 }}>Erinnerungen</Typography>
{canManageReminders && (
<Button size="small" startIcon={<AddIcon />} onClick={() => setReminderFormOpen(true)}>
Neue Erinnerung
</Button>
)}
</Box>
{erinnerungen.length === 0 && !reminderFormOpen ? (
<Typography variant="body2" color="text.secondary">Keine Erinnerungen</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{erinnerungen.map((r) => (
<Box key={r.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, opacity: r.erledigt ? 0.5 : 1 }}>
<Checkbox
checked={r.erledigt}
disabled={r.erledigt || !canManageReminders}
onChange={() => markReminderDone.mutate(r.id)}
size="small"
/>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="body2" sx={{ textDecoration: r.erledigt ? 'line-through' : 'none' }}>
{r.nachricht || 'Erinnerung'}
</Typography>
<Typography variant="caption" color="text.secondary">
Fällig: {formatDate(r.faellig_am)}
</Typography>
</Box>
{canManageReminders && (
<IconButton size="small" color="error" onClick={() => setDeleteReminderTarget(r.id)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
))}
</Box>
)}
{/* Inline Add Reminder Form */}
{reminderFormOpen && (
<Box sx={{ display: 'flex', gap: 1, mt: 2, alignItems: 'flex-end' }}>
<GermanDateField
size="small"
label="Fällig am"
value={reminderForm.faellig_am}
onChange={(iso) => setReminderForm((f) => ({ ...f, faellig_am: iso }))}
/>
<TextField
size="small"
label="Nachricht"
sx={{ flexGrow: 1 }}
value={reminderForm.nachricht || ''}
onChange={(e) => setReminderForm((f) => ({ ...f, nachricht: e.target.value }))}
/>
<Button
size="small"
variant="contained"
disabled={!reminderForm.faellig_am || addReminder.isPending}
onClick={() => addReminder.mutate(reminderForm)}
>
Speichern
</Button>
<Button size="small" onClick={() => { setReminderFormOpen(false); setReminderForm({ faellig_am: '', nachricht: '' }); }}>
Abbrechen
</Button>
</Box>
)}
</Paper>
{/* ── Notizen ── */}
{!editMode && bestellung.notizen && (
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Notizen</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{bestellung.notizen}</Typography>
</Paper>
)}
{/* ══════════════════════════════════════════════════════════════════════ */}
{/* Historie */}
{/* ══════════════════════════════════════════════════════════════════════ */}
<Accordion defaultExpanded={false} sx={{ mb: 3 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<History />
<Typography variant="h6">Historie ({historie.length} Einträge)</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
{historie.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine Einträge</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{historie.map((h) => (
<Box key={h.id} sx={{ display: 'flex', gap: 1 }}>
<Box sx={{ width: 6, minHeight: '100%', borderRadius: 3, bgcolor: 'divider', flexShrink: 0 }} />
<Box>
<Typography variant="body2">{h.aktion}</Typography>
<Typography variant="caption" color="text.secondary">
{h.erstellt_von_name || 'System'} &middot; {formatDateTime(h.erstellt_am)}
</Typography>
{h.details && (
<Typography variant="caption" display="block" color="text.secondary">
{Object.entries(h.details).map(([k, v]) => `${k}: ${v}`).join(', ')}
</Typography>
)}
</Box>
</Box>
))}
</Box>
)}
</AccordionDetails>
</Accordion>
{/* ══════════════════════════════════════════════════════════════════════ */}
{/* Dialogs */}
{/* ══════════════════════════════════════════════════════════════════════ */}
{/* Status Confirmation */}
<Dialog open={statusConfirmTarget != null} onClose={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>
<DialogTitle>Status ändern{statusForce ? ' (manuell)' : ''}</DialogTitle>
<DialogContent>
<Typography>
Status von <strong>{BESTELLUNG_STATUS_LABELS[bestellung.status]}</strong> auf{' '}
<strong>{statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''}</strong> ändern?
</Typography>
{statusForce && (
<Typography variant="body2" color="warning.main" sx={{ mt: 1 }}>
Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs.
</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>Abbrechen</Button>
<Button variant="contained" onClick={() => statusConfirmTarget && updateStatus.mutate({ status: statusConfirmTarget, force: statusForce || undefined })} disabled={updateStatus.isPending}>
Bestätigen
</Button>
</DialogActions>
</Dialog>
{/* Delete Item Confirmation */}
<Dialog open={deleteItemTarget != null} onClose={() => setDeleteItemTarget(null)}>
<DialogTitle>Position löschen</DialogTitle>
<DialogContent>
<Typography>Soll diese Position wirklich gelöscht werden?</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteItemTarget(null)}>Abbrechen</Button>
<Button color="error" variant="contained" onClick={() => deleteItemTarget != null && deleteItem.mutate(deleteItemTarget)} disabled={deleteItem.isPending}>
Löschen
</Button>
</DialogActions>
</Dialog>
{/* Delete File Confirmation */}
<Dialog open={deleteFileTarget != null} onClose={() => setDeleteFileTarget(null)}>
<DialogTitle>Datei löschen</DialogTitle>
<DialogContent>
<Typography>Soll diese Datei wirklich gelöscht werden?</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteFileTarget(null)}>Abbrechen</Button>
<Button color="error" variant="contained" onClick={() => deleteFileTarget != null && deleteFile.mutate(deleteFileTarget)} disabled={deleteFile.isPending}>
Löschen
</Button>
</DialogActions>
</Dialog>
{/* Delete Reminder Confirmation */}
<Dialog open={deleteReminderTarget != null} onClose={() => setDeleteReminderTarget(null)}>
<DialogTitle>Erinnerung löschen</DialogTitle>
<DialogContent>
<Typography>Soll diese Erinnerung wirklich gelöscht werden?</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteReminderTarget(null)}>Abbrechen</Button>
<Button color="error" variant="contained" onClick={() => deleteReminderTarget != null && deleteReminder.mutate(deleteReminderTarget)} disabled={deleteReminder.isPending}>
Löschen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout>
);
}