Files
dashfront/src/components/manifest/OrderPaymentList.jsx
2026-10-28 00:08:45 +01:00

419 lines
14 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 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 Typography from '@mui/material/Typography';
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 DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
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 orderAutomat from '../../automats/orderAutomat';
import companyAutomat from '../../automats/companyAutomat';
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 {
loadParties,
manifestConfig,
partyDisplayName,
} from './manifestConfig';
import {
YELLOW_PANEL,
SURFACE_RAISED,
} 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', fontFamily: 'monospace' }} />
</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', fontFamily: 'monospace', ...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: <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,
};
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 = companyAutomat.subscribe(
this,
(state) => ({
accountsById: Object.fromEntries((state.accounts || []).map((a) => [a.id, a.name])),
}),
['accounts'],
);
if (orderAutomat.state.status === 'idle') {
void orderAutomat.actions.load().catch(() => {});
}
void companyAutomat.actions.ensureAccountsLoaded().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 } = 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>
{/* 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>
</>
);
}
}
export default withNavigate(OrderPaymentList);