import React, { useState, useRef, useEffect, useMemo } from 'react'; import { Alert, Box, Typography, Paper, Button, Chip, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, TextField, IconButton, Grid, Card, CardContent, LinearProgress, Checkbox, Menu, MenuItem, Accordion, AccordionSummary, AccordionDetails, Autocomplete, Tooltip, Dialog, DialogTitle, DialogContent, DialogActions, } from '@mui/material'; import { 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, keepPreviousData } 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 { ausruestungsanfrageApi } from '../services/ausruestungsanfrage'; 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'; import type { AusruestungArtikel, AusruestungEigenschaft, AusruestungKategorie } from '../types/ausruestungsanfrage.types'; import { ConfirmDialog, StatusChip, PageHeader } from '../components/templates'; // ── Helpers ── const DIENSTGRAD_KURZ: Record = { '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 = { entwurf: ['wartet_auf_genehmigung'], wartet_auf_genehmigung: ['bereit_zur_bestellung', 'entwurf'], bereit_zur_bestellung: ['bestellt'], bestellt: ['teillieferung', 'lieferung_pruefen'], teillieferung: ['lieferung_pruefen'], lieferung_pruefen: ['abgeschlossen'], abgeschlossen: [], }; // Empty line item form const emptyItem: BestellpositionFormData = { bezeichnung: '', artikelnummer: '', menge: 1, einheit: 'Stk', einzelpreis: undefined }; // ══════════════════════════════════════════════════════════════════════════════ // KatalogAddDialog — browse catalog and add items to order // ══════════════════════════════════════════════════════════════════════════════ interface KatalogAddDialogProps { open: boolean; onClose: () => void; onAddItem: (data: BestellpositionFormData) => void; isPending: boolean; } function KatalogAddDialog({ open, onClose, onAddItem, isPending }: KatalogAddDialogProps) { const [search, setSearch] = useState(''); const [filterKategorie, setFilterKategorie] = useState(''); const [expandedId, setExpandedId] = useState(null); const [itemConfig, setItemConfig] = useState({ menge: 1, einheit: 'Stk', einzelpreis: '', artikelnummer: '' }); const [eigenschaftValues, setEigenschaftValues] = useState>({}); const [sortField, setSortField] = useState<'bezeichnung' | 'kategorie'>('bezeichnung'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); // Reset state when dialog closes useEffect(() => { if (!open) { setSearch(''); setFilterKategorie(''); setExpandedId(null); setEigenschaftValues({}); setSortField('bezeichnung'); setSortDir('asc'); } }, [open]); const { data: kategorien = [] } = useQuery({ queryKey: ['ausruestungsanfrage', 'kategorien'], queryFn: () => ausruestungsanfrageApi.getKategorien(), enabled: open, }); const { data: items = [], isFetching } = useQuery({ queryKey: ['ausruestungsanfrage', 'items', filterKategorie, search], queryFn: () => ausruestungsanfrageApi.getItems({ ...(filterKategorie ? { kategorie_id: filterKategorie as number } : {}), ...(search.trim() ? { search: search.trim() } : {}), aktiv: true, }), placeholderData: keepPreviousData, enabled: open, }); const { data: eigenschaften = [] } = useQuery({ queryKey: ['ausruestungsanfrage', 'eigenschaften', expandedId], queryFn: () => ausruestungsanfrageApi.getArtikelEigenschaften(expandedId!), enabled: open && !!expandedId, }); const kategorieOptions = useMemo(() => { const map = new Map(kategorien.map((k: AusruestungKategorie) => [k.id, k])); return kategorien.map((k: AusruestungKategorie) => { if (k.parent_id) { const parent = map.get(k.parent_id); return { id: k.id, name: parent ? `${parent.name} > ${k.name}` : k.name }; } return { id: k.id, name: k.name }; }); }, [kategorien]); const sortedItems = useMemo(() => { const sorted = [...items]; sorted.sort((a, b) => { let aVal: string, bVal: string; if (sortField === 'bezeichnung') { aVal = a.bezeichnung.toLowerCase(); bVal = b.bezeichnung.toLowerCase(); } else { aVal = (kategorieOptions.find(k => k.id === a.kategorie_id)?.name || a.kategorie_name || '').toLowerCase(); bVal = (kategorieOptions.find(k => k.id === b.kategorie_id)?.name || b.kategorie_name || '').toLowerCase(); } if (aVal < bVal) return sortDir === 'asc' ? -1 : 1; if (aVal > bVal) return sortDir === 'asc' ? 1 : -1; return 0; }); return sorted; }, [items, sortField, sortDir, kategorieOptions]); const handleToggleExpand = (item: AusruestungArtikel) => { if (expandedId === item.id) { setExpandedId(null); setEigenschaftValues({}); } else { setExpandedId(item.id); setEigenschaftValues({}); setItemConfig({ menge: 1, einheit: 'Stk', einzelpreis: item.geschaetzter_preis != null ? String(item.geschaetzter_preis) : '', artikelnummer: '', }); } }; const handleSort = (field: 'bezeichnung' | 'kategorie') => { if (sortField === field) { setSortDir(prev => prev === 'asc' ? 'desc' : 'asc'); } else { setSortField(field); setSortDir('asc'); } }; const handleAdd = (item: AusruestungArtikel) => { const charSpecs = Object.entries(eigenschaftValues) .filter(([, v]) => v.trim()) .map(([eid, v]) => { const e = eigenschaften.find(e => e.id === Number(eid)); return e ? `${e.name}: ${v}` : v; }); onAddItem({ bezeichnung: item.bezeichnung, artikel_id: item.id, artikelnummer: itemConfig.artikelnummer || undefined, menge: Number(itemConfig.menge) || 1, einheit: itemConfig.einheit || 'Stk', einzelpreis: itemConfig.einzelpreis ? Number(itemConfig.einzelpreis) : undefined, spezifikationen: charSpecs.length > 0 ? charSpecs : undefined, }); setExpandedId(null); setEigenschaftValues({}); }; return ( Artikel aus Katalog hinzufügen {/* Filters */} setSearch(e.target.value)} placeholder="Artikel suchen..." sx={{ minWidth: 200 }} /> setFilterKategorie(e.target.value as number | '')} sx={{ minWidth: 180 }} > Alle Kategorien {kategorieOptions.map(k => ( {k.name} ))} {isFetching && Lade...} {/* Table */} {items.length === 0 && !isFetching ? ( Keine Artikel gefunden. ) : ( handleSort('bezeichnung')} > Bezeichnung handleSort('kategorie')} > Kategorie Beschreibung Richtpreis {sortedItems.map(item => ( handleToggleExpand(item)} > {item.bezeichnung} {(item.eigenschaften_count ?? 0) > 0 && ( )} {kategorieOptions.find(k => k.id === item.kategorie_id)?.name || item.kategorie_name || '–'} {item.beschreibung || '–'} {item.geschaetzter_preis != null ? new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(item.geschaetzter_preis) : '–'} {/* Expanded configuration row */} {expandedId === item.id && ( {item.bezeichnung} — Konfigurieren setItemConfig(c => ({ ...c, menge: Math.max(1, Number(e.target.value)) }))} inputProps={{ min: 1 }} sx={{ width: 90 }} /> setItemConfig(c => ({ ...c, einheit: e.target.value }))} sx={{ width: 90 }} /> setItemConfig(c => ({ ...c, einzelpreis: e.target.value }))} placeholder="EUR" sx={{ width: 120 }} /> setItemConfig(c => ({ ...c, artikelnummer: e.target.value }))} sx={{ width: 160 }} /> {/* Eigenschaften */} {eigenschaften.length > 0 && ( {eigenschaften.map(e => e.typ === 'options' && e.optionen?.length ? ( setEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))} sx={{ minWidth: 140 }} > {e.optionen.map(opt => ( {opt} ))} ) : ( setEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))} sx={{ minWidth: 160 }} /> ) )} )} )} ))}
)}
); } // ══════════════════════════════════════════════════════════════════════════════ // 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(null); const orderId = Number(id); // ── State ── const [newItem, setNewItem] = useState({ ...emptyItem }); const [statusConfirmTarget, setStatusConfirmTarget] = useState(null); const [statusForce, setStatusForce] = useState(false); const [overrideMenuAnchor, setOverrideMenuAnchor] = useState(null); const [deleteItemTarget, setDeleteItemTarget] = useState(null); const [deleteFileTarget, setDeleteFileTarget] = useState(null); const [editMode, setEditMode] = useState(false); const [editOrderData, setEditOrderData] = useState<{ bezeichnung: string; lieferant_id?: number; besteller_id?: string; mitglied_id?: string; notizen: string; steuersatz: number; }>({ bezeichnung: '', notizen: '', steuersatz: 20 }); const [editItemsData, setEditItemsData] = useState>({}); const [isSavingAll, setIsSavingAll] = useState(false); const [reminderForm, setReminderForm] = useState({ faellig_am: '', nachricht: '' }); const [reminderFormOpen, setReminderFormOpen] = useState(false); const [deleteReminderTarget, setDeleteReminderTarget] = useState(null); // ── Catalog picker state ── const [selectedKatalogItem, setSelectedKatalogItem] = useState(null); const [katalogEigenschaften, setKatalogEigenschaften] = useState([]); const [eigenschaftValues, setEigenschaftValues] = useState>({}); const [katalogDialogOpen, setKatalogDialogOpen] = useState(false); // ── 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 { data: katalogItems = [] } = useQuery({ queryKey: ['katalogItems'], queryFn: () => bestellungApi.getKatalogItems(), enabled: editMode, staleTime: 5 * 60 * 1000, }); const canCreate = hasPermission('bestellungen:create'); const canDelete = hasPermission('bestellungen:delete'); const canManageReminders = hasPermission('bestellungen:manage_reminders'); const canManageOrders = hasPermission('bestellungen:manage_orders'); const canApprove = hasPermission('bestellungen:approve'); const canExport = hasPermission('bestellungen:export'); const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : []; const allCostsEntered = positionen.length === 0 || positionen.every(p => p.einzelpreis != null && Number(p.einzelpreis) > 0); const allItemsReceived = positionen.length === 0 || positionen.every(p => Number(p.erhalten_menge) >= Number(p.menge)); // All statuses except current, for force override const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'wartet_auf_genehmigung', 'bereit_zur_bestellung', 'bestellt', 'teillieferung', 'lieferung_pruefen', '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] }); queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); 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] }); queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); setNewItem({ ...emptyItem }); setSelectedKatalogItem(null); setKatalogEigenschaften([]); setEigenschaftValues({}); 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] }); queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); 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] }); queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); }, onError: () => showError('Fehler beim Aktualisieren'), }); const uploadFile = useMutation({ mutationFn: (file: File) => bestellungApi.uploadFile(orderId, file), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); 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] }); queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); 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] }); queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); 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] }); queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); }, onError: () => showError('Fehler beim Aktualisieren'), }); const deleteReminder = useMutation({ mutationFn: (reminderId: number) => bestellungApi.deleteReminder(reminderId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); 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 || '', mitglied_id: bestellung.mitglied_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, spezifikationen: p.spezifikationen || [], }])) ); 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, mitglied_id: editOrderData.mitglied_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, spezifikationen: itemEdit.spezifikationen, }); } } await queryClient.invalidateQueries({ queryKey: ['bestellung', orderId] }); queryClient.invalidateQueries({ queryKey: ['bestellungen'] }); showSuccess('Änderungen gespeichert'); setEditMode(false); setEditItemsData({}); } catch { showError('Fehler beim Speichern'); } finally { setIsSavingAll(false); } } function handleAddItem() { if (!newItem.bezeichnung.trim()) return; // Merge characteristic values into spezifikationen const charSpecs = Object.entries(eigenschaftValues) .filter(([, v]) => v.trim()) .map(([eid, v]) => { const eig = katalogEigenschaften.find(e => e.id === Number(eid)); return eig ? `${eig.name}: ${v}` : v; }); const mergedSpecs = [...(newItem.spezifikationen || []), ...charSpecs]; addItem.mutate({ ...newItem, spezifikationen: mergedSpecs.length > 0 ? mergedSpecs : undefined, }); setSelectedKatalogItem(null); setKatalogEigenschaften([]); setEigenschaftValues({}); } function handleFileSelect(e: React.ChangeEvent) { 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; try { 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); if (bestellung.besteller_telefon) row('Telefon', bestellung.besteller_telefon); curY += 3; } // ── Für Mitglied block ── if (bestellung.mitglied_name) { doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.text('Für Mitglied', 10, curY); curY += 5; const mitgliedNameWithRank = bestellung.mitglied_dienstgrad ? `${kurzDienstgrad(bestellung.mitglied_dienstgrad)} ${bestellung.mitglied_name}` : bestellung.mitglied_name; row('Name', mitgliedNameWithRank); curY += 3; } // ── Order info block ── doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.text('Bestellinformationen', 10, curY); curY += 5; row('Bezeichnung', bestellung.bezeichnung); row('Erstellt am', formatDate(bestellung.erstellt_am)); curY += 5; // ── Place and date ── doc.setFontSize(10); doc.setFont('helvetica', 'normal'); const pageWidth = doc.internal.pageSize.width; const dateStr = bestellung.bestellt_am ? new Date(bestellung.bestellt_am).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); doc.text(`St. Valentin, am ${dateStr}`, pageWidth - 10, curY, { align: 'right' }); curY += 8; // ── Line items table ── const steuersatz = (parseFloat(String(bestellung.steuersatz)) || 20) / 100; const hasPrices = positionen.some((p) => p.einzelpreis != null && p.einzelpreis > 0); 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: (string | number)[][] = []; for (const p of positionen) { const ep = p.einzelpreis != null ? parseFloat(String(p.einzelpreis)) : undefined; const menge = parseFloat(String(p.menge)) || 0; const gesamt = ep != null ? ep * menge : undefined; rows.push([ p.bezeichnung, p.artikelnummer || '', `${menge} ${p.einheit}`, ep != null ? formatCurrency(ep) : '–', gesamt != null ? formatCurrency(gesamt) : '–', ]); for (const spec of p.spezifikationen || []) { rows.push([` • ${spec}`, '', '', '', '']); } } autoTable(doc, { head: [['Bezeichnung', 'Art.-Nr.', 'Menge', 'Einzelpreis', 'Gesamt']], body: rows, startY: curY, theme: 'plain', headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' }, margin: { left: 10, right: 10 }, styles: { fontSize: 9, cellPadding: 2 }, columnStyles: { 2: { halign: 'right' }, 3: { halign: 'right' }, 4: { halign: 'right' }, }, didParseCell: (data: any) => { if (data.section === 'body') { const cell0 = String(data.row.raw[0] ?? ''); if (cell0.startsWith(' •')) { data.cell.styles.fontSize = 8; data.cell.styles.textColor = [100, 100, 100]; data.cell.styles.fillColor = [255, 255, 255]; } } }, didDrawCell: (data: any) => { if (data.section === 'body' && data.column.index === 0) { const cell0 = String(data.row.raw?.[0] ?? ''); if (!cell0.startsWith(' •') && data.row.index > 0) { data.doc.setDrawColor(200, 200, 200); data.doc.setLineWidth(0.2); data.doc.line( data.settings.margin.left, data.cell.y, data.doc.internal.pageSize.width - data.settings.margin.right, data.cell.y, ); } // Line at bottom of last row if (data.row.index === rows.length - 1) { data.doc.setDrawColor(200, 200, 200); data.doc.setLineWidth(0.2); data.doc.line( data.settings.margin.left, data.cell.y + data.cell.height, data.doc.internal.pageSize.width - data.settings.margin.right, data.cell.y + data.cell.height, ); } } }, 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: string[][] = []; for (const p of positionen) { const menge = parseFloat(String(p.menge)) || 0; rows.push([p.bezeichnung, p.artikelnummer || '', `${menge} ${p.einheit}`]); for (const spec of p.spezifikationen || []) { rows.push([` • ${spec}`, '', '']); } } autoTable(doc, { head: [['Bezeichnung', 'Art.-Nr.', 'Menge']], body: rows, startY: curY, theme: 'plain', headStyles: { fillColor: [66, 66, 66], textColor: 255, fontStyle: 'bold' }, margin: { left: 10, right: 10 }, styles: { fontSize: 9, cellPadding: 2 }, columnStyles: { 2: { halign: 'right' }, }, didParseCell: (data: any) => { if (data.section === 'body') { const cell0 = String(data.row.raw[0] ?? ''); if (cell0.startsWith(' •')) { data.cell.styles.fontSize = 8; data.cell.styles.textColor = [100, 100, 100]; data.cell.styles.fillColor = [255, 255, 255]; } } }, didDrawCell: (data: any) => { if (data.section === 'body' && data.column.index === 0) { const cell0 = String(data.row.raw?.[0] ?? ''); if (!cell0.startsWith(' •') && data.row.index > 0) { data.doc.setDrawColor(200, 200, 200); data.doc.setLineWidth(0.2); data.doc.line( data.settings.margin.left, data.cell.y, data.doc.internal.pageSize.width - data.settings.margin.right, data.cell.y, ); } // Line at bottom of last row if (data.row.index === rows.length - 1) { data.doc.setDrawColor(200, 200, 200); data.doc.setLineWidth(0.2); data.doc.line( data.settings.margin.left, data.cell.y + data.cell.height, data.doc.internal.pageSize.width - data.settings.margin.right, data.cell.y + data.cell.height, ); } } }, 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); // Signature line (right) doc.line(120, curY, 200, curY); curY += 4; 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`); } catch (err) { console.error('PDF generation failed:', err); showError('PDF konnte nicht erstellt werden'); } } if (isLoading) { return ( Laden... ); } if (isError || !bestellung) { const is404 = (error as any)?.response?.status === 404 || !bestellung; return ( {is404 ? 'Bestellung nicht gefunden.' : 'Fehler beim Laden der Bestellung.'} {!is404 && ( )} ); } return ( {/* ── Header ── */} {canExport && !editMode && ( )} {canCreate && !editMode && ( )} {editMode && ( <> )} } /> {/* ── Info Cards ── */} {editMode ? ( setEditOrderData(d => ({ ...d, bezeichnung: e.target.value }))} /> o.name} value={vendors.find(v => v.id === editOrderData.lieferant_id) ?? null} onChange={(_, v) => setEditOrderData(d => ({ ...d, lieferant_id: v?.id }))} renderInput={(params) => } /> o.name} value={orderUsers.find(u => u.id === editOrderData.besteller_id) ?? null} onChange={(_, v) => setEditOrderData(d => ({ ...d, besteller_id: v?.id || '' }))} renderInput={(params) => } /> o.name} value={orderUsers.find(u => u.id === editOrderData.mitglied_id) ?? null} onChange={(_, v) => setEditOrderData(d => ({ ...d, mitglied_id: v?.id || '' }))} renderInput={(params) => } /> setEditOrderData(d => ({ ...d, notizen: e.target.value }))} /> ) : ( Lieferant {bestellung.lieferant_name || '–'} Besteller {bestellung.besteller_name || '–'} {bestellung.mitglied_name && ( Für Mitglied {bestellung.mitglied_dienstgrad ? `${kurzDienstgrad(bestellung.mitglied_dienstgrad)} ${bestellung.mitglied_name}` : bestellung.mitglied_name} )} Erstellt am {formatDate(bestellung.erstellt_am)} )} {/* ── Status Action ── */} {(canManageOrders || canCreate || canApprove) && ( {(['bestellt', 'teillieferung', 'lieferung_pruefen'] as BestellungStatus[]).includes(bestellung.status) && !allCostsEntered && ( Nicht alle Positionen haben Kosten. Bitte Einzelpreise eintragen, bevor die Bestellung abgeschlossen wird. )} {(['bestellt', 'teillieferung', 'lieferung_pruefen'] as BestellungStatus[]).includes(bestellung.status) && !allItemsReceived && ( Nicht alle Positionen wurden vollständig empfangen. Bitte Eingangsmenge prüfen, bevor die Bestellung abgeschlossen wird. )} {validTransitions .filter((s) => { // Approve/reject transitions from wartet_auf_genehmigung require canApprove if (bestellung.status === 'wartet_auf_genehmigung') { return canApprove; } // Transition to bereit_zur_bestellung from other states also requires canApprove if (s === 'bereit_zur_bestellung') return canApprove; // All other transitions require canCreate or canManageOrders return canCreate || canManageOrders; }) .map((s) => { const isReject = bestellung.status === 'wartet_auf_genehmigung' && s === 'entwurf'; const label = isReject ? 'Ablehnen' : BESTELLUNG_STATUS_LABELS[s]; const color = isReject ? 'error' : 'primary'; const isAbgeschlossen = s === 'abgeschlossen'; return ( ); })} {/* Manual override menu */} {overrideStatuses.length > 0 && canManageOrders && ( <> setOverrideMenuAnchor(null)} > {overrideStatuses.map((s) => ( { setOverrideMenuAnchor(null); setStatusForce(true); setStatusConfirmTarget(s); }} > {BESTELLUNG_STATUS_LABELS[s]} ))} )} )} {/* ── Delivery Progress ── */} {positionen.length > 0 && ( Lieferfortschritt: {totalReceived} / {totalOrdered} ({receivedPercent}%) = 100 ? 'success' : 'primary'} sx={{ height: 8, borderRadius: 4 }} /> )} {/* ══════════════════════════════════════════════════════════════════════ */} {/* Positionen */} {/* ══════════════════════════════════════════════════════════════════════ */} Positionen {editMode && canCreate && ( )} Bezeichnung Artikelnr. Menge Einheit Einzelpreis Gesamt Erhalten {(editMode && (canCreate || canDelete)) && Aktionen} {positionen.map((p) => editMode ? ( 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, spezifikationen: p.spezifikationen || [] }), bezeichnung: e.target.value } }))} /> 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, spezifikationen: p.spezifikationen || [] }), artikelnummer: e.target.value } }))} /> 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, spezifikationen: p.spezifikationen || [] }), menge: Number(e.target.value) } }))} /> 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, spezifikationen: p.spezifikationen || [] }), einheit: e.target.value } }))} /> 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, spezifikationen: p.spezifikationen || [] }), einzelpreis: e.target.value ? Number(e.target.value) : undefined } }))} /> {formatCurrency((editItemsData[p.id]?.einzelpreis ?? parseFloat(String(p.einzelpreis ?? 0))) * (editItemsData[p.id]?.menge ?? parseFloat(String(p.menge))))} {canManageOrders ? ( updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} /> ) : p.erhalten_menge} {(canCreate || canDelete) && ( {canDelete && ( setDeleteItemTarget(p.id)}> )} )} {/* Specifications editor row */} {(editItemsData[p.id]?.spezifikationen || []).map((spec, specIdx) => ( setEditItemsData(d => { const cur = d[p.id]?.spezifikationen ? [...d[p.id].spezifikationen] : []; cur[specIdx] = e.target.value; return { ...d, [p.id]: { ...d[p.id], spezifikationen: cur } }; })} /> setEditItemsData(d => { const cur = d[p.id]?.spezifikationen ? [...d[p.id].spezifikationen] : []; cur.splice(specIdx, 1); return { ...d, [p.id]: { ...d[p.id], spezifikationen: cur } }; })}> ))} ) : ( {p.bezeichnung} {p.artikelnummer || '–'} {p.menge} {p.einheit} {formatCurrency(p.einzelpreis)} {formatCurrency((p.einzelpreis ?? 0) * p.menge)} {canManageOrders ? ( updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} /> ) : ( p.erhalten_menge )} {p.spezifikationen && p.spezifikationen.length > 0 && ( {p.spezifikationen.map((spec, i) => ( • {spec} ))} )} ), )} {/* ── Add Item Row ── */} {editMode && canCreate && ( <> freeSolo size="small" options={katalogItems} getOptionLabel={(o) => typeof o === 'string' ? o : o.bezeichnung} value={selectedKatalogItem || newItem.bezeichnung || ''} onChange={async (_, v) => { if (typeof v === 'string') { setNewItem((f) => ({ ...f, bezeichnung: v, artikel_id: undefined })); setSelectedKatalogItem(null); setKatalogEigenschaften([]); setEigenschaftValues({}); } else if (v) { setNewItem((f) => ({ ...f, bezeichnung: v.bezeichnung, artikel_id: v.id })); setSelectedKatalogItem(v); // Load eigenschaften try { const eigs = await ausruestungsanfrageApi.getArtikelEigenschaften(v.id); setKatalogEigenschaften(eigs || []); } catch { setKatalogEigenschaften([]); } setEigenschaftValues({}); } }} onInputChange={(_, val, reason) => { if (reason === 'input') { setNewItem((f) => ({ ...f, bezeichnung: val, artikel_id: undefined })); setSelectedKatalogItem(null); setKatalogEigenschaften([]); setEigenschaftValues({}); } }} renderInput={(params) => } renderOption={(props, option) => (
  • {option.bezeichnung} {option.kategorie_name && {option.kategorie_name}}
  • )} isOptionEqualToValue={(o, v) => o.id === (typeof v === 'object' ? v.id : undefined)} sx={{ minWidth: 200 }} />
    setNewItem((f) => ({ ...f, artikelnummer: e.target.value }))} /> setNewItem((f) => ({ ...f, menge: Number(e.target.value) }))} /> setNewItem((f) => ({ ...f, einheit: e.target.value }))} /> setNewItem((f) => ({ ...f, einzelpreis: e.target.value ? Number(e.target.value) : undefined }))} /> {formatCurrency((newItem.einzelpreis ?? 0) * newItem.menge)}
    {/* Characteristic fields when catalog item selected */} {katalogEigenschaften.length > 0 && ( {katalogEigenschaften.map((e) => e.typ === 'options' && e.optionen?.length ? ( setEigenschaftValues((prev) => ({ ...prev, [e.id]: ev.target.value }))} sx={{ minWidth: 140 }} > {e.optionen.map((opt) => {opt})} ) : ( setEigenschaftValues((prev) => ({ ...prev, [e.id]: ev.target.value }))} sx={{ minWidth: 160 }} /> ) )} {selectedKatalogItem?.bevorzugter_lieferant_name && !bestellung?.lieferant_id && ( )} )} )} {/* ── Totals Rows (Netto / MwSt / Brutto) ── */} {positionen.length > 0 && ( <> Netto {formatCurrency(totalCost)} MwSt. {editMode && canCreate ? ( { const val = parseFloat(e.target.value); if (!isNaN(val) && val >= 0 && val <= 100) { setEditOrderData(d => ({ ...d, steuersatz: val })); } }} /> ) : ( {steuersatz} )} % {formatCurrency(taxAmount)} Brutto {formatCurrency(totalBrutto)} )}
    {/* ══════════════════════════════════════════════════════════════════════ */} {/* Dateien */} {/* ══════════════════════════════════════════════════════════════════════ */} Dateien {canCreate && ( <> )} {dateien.length === 0 ? ( Keine Dateien vorhanden ) : ( {dateien.map((d) => ( {d.thumbnail_pfad && ( )} {d.dateiname} {formatFileSize(d.dateigroesse)} · {formatDate(d.hochgeladen_am)} {canDelete && ( setDeleteFileTarget(d.id)}> )} ))} )} {/* ══════════════════════════════════════════════════════════════════════ */} {/* Erinnerungen */} {/* ══════════════════════════════════════════════════════════════════════ */} Erinnerungen {canManageReminders && ( )} {erinnerungen.length === 0 && !reminderFormOpen ? ( Keine Erinnerungen ) : ( {erinnerungen.map((r) => ( markReminderDone.mutate(r.id)} size="small" /> {r.nachricht || 'Erinnerung'} Fällig: {formatDate(r.faellig_am)} {canManageReminders && ( setDeleteReminderTarget(r.id)}> )} ))} )} {/* Inline Add Reminder Form */} {reminderFormOpen && ( setReminderForm((f) => ({ ...f, faellig_am: iso }))} /> setReminderForm((f) => ({ ...f, nachricht: e.target.value }))} /> )} {/* ── Notizen ── */} {!editMode && bestellung.notizen && ( Notizen {bestellung.notizen} )} {/* ══════════════════════════════════════════════════════════════════════ */} {/* Historie */} {/* ══════════════════════════════════════════════════════════════════════ */} }> Historie ({historie.length} Einträge) {historie.length === 0 ? ( Keine Einträge ) : ( {historie.map((h) => ( {h.aktion} {h.erstellt_von_name || 'System'} · {formatDateTime(h.erstellt_am)} {h.details && ( {Object.entries(h.details).map(([k, v]) => `${k}: ${v}`).join(', ')} )} ))} )} {/* ══════════════════════════════════════════════════════════════════════ */} {/* Dialogs */} {/* ══════════════════════════════════════════════════════════════════════ */} {/* Status Confirmation */} { setStatusConfirmTarget(null); setStatusForce(false); }} onConfirm={() => statusConfirmTarget && updateStatus.mutate({ status: statusConfirmTarget, force: statusForce || undefined })} title={`Status ändern${statusForce ? ' (manuell)' : ''}`} message={ <> Status von {BESTELLUNG_STATUS_LABELS[bestellung.status]} auf{' '} {statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''} ändern? {statusForce && ( Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs. )} {statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && ( Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern. )} } confirmLabel="Bestätigen" isLoading={updateStatus.isPending} /> {/* Delete Item Confirmation */} setDeleteItemTarget(null)} onConfirm={() => deleteItemTarget != null && deleteItem.mutate(deleteItemTarget)} title="Position löschen" message="Soll diese Position wirklich gelöscht werden?" confirmLabel="Löschen" confirmColor="error" isLoading={deleteItem.isPending} /> {/* Delete File Confirmation */} setDeleteFileTarget(null)} onConfirm={() => deleteFileTarget != null && deleteFile.mutate(deleteFileTarget)} title="Datei löschen" message="Soll diese Datei wirklich gelöscht werden?" confirmLabel="Löschen" confirmColor="error" isLoading={deleteFile.isPending} /> {/* Delete Reminder Confirmation */} setDeleteReminderTarget(null)} onConfirm={() => deleteReminderTarget != null && deleteReminder.mutate(deleteReminderTarget)} title="Erinnerung löschen" message="Soll diese Erinnerung wirklich gelöscht werden?" confirmLabel="Löschen" confirmColor="error" isLoading={deleteReminder.isPending} /> {/* Katalog Add Dialog */} setKatalogDialogOpen(false)} onAddItem={(data) => { addItem.mutate(data); setKatalogDialogOpen(false); }} isPending={addItem.isPending} />
    ); }