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