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

1848 lines
82 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 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<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: ['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<number | ''>('');
const [expandedId, setExpandedId] = useState<number | null>(null);
const [itemConfig, setItemConfig] = useState({ menge: 1, einheit: 'Stk', einzelpreis: '', artikelnummer: '' });
const [eigenschaftValues, setEigenschaftValues] = useState<Record<number, string>>({});
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 (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth
PaperProps={{ sx: { height: '80vh' } }}>
<DialogTitle>Artikel aus Katalog hinzufügen</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 0, p: 0 }}>
{/* Filters */}
<Box sx={{ display: 'flex', gap: 2, p: 2, pb: 1.5, flexWrap: 'wrap', alignItems: 'center', borderBottom: 1, borderColor: 'divider' }}>
<TextField
size="small"
label="Suche"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Artikel suchen..."
sx={{ minWidth: 200 }}
/>
<TextField
select
size="small"
label="Kategorie"
value={filterKategorie}
onChange={e => setFilterKategorie(e.target.value as number | '')}
sx={{ minWidth: 180 }}
>
<MenuItem value="">Alle Kategorien</MenuItem>
{kategorieOptions.map(k => (
<MenuItem key={k.id} value={k.id}>{k.name}</MenuItem>
))}
</TextField>
{isFetching && <Typography variant="caption" color="text.secondary">Lade...</Typography>}
</Box>
{/* Table */}
<Box sx={{ flexGrow: 1, overflowY: 'auto' }}>
{items.length === 0 && !isFetching ? (
<Box sx={{ p: 3 }}>
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
</Box>
) : (
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>
<TableSortLabel
active={sortField === 'bezeichnung'}
direction={sortField === 'bezeichnung' ? sortDir : 'asc'}
onClick={() => handleSort('bezeichnung')}
>
Bezeichnung
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel
active={sortField === 'kategorie'}
direction={sortField === 'kategorie' ? sortDir : 'asc'}
onClick={() => handleSort('kategorie')}
>
Kategorie
</TableSortLabel>
</TableCell>
<TableCell>Beschreibung</TableCell>
<TableCell align="right">Richtpreis</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{sortedItems.map(item => (
<React.Fragment key={item.id}>
<TableRow
hover
selected={expandedId === item.id}
sx={{ cursor: 'pointer' }}
onClick={() => handleToggleExpand(item)}
>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{item.bezeichnung}
{(item.eigenschaften_count ?? 0) > 0 && (
<Chip label={`${item.eigenschaften_count} Eig.`} size="small" variant="outlined" />
)}
</Box>
</TableCell>
<TableCell>
{kategorieOptions.find(k => k.id === item.kategorie_id)?.name || item.kategorie_name || ''}
</TableCell>
<TableCell sx={{ maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.beschreibung || ''}
</TableCell>
<TableCell align="right">
{item.geschaetzter_preis != null
? new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(item.geschaetzter_preis)
: ''}
</TableCell>
<TableCell align="right">
<Button size="small" variant={expandedId === item.id ? 'contained' : 'outlined'}
onClick={e => { e.stopPropagation(); handleToggleExpand(item); }}>
{expandedId === item.id ? 'Abbrechen' : 'Auswählen'}
</Button>
</TableCell>
</TableRow>
{/* Expanded configuration row */}
{expandedId === item.id && (
<TableRow>
<TableCell colSpan={5} sx={{ p: 0 }}>
<Box sx={{ p: 2, bgcolor: 'action.hover', borderLeft: 4, borderColor: 'primary.main' }}>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>
{item.bezeichnung} Konfigurieren
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<TextField
size="small"
label="Menge"
type="number"
value={itemConfig.menge}
onChange={e => setItemConfig(c => ({ ...c, menge: Math.max(1, Number(e.target.value)) }))}
inputProps={{ min: 1 }}
sx={{ width: 90 }}
/>
<TextField
size="small"
label="Einheit"
value={itemConfig.einheit}
onChange={e => setItemConfig(c => ({ ...c, einheit: e.target.value }))}
sx={{ width: 90 }}
/>
<TextField
size="small"
label="Einzelpreis"
type="number"
value={itemConfig.einzelpreis}
onChange={e => setItemConfig(c => ({ ...c, einzelpreis: e.target.value }))}
placeholder="EUR"
sx={{ width: 120 }}
/>
<TextField
size="small"
label="Artikelnr. (optional)"
value={itemConfig.artikelnummer}
onChange={e => setItemConfig(c => ({ ...c, artikelnummer: e.target.value }))}
sx={{ width: 160 }}
/>
</Box>
{/* Eigenschaften */}
{eigenschaften.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1.5 }}>
{eigenschaften.map(e =>
e.typ === 'options' && e.optionen?.length ? (
<TextField
key={e.id}
select
size="small"
label={e.name}
required={e.pflicht}
value={eigenschaftValues[e.id] || ''}
onChange={ev => setEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))}
sx={{ minWidth: 140 }}
>
<MenuItem value=""></MenuItem>
{e.optionen.map(opt => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</TextField>
) : (
<TextField
key={e.id}
size="small"
label={e.name}
required={e.pflicht}
value={eigenschaftValues[e.id] || ''}
onChange={ev => setEigenschaftValues(prev => ({ ...prev, [e.id]: ev.target.value }))}
sx={{ minWidth: 160 }}
/>
)
)}
</Box>
)}
<Box sx={{ mt: 1.5 }}>
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={() => handleAdd(item)}
disabled={isPending}
>
Zur Bestellung hinzufügen
</Button>
</Box>
</Box>
</TableCell>
</TableRow>
)}
</React.Fragment>
))}
</TableBody>
</Table>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Schließen</Button>
</DialogActions>
</Dialog>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// 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 [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;
mitglied_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;
spezifikationen: string[];
}>>({});
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);
// ── Catalog picker state ──
const [selectedKatalogItem, setSelectedKatalogItem] = useState<AusruestungArtikel | null>(null);
const [katalogEigenschaften, setKatalogEigenschaften] = useState<AusruestungEigenschaft[]>([]);
const [eigenschaftValues, setEigenschaftValues] = useState<Record<number, string>>({});
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<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;
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 (
<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 ── */}
<PageHeader
title={bestellung.bezeichnung}
backTo="/bestellungen"
actions={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{canExport && !editMode && (
<Tooltip title={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung' ? 'Export erst nach Genehmigung verfügbar' : 'PDF Export'}>
<span>
<Button
variant="outlined"
size="small"
startIcon={<PdfIcon />}
onClick={generateBestellungDetailPdf}
disabled={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung'}
>
Export
</Button>
</span>
</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>
</>
)}
<StatusChip
status={bestellung.status}
labelMap={BESTELLUNG_STATUS_LABELS}
colorMap={BESTELLUNG_STATUS_COLORS}
size="medium"
/>
</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} sm={6}>
<Autocomplete
options={orderUsers}
getOptionLabel={(o) => o.name}
value={orderUsers.find(u => u.id === editOrderData.mitglied_id) ?? null}
onChange={(_, v) => setEditOrderData(d => ({ ...d, mitglied_id: v?.id || '' }))}
renderInput={(params) => <TextField {...params} label="Für Mitglied" 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>
{bestellung.mitglied_name && (
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Für Mitglied</Typography>
<Typography>
{bestellung.mitglied_dienstgrad
? `${kurzDienstgrad(bestellung.mitglied_dienstgrad)} ${bestellung.mitglied_name}`
: bestellung.mitglied_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 || canCreate || canApprove) && (
<Box sx={{ mb: 3 }}>
{(['bestellt', 'teillieferung', 'lieferung_pruefen'] as BestellungStatus[]).includes(bestellung.status) && !allCostsEntered && (
<Alert severity="warning" sx={{ mb: 2 }}>Nicht alle Positionen haben Kosten. Bitte Einzelpreise eintragen, bevor die Bestellung abgeschlossen wird.</Alert>
)}
{(['bestellt', 'teillieferung', 'lieferung_pruefen'] as BestellungStatus[]).includes(bestellung.status) && !allItemsReceived && (
<Alert severity="warning" sx={{ mb: 2 }}>Nicht alle Positionen wurden vollständig empfangen. Bitte Eingangsmenge prüfen, bevor die Bestellung abgeschlossen wird.</Alert>
)}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{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 (
<Button
key={s}
variant="contained"
color={color as 'error' | 'primary'}
disabled={isAbgeschlossen && (!allCostsEntered || !allItemsReceived)}
onClick={() => { setStatusForce(false); setStatusConfirmTarget(s); }}
>
{label}
</Button>
);
})}
{/* 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>
</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>
{editMode && canCreate && (
<Button
size="small"
variant="outlined"
startIcon={<AddIcon />}
onClick={() => setKatalogDialogOpen(true)}
sx={{ ml: 1 }}
>
Aus Katalog
</Button>
)}
</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 ? (
<React.Fragment key={p.id}>
<TableRow key={`${p.id}-row`}>
<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, spezifikationen: p.spezifikationen || [] }), 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, spezifikationen: p.spezifikationen || [] }), 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, spezifikationen: p.spezifikationen || [] }), 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, spezifikationen: p.spezifikationen || [] }), 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, spezifikationen: p.spezifikationen || [] }), 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 }}
disabled={!['bestellt', 'teillieferung', 'lieferung_pruefen'].includes(bestellung.status)}
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>
{/* Specifications editor row */}
<TableRow key={`${p.id}-specs`}>
<TableCell colSpan={8} sx={{ pt: 0, pb: 1, pl: 4 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{(editItemsData[p.id]?.spezifikationen || []).map((spec, specIdx) => (
<Box key={specIdx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
value={spec}
placeholder="Spezifikation"
sx={{ flexGrow: 1 }}
onChange={(e) => 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 } };
})}
/>
<IconButton size="small" color="error" onClick={() => 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 } };
})}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
))}
<Button
size="small"
startIcon={<AddIcon />}
onClick={() => setEditItemsData(d => {
const cur = d[p.id]?.spezifikationen ? [...d[p.id].spezifikationen] : [];
return { ...d, [p.id]: { ...d[p.id], spezifikationen: [...cur, ''] } };
})}
sx={{ alignSelf: 'flex-start' }}
>
Spezifikation hinzufügen
</Button>
</Box>
</TableCell>
</TableRow>
</React.Fragment>
) : (
<React.Fragment key={p.id}>
<TableRow>
<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 }}
disabled={!['bestellt', 'teillieferung', 'lieferung_pruefen'].includes(bestellung.status)}
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })}
/>
) : (
p.erhalten_menge
)}
</TableCell>
</TableRow>
{p.spezifikationen && p.spezifikationen.length > 0 && (
<TableRow>
<TableCell colSpan={7} sx={{ pt: 0, pb: 1, pl: 4, borderTop: 'none' }}>
{p.spezifikationen.map((spec, i) => (
<Typography key={i} variant="caption" color="text.secondary" display="block"> {spec}</Typography>
))}
</TableCell>
</TableRow>
)}
</React.Fragment>
),
)}
{/* ── Add Item Row ── */}
{editMode && canCreate && (
<>
<TableRow>
<TableCell>
<Autocomplete<AusruestungArtikel, false, false, true>
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) => <TextField {...params} size="small" placeholder="Bezeichnung" />}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<Box>
<Typography variant="body2">{option.bezeichnung}</Typography>
{option.kategorie_name && <Typography variant="caption" color="text.secondary">{option.kategorie_name}</Typography>}
</Box>
</li>
)}
isOptionEqualToValue={(o, v) => o.id === (typeof v === 'object' ? v.id : undefined)}
sx={{ minWidth: 200 }}
/>
</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>
{/* Characteristic fields when catalog item selected */}
{katalogEigenschaften.length > 0 && (
<TableRow>
<TableCell colSpan={8} sx={{ pt: 0, pb: 1, borderBottom: 'none' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, ml: 1, pl: 1.5, borderLeft: '2px solid', borderColor: 'divider' }}>
{katalogEigenschaften.map((e) =>
e.typ === 'options' && e.optionen?.length ? (
<TextField
key={e.id}
select
size="small"
label={e.name}
required={e.pflicht}
value={eigenschaftValues[e.id] || ''}
onChange={(ev) => setEigenschaftValues((prev) => ({ ...prev, [e.id]: ev.target.value }))}
sx={{ minWidth: 140 }}
>
<MenuItem value=""></MenuItem>
{e.optionen.map((opt) => <MenuItem key={opt} value={opt}>{opt}</MenuItem>)}
</TextField>
) : (
<TextField
key={e.id}
size="small"
label={e.name}
required={e.pflicht}
value={eigenschaftValues[e.id] || ''}
onChange={(ev) => setEigenschaftValues((prev) => ({ ...prev, [e.id]: ev.target.value }))}
sx={{ minWidth: 160 }}
/>
)
)}
{selectedKatalogItem?.bevorzugter_lieferant_name && !bestellung?.lieferant_id && (
<Chip
size="small"
label={`Bevorzugter Lieferant: ${selectedKatalogItem.bevorzugter_lieferant_name}`}
color="info"
variant="outlined"
sx={{ alignSelf: 'center' }}
/>
)}
</Box>
</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 */}
<ConfirmDialog
open={statusConfirmTarget != null}
onClose={() => { setStatusConfirmTarget(null); setStatusForce(false); }}
onConfirm={() => statusConfirmTarget && updateStatus.mutate({ status: statusConfirmTarget, force: statusForce || undefined })}
title={`Status ändern${statusForce ? ' (manuell)' : ''}`}
message={
<>
<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>
)}
{statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && (
<Alert severity="warning" sx={{ mt: 2 }}>
Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern.
</Alert>
)}
</>
}
confirmLabel="Bestätigen"
isLoading={updateStatus.isPending}
/>
{/* Delete Item Confirmation */}
<ConfirmDialog
open={deleteItemTarget != null}
onClose={() => 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 */}
<ConfirmDialog
open={deleteFileTarget != null}
onClose={() => 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 */}
<ConfirmDialog
open={deleteReminderTarget != null}
onClose={() => 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 */}
<KatalogAddDialog
open={katalogDialogOpen}
onClose={() => setKatalogDialogOpen(false)}
onAddItem={(data) => {
addItem.mutate(data);
setKatalogDialogOpen(false);
}}
isPending={addItem.isPending}
/>
</DashboardLayout>
);
}