Files
dashfront/src/components/manifest/OrderPaymentList.jsx
2026-10-15 11:17:04 +02:00

612 lines
20 KiB
JavaScript
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 { 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 <IconComp sx={sx} />;
}
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: <PaymentTableRow/>
class PaymentTableRow extends PureComponent {
render() {
const { item: payment } = this.props;
if (!payment) {
return (
<ListItemButton disabled divider sx={{ ...rowSx, opacity: 0, pointerEvents: 'none' }}>
<Typography variant="body2">&nbsp;</Typography>
</ListItemButton>
);
}
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 (
<ListItemButton divider sx={rowSx} onClick={() => onNavigateOrder?.(payment.orderId)}>
{/* Payment ID from payment table */}
<Box sx={{ width: 80, flexShrink: 0 }}>
<Chip size="small" label={`#${payment.id}`} sx={{ fontWeight: 700, fontVariantNumeric: 'tabular-nums' }} />
</Box>
{/* Amount */}
<Box sx={{ width: 130, flexShrink: 0, textAlign: 'right' }}>
<Typography variant="body1" sx={{ fontWeight: 700, color: 'success.main', fontVariantNumeric: 'tabular-nums' }}>
+{formatPrice(payment.amount)}
</Typography>
</Box>
{/* Payment Type */}
<Box sx={{ width: 160, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
{typeIcon(payment.type)}
<Typography variant="body2" sx={{ fontWeight: 600, ...ellipsisSx }}>
{typeLabel(payment.type)}
</Typography>
</Box>
{/* Account from account table */}
<Box sx={{ width: 150, flexShrink: 0 }}>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{accountName}
</Typography>
</Box>
{/* Linked Manifest (Auftrag) */}
<Box sx={{ width: 120, flexShrink: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'primary.main', ...ellipsisSx }}>
{payment.orderNumber}
</Typography>
</Box>
{/* Customer Name */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 500, ...ellipsisSx }}>
{customerName}
</Typography>
</Box>
{/* Action: Delete payment */}
<IconButton
size="small"
color="error"
disabled={isBusy}
onClick={(e) => {
e.stopPropagation();
onDelete?.(payment);
}}
aria-label={`Zahlung #${payment.id} löschen`}
sx={{ width: 36, height: 36, flexShrink: 0 }}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</ListItemButton>
);
}
}
// MARK: <RecordPaymentDialog/>
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 (
<AppDialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PaymentsIcon color="primary" />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Zahlungseingang erfassen
</Typography>
</Box>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
<TextField
select
label="Auftrag (Manifest)"
size="small"
value={orderId}
onChange={(e) => 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 (
<MenuItem key={o.id} value={o.id}>
{o.number} — {partyName} (Offen: {formatPrice(rem)})
</MenuItem>
);
})}
</TextField>
<TextField
select
label="Konto / Kasse (account)"
size="small"
value={accountId}
onChange={(e) => this.setState({ accountId: e.target.value })}
>
{accounts.length > 0 ? (
accounts.map((acc) => (
<MenuItem key={acc.id} value={acc.id}>
{acc.name} ({acc.type})
</MenuItem>
))
) : (
<MenuItem value={1}>Standardkonto</MenuItem>
)}
</TextField>
<TextField
select
label="Zahlungsart (type)"
size="small"
value={type}
onChange={(e) => this.setState({ type: e.target.value })}
>
{PAYMENT_TYPES.map((pt) => (
<MenuItem key={pt.value} value={pt.value}>
{pt.label}
</MenuItem>
))}
</TextField>
<TextField
label="Zahlungsbetrag (€)"
size="small"
type="number"
value={amount}
onChange={(e) => this.setState({ amount: e.target.value })}
slotProps={{ htmlInput: { min: 0.01, step: 'any' } }}
/>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1.5 }}>
<Button variant="outlined" onClick={onClose} disabled={saving}>
Abbrechen
</Button>
<Button variant="contained" onClick={this.handleSubmit} disabled={saving} startIcon={<PaymentsIcon />}>
{saving ? 'Wird verbucht…' : 'Zahlung buchen'}
</Button>
</Box>
</Box>
</AppDialog>
);
}
}
// MARK: <OrderPaymentList/>
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 (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (status === 'failed') {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
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 */}
<PanelTileGrid>
<Box
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
flexDirection: 'column',
gap: 2,
pb: 2,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 3,
p: 2,
bgcolor: SURFACE_RAISED,
borderRadius: 2,
flexWrap: 'wrap',
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Gesamtsumme aller Zahlungen
</Typography>
<Typography variant="h5" sx={{ fontWeight: 700, color: 'success.main', fontVariantNumeric: 'tabular-nums' }}>
{formatPrice(totalAmount)}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
<Box>
<Typography variant="caption" color="text.secondary">
Anzahl Zahlungseinträge (Tabelle payment)
</Typography>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{allPayments.length}
</Typography>
</Box>
<Box sx={{ flex: 1 }} />
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => this.setState({ recordDialogOpen: true })}
sx={{ height: 40 }}
>
Zahlungseingang erfassen
</Button>
</Box>
{/* Search Bar */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
placeholder="Zahlungs-ID, Auftrag, Kunde, Konto oder Zahlungsart suchen…"
value={filter}
onChange={(next) => this.setState({ filter: next })}
/>
</Box>
</Box>
</PanelTileGrid>
{/* Payments Table (connected directly to payment records) */}
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={YELLOW_PANEL} sx={{ pb: 0 }}>
<FillSpace
reserveBottom={32}
minPageSize={8}
minHeight={0}
items={filtered}
totalCount={filtered.length}
renderItem={PaymentTableRow}
virtualScrollbar
header={
<Box
sx={{
display: 'flex',
alignItems: 'center',
height: 36,
minHeight: 36,
px: 2,
gap: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
color: 'text.secondary',
typography: 'caption',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
userSelect: 'none',
}}
>
<Box sx={{ width: 80, flexShrink: 0 }}>ID</Box>
<Box sx={{ width: 130, flexShrink: 0, textAlign: 'right' }}>Betrag</Box>
<Box sx={{ width: 160, flexShrink: 0 }}>Zahlungsart</Box>
<Box sx={{ width: 150, flexShrink: 0 }}>Konto</Box>
<Box sx={{ width: 120, flexShrink: 0 }}>Auftrag</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>Kunde</Box>
<Box sx={{ width: 36, flexShrink: 0 }} />
</Box>
}
/>
</PanelTile>
</PanelTileGrid>
{recordDialogOpen && (
<RecordPaymentDialog
open={recordDialogOpen}
orders={items}
partiesById={partiesById}
onClose={() => this.setState({ recordDialogOpen: false })}
/>
)}
</>
);
}
}
export default withNavigate(OrderPaymentList);