import { PureComponent } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Chip from '@mui/material/Chip'; import CircularProgress from '@mui/material/CircularProgress'; import Divider from '@mui/material/Divider'; import IconButton from '@mui/material/IconButton'; import ListItemButton from '@mui/material/ListItemButton'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import MenuItem from '@mui/material/MenuItem'; import PaymentsIcon from '@mui/icons-material/Payments'; import AccountBalanceIcon from '@mui/icons-material/AccountBalance'; import PointOfSaleIcon from '@mui/icons-material/PointOfSale'; import CreditCardIcon from '@mui/icons-material/CreditCard'; import AddIcon from '@mui/icons-material/Add'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import CloseIcon from '@mui/icons-material/Close'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import { enqueueSnackbar } from 'notistack'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import FillSpace from 'dashgui/FillSpace'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import BackendError from '../BackendError'; import AppDialog from '../AppDialog'; import orderAutomat from '../../automats/orderAutomat'; import accountAutomat from '../../automats/accountAutomat'; import { payment as paymentApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; import { formatPrice } from '../../utils/price'; import withNavigate from '../withNavigate'; import { formatDate, loadParties, manifestConfig, partyDisplayName, } from './manifestConfig'; import { YELLOW_PANEL, SURFACE_RAISED, WHITE, } from '../../colors'; const cfg = manifestConfig('order'); export const PAYMENT_TYPES = [ { value: 'transfer', label: 'Banküberweisung', icon: AccountBalanceIcon }, { value: 'cash', label: 'Barzahlung', icon: PointOfSaleIcon }, { value: 'card', label: 'EC- / Kreditkarte', icon: CreditCardIcon }, { value: 'paypal', label: 'PayPal', icon: PaymentsIcon }, { value: 'sepa', label: 'SEPA-Lastschrift', icon: AccountBalanceIcon }, { value: 'invoice', label: 'Auf Rechnung', icon: PaymentsIcon }, { value: 'other', label: 'Sonstige', icon: PaymentsIcon }, ]; export function typeLabel(val) { const match = PAYMENT_TYPES.find((t) => t.value === val); return match?.label || val || 'Zahlung'; } export function typeIcon(val, sx = { fontSize: 18 }) { const match = PAYMENT_TYPES.find((t) => t.value === val); const IconComp = match?.icon || PaymentsIcon; return ; } const rowSx = { alignItems: 'center', height: 56, minHeight: 56, maxHeight: 56, boxSizing: 'border-box', py: 0, gap: 1.5, }; const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const paymentRowContext = { partiesById: {}, accountsById: {}, busyId: null, onDelete: null, onNavigateOrder: null, }; // MARK: class PaymentTableRow extends PureComponent { render() { const { item: payment } = this.props; if (!payment) { return (   ); } const { partiesById, accountsById, busyId, onDelete, onNavigateOrder } = paymentRowContext; const party = partiesById[payment.entityId]; const customerName = partyDisplayName('order', party) || (payment.entityId ? `Kunde #${payment.entityId}` : '–'); const accountName = accountsById[payment.accountId] || `Konto #${payment.accountId}`; const isBusy = busyId === payment.id; return ( onNavigateOrder?.(payment.orderId)}> {/* Payment ID from payment table */} {/* Amount */} +{formatPrice(payment.amount)} {/* Payment Type */} {typeIcon(payment.type)} {typeLabel(payment.type)} {/* Account from account table */} {accountName} {/* Linked Manifest (Auftrag) */} {payment.orderNumber} {/* Customer Name */} {customerName} {/* Action: Delete payment */} { e.stopPropagation(); onDelete?.(payment); }} aria-label={`Zahlung #${payment.id} löschen`} sx={{ width: 36, height: 36, flexShrink: 0 }} > ); } } // MARK: class RecordPaymentDialog extends PureComponent { constructor(props) { super(props); const orders = props.orders || []; // Prefer unpaid orders const unpaidOrders = orders.filter((o) => { const tot = Number(o.orderTotal) || 0; const pd = Number(o.paidTotal) || 0; return tot === 0 || pd < tot; }); const selectedOrder = unpaidOrders[0] || orders[0] || null; const remaining = selectedOrder ? Math.max(0, Math.round(((Number(selectedOrder.orderTotal) || 0) - (Number(selectedOrder.paidTotal) || 0)) * 100) / 100) : 0; const accounts = accountAutomat.state.items || []; const defaultAccountId = accounts[0]?.id || 1; this.state = { orderId: selectedOrder?.id || '', accountId: defaultAccountId, type: 'transfer', amount: remaining > 0 ? String(remaining) : '', saving: false, }; this.handleOrderChange = this.handleOrderChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); } handleOrderChange(newOrderId) { const order = this.props.orders.find((o) => o.id === Number(newOrderId)); const remaining = order ? Math.max(0, Math.round(((Number(order.orderTotal) || 0) - (Number(order.paidTotal) || 0)) * 100) / 100) : 0; this.setState({ orderId: newOrderId, amount: remaining > 0 ? String(remaining) : '', }); } async handleSubmit() { const { onClose, onSuccess } = this.props; const { orderId, accountId, type, amount } = this.state; const numAmount = Number(String(amount).replace(',', '.')); if (!orderId) { enqueueSnackbar('Bitte einen Auftrag auswählen.', { variant: 'warning' }); return; } if (!Number.isFinite(numAmount) || numAmount <= 0) { enqueueSnackbar('Bitte einen gültigen Betrag größer 0 eingeben.', { variant: 'warning' }); return; } this.setState({ saving: true }); try { await paymentApi.create(orderId, { accountId: Number(accountId), type, amount: numAmount, }); enqueueSnackbar('Zahlungseingang erfolgreich verbucht!', { variant: 'success' }); await orderAutomat.actions.reload(); onSuccess?.(); onClose(); } catch (err) { notifyError(err); this.setState({ saving: false }); } } render() { const { open, onClose, orders, partiesById } = this.props; const { orderId, accountId, type, amount, saving } = this.state; const accounts = accountAutomat.state.items || []; return ( Zahlungseingang erfassen this.handleOrderChange(e.target.value)} fullWidth > {orders.map((o) => { const party = partiesById[o.entityId]; const partyName = partyDisplayName('order', party) || `Kunde #${o.entityId}`; const tot = Number(o.orderTotal) || 0; const pd = Number(o.paidTotal) || 0; const rem = Math.max(0, tot - pd); return ( {o.number} — {partyName} (Offen: {formatPrice(rem)}) ); })} this.setState({ accountId: e.target.value })} > {accounts.length > 0 ? ( accounts.map((acc) => ( {acc.name} ({acc.type}) )) ) : ( Standardkonto )} this.setState({ type: e.target.value })} > {PAYMENT_TYPES.map((pt) => ( {pt.label} ))} this.setState({ amount: e.target.value })} slotProps={{ htmlInput: { min: 0.01, step: 'any' } }} /> ); } } // MARK: export class OrderPaymentList extends PureComponent { constructor(props) { super(props); const s = orderAutomat.state; this.state = { status: s.status === 'ready' ? 'ready' : 'loading', error: s.error ? backendErrorFrom(s.error) : null, items: s.items ?? [], filter: '', partiesById: {}, accountsById: {}, busyId: null, recordDialogOpen: false, }; this._cancelled = false; this.handleRetry = this.handleRetry.bind(this); this.handleDeletePayment = this.handleDeletePayment.bind(this); this.handleNavigateOrder = this.handleNavigateOrder.bind(this); } componentDidMount() { this._cancelled = false; this.unsub = orderAutomat.subscribe(this, (state) => ({ items: state.items ?? [], status: state.status, error: state.error ? backendErrorFrom(state.error) : null, })); this.unsubAccount = accountAutomat.subscribe(this, (state) => ({ accountsById: Object.fromEntries((state.items || []).map((a) => [a.id, a.name])), })); if (orderAutomat.state.status === 'idle') { void orderAutomat.actions.load().catch(() => {}); } void accountAutomat.actions.ensureLoaded().catch(() => {}); void this.loadPartiesData(); } componentWillUnmount() { this._cancelled = true; this.unsub?.(); this.unsubAccount?.(); } async loadPartiesData() { try { const parties = await loadParties('order'); if (!this._cancelled) { this.setState({ partiesById: cfg.partiesByIdFromList(parties) }); } } catch { // Non-fatal } } handleRetry() { void orderAutomat.actions.load().catch(() => {}); } extractAllPayments(orders = this.state.items) { const list = []; for (const order of orders) { if (!order || !Array.isArray(order.payments)) continue; for (const pay of order.payments) { list.push({ ...pay, orderId: order.id, orderNumber: order.number, orderDate: order.date, entityId: order.entityId, orderTotal: order.orderTotal, paidTotal: order.paidTotal, }); } } // Sort descending by payment id return list.sort((a, b) => Number(b.id || 0) - Number(a.id || 0)); } async handleDeletePayment(payment) { if (!window.confirm(`Zahlung #${payment.id} über ${formatPrice(payment.amount)} wirklich löschen?`)) return; this.setState({ busyId: payment.id }); try { await paymentApi.delete(payment.id); enqueueSnackbar(`Zahlung #${payment.id} gelöscht.`, { variant: 'success' }); await orderAutomat.actions.reload(); } catch (err) { notifyError(err); } finally { if (!this._cancelled) { this.setState({ busyId: null }); } } } handleNavigateOrder(orderId) { if (orderId) { this.props.navigate(`/auftrag/bestellung/${orderId}`); } } getFilteredPayments() { const all = this.extractAllPayments(); const q = this.state.filter.trim().toLowerCase(); if (!q) return all; const { partiesById, accountsById } = this.state; return all.filter((pay) => { const party = partiesById[pay.entityId]; const customerName = partyDisplayName('order', party); const accountName = accountsById[pay.accountId]; const typeStr = typeLabel(pay.type); return [ String(pay.id), pay.orderNumber, customerName, accountName, typeStr, pay.type, ].some((val) => String(val || '').toLowerCase().includes(q)); }); } render() { const { status, error, filter, partiesById, accountsById, busyId, recordDialogOpen, items } = this.state; if (status === 'loading' || status === 'idle') { return ( ); } if (status === 'failed') { return ; } const filtered = this.getFilteredPayments(); const allPayments = this.extractAllPayments(); const totalAmount = allPayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0); paymentRowContext.partiesById = partiesById; paymentRowContext.accountsById = accountsById; paymentRowContext.busyId = busyId; paymentRowContext.onDelete = this.handleDeletePayment; paymentRowContext.onNavigateOrder = this.handleNavigateOrder; return ( <> {/* Metrics Banner */} Gesamtsumme aller Zahlungen {formatPrice(totalAmount)} Anzahl Zahlungseinträge (Tabelle payment) {allPayments.length} {/* Search Bar */} this.setState({ filter: next })} /> {/* Payments Table (connected directly to payment records) */} ID Betrag Zahlungsart Konto Auftrag Kunde } /> {recordDialogOpen && ( this.setState({ recordDialogOpen: false })} /> )} ); } } export default withNavigate(OrderPaymentList);