Files
dashfront/src/components/manifest/OrderDocumentsTiles.jsx
2026-10-27 00:28:06 +01:00

463 lines
18 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 Card from '@mui/material/Card';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
import AddIcon from '@mui/icons-material/Add';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import { enqueueSnackbar } from 'notistack';
import { PanelTile } from 'dashgui/panels';
import AppDialog from '../AppDialog';
import { payment as paymentApi } from '../../api';
import { notifyError } from '../../utils/notify';
import { formatDate, formatDateTime, todayInputValue } from './manifestConfig';
import { formatPrice } from '../../utils/price';
import { typeLabel, typeIcon, PAYMENT_TYPES } from './OrderPaymentList';
import ShipmentDetailDialog from './ShipmentDetailDialog';
import {
GREEN_PANEL,
YELLOW_PANEL,
WHITE,
} from '../../colors';
/**
* Renders 1-wide associated document tiles for an order or retoure in edit mode:
* 1. Lieferscheine / Auslieferungen (Shipments)
* 2. Zahlungen / Erstattungen (Payments)
*/
export class OrderDocumentsTiles extends PureComponent {
constructor(props) {
super(props);
this.state = {
selectedShipment: null,
paymentDialogOpen: false,
paymentAmount: '',
paymentType: 'cash',
paymentDate: todayInputValue(),
paymentSaving: false,
};
this.handleOpenShipment = this.handleOpenShipment.bind(this);
this.handleCloseShipment = this.handleCloseShipment.bind(this);
this.handleOpenPayments = this.handleOpenPayments.bind(this);
this.handleOpenPaymentDialog = this.handleOpenPaymentDialog.bind(this);
this.handleClosePaymentDialog = this.handleClosePaymentDialog.bind(this);
this.handleSubmitPayment = this.handleSubmitPayment.bind(this);
}
handleOpenPaymentDialog() {
const { orderTotal, paidTotal, kind } = this.props;
const tot = Number(orderTotal) || 0;
const pd = Number(paidTotal) || 0;
const absTot = Math.abs(tot);
const absPd = Math.abs(pd);
const isPaid = Boolean(this.props.paid || (absTot > 0 && absPd >= absTot - 0.005));
const remaining = isPaid ? 0 : Math.max(0, absTot - absPd);
const defaultAmount = remaining > 0
? (kind === 'retoure' || tot < 0 ? -remaining : remaining)
: '';
this.setState({
paymentDialogOpen: true,
paymentAmount: defaultAmount !== '' ? String(Math.round(defaultAmount * 100) / 100) : '',
paymentType: 'cash',
paymentDate: todayInputValue(),
});
}
handleClosePaymentDialog() {
this.setState({ paymentDialogOpen: false, paymentSaving: false });
}
async handleSubmitPayment(e) {
e?.preventDefault?.();
const { orderId, onShipmentChanged } = this.props;
const { paymentAmount, paymentType, paymentDate, paymentSaving } = this.state;
if (paymentSaving || !orderId) return;
const amt = Number(String(paymentAmount).replace(',', '.'));
if (Number.isNaN(amt) || amt === 0) {
enqueueSnackbar('Bitte einen gültigen Betrag ungleich 0 eingeben.', { variant: 'warning' });
return;
}
this.setState({ paymentSaving: true });
try {
await paymentApi.create({
manifestId: orderId,
amount: amt,
type: paymentType,
date: paymentDate || undefined,
});
enqueueSnackbar(amt < 0 ? 'Erstattung erfolgreich gebucht!' : 'Zahlung erfolgreich erfasst!', {
variant: 'success',
});
this.handleClosePaymentDialog();
onShipmentChanged?.();
} catch (err) {
notifyError(err);
this.setState({ paymentSaving: false });
}
}
handleOpenShipment(sh) {
this.setState({ selectedShipment: sh });
}
handleCloseShipment() {
this.setState({ selectedShipment: null });
}
handleOpenPayments() {
if (this.props.navigate) {
this.props.navigate('/auftrag/zahlungen');
}
}
renderShipmentsTile() {
const { shipments = [], shipped, delivered, navigate, deliverDisabled } = this.props;
const statusLabel = delivered
? 'Geliefert'
: shipped
? 'Versendet'
: shipments.length > 0
? 'Teilweise'
: 'Offen';
const statusColor = delivered || shipped ? 'success' : shipments.length > 0 ? 'warning' : 'default';
return (
<PanelTile wide={1} minHeight={220} color={GREEN_PANEL} title="Lieferscheine">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Stack direction="row" spacing={0.75} sx={{ alignItems: 'center' }}>
<LocalShippingOutlinedIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
<Chip size="small" label={statusLabel} color={statusColor} sx={{ fontWeight: 600, height: 22 }} />
</Stack>
<Button
size="small"
variant="outlined"
startIcon={<AddIcon />}
disabled={deliverDisabled}
onClick={this.props.onOpenCreateShipment || (() => navigate?.('/auftrag/ausliefern'))}
title={deliverDisabled ? 'Alle Positionen bereits ausgeliefert' : 'Lieferschein erstellen'}
sx={{ fontWeight: 600, textTransform: 'none', height: 26, fontSize: '0.75rem', px: 1 }}
>
Liefern
</Button>
</Box>
{shipments.length > 0 ? (
<Stack spacing={1}>
{shipments.map((sh) => {
const carrier = sh.shippings?.[0]?.carrier;
const tracking = sh.shippings?.[0]?.trackingCode;
const posCount = sh.lineCount ?? sh.lines?.length ?? 0;
const shDate = sh.date || sh.createdAt || sh.created_at;
return (
<Card
key={sh.id ?? sh.number}
variant="outlined"
onClick={() => this.handleOpenShipment(sh)}
sx={{
p: 1.25,
bgcolor: WHITE,
cursor: 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
borderColor: 'primary.main',
},
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.85rem', fontFamily: 'monospace' }}>
{sh.number}
</Typography>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary">
{shDate ? formatDateTime(shDate) : '–'}
</Typography>
{navigate && (
<IconButton
size="small"
title="In Auslieferungen öffnen"
onClick={(e) => {
e.stopPropagation();
navigate(`/auftrag/ausliefern?shipmentId=${sh.id}`);
}}
sx={{ p: 0.25, ml: 0.5, color: 'text.secondary', '&:hover': { color: 'primary.main' } }}
>
<OpenInNewIcon sx={{ fontSize: 13 }} />
</IconButton>
)}
</Stack>
</Box>
{sh.externalNumber ? (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>
Ref: <Box component="span" sx={{ fontFamily: 'monospace' }}>{sh.externalNumber}</Box>
</Typography>
) : null}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{posCount} Pos.
</Typography>
{carrier || tracking ? (
<Chip
size="small"
color="primary"
variant="outlined"
label={carrier || 'Paket'}
sx={{ height: 18, fontSize: '0.65rem' }}
/>
) : null}
</Box>
</Card>
);
})}
</Stack>
) : null}
</PanelTile>
);
}
renderPaymentsTile() {
const { payments = [], paid, paidTotal, orderTotal, kind } = this.props;
const isRetoure = kind === 'retoure';
const tot = Number(orderTotal) || 0;
const pd = Number(paidTotal) || 0;
const absTot = Math.abs(tot);
const absPd = Math.abs(pd);
const isPaid = Boolean(paid || (absTot > 0 && absPd >= absTot - 0.005));
let openAmount = 0;
if (!isPaid) {
const remaining = Math.max(0, absTot - absPd);
openAmount = tot < 0 ? -remaining : remaining;
}
const statusLabel = isPaid
? (isRetoure ? 'Erstattet' : 'Bezahlt')
: Math.abs(pd) > 0
? 'Teilzahlung'
: 'Offen';
const statusColor = isPaid ? 'success' : Math.abs(pd) > 0 ? 'warning' : 'error';
return (
<PanelTile wide={1} minHeight={220} color={YELLOW_PANEL} title={isRetoure ? 'Erstattungen / Zahlungen' : 'Zahlungen'}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Stack direction="row" spacing={0.75} sx={{ alignItems: 'center' }}>
<PaymentsOutlinedIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
<Chip size="small" label={statusLabel} color={statusColor} sx={{ fontWeight: 600, height: 22 }} />
</Stack>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Button
size="small"
variant="contained"
onClick={this.handleOpenPaymentDialog}
sx={{ fontWeight: 600, textTransform: 'none', height: 26, fontSize: '0.75rem', px: 1 }}
>
{isRetoure ? '+ Erstattung' : '+ Zahlung'}
</Button>
<Button
size="small"
variant="outlined"
onClick={this.handleOpenPayments}
sx={{ fontWeight: 600, textTransform: 'none', height: 26, fontSize: '0.75rem', px: 1 }}
>
Journal
</Button>
</Box>
</Box>
{/* Financial KPI Card */}
<Box
sx={{
p: 1,
mb: 1.5,
bgcolor: WHITE,
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary">
{isRetoure ? 'Gutschrift / Summe:' : 'Summe:'}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{formatPrice(tot)}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary">
{isRetoure ? 'Erstattet:' : 'Bezahlt:'}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'success.main' }}>
{formatPrice(pd)}
</Typography>
</Box>
<Divider sx={{ my: 0.5 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">
Offen:
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: openAmount !== 0 ? 'error.main' : 'text.secondary' }}>
{formatPrice(openAmount)}
</Typography>
</Box>
</Box>
{payments.length > 0 ? (
<Stack spacing={1}>
{payments.map((pay) => {
const pType = pay.type || pay.paymentType || pay.payment_type;
const payDate = pay.date || pay.createdAt || pay.created_at || pay.timestamp;
const pAmt = Number(pay.amount) || 0;
const isRefund = pAmt < 0;
return (
<Card
key={pay.id}
variant="outlined"
onClick={this.handleOpenPayments}
sx={{
p: 1.25,
bgcolor: WHITE,
cursor: 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
borderColor: 'primary.main',
},
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Stack direction="row" spacing={0.75} sx={{ alignItems: 'center', minWidth: 0, overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', color: 'text.secondary', flexShrink: 0 }}>
{typeIcon(pType, { fontSize: 16 })}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{typeLabel(pType)}
</Typography>
</Stack>
<Typography
variant="subtitle2"
sx={{
fontWeight: 700,
color: isRefund ? 'error.main' : 'success.dark',
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
ml: 1,
}}
>
{isRefund ? formatPrice(pAmt) : `+${formatPrice(pAmt)}`}
</Typography>
</Box>
{payDate ? (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 0.5 }}>
{pay.id != null ? (
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
#{pay.id}
</Typography>
) : <span />}
<Typography variant="caption" color="text.secondary">
{formatDateTime(payDate)}
</Typography>
</Box>
) : null}
</Card>
);
})}
</Stack>
) : null}
</PanelTile>
);
}
render() {
const { kind } = this.props;
const isRetoure = kind === 'retoure';
return (
<>
{this.renderShipmentsTile()}
{this.renderPaymentsTile()}
<ShipmentDetailDialog
open={Boolean(this.state.selectedShipment)}
shipment={this.state.selectedShipment}
onClose={this.handleCloseShipment}
onNavigate={this.props.navigate}
onChanged={this.props.onShipmentChanged}
/>
<AppDialog
open={this.state.paymentDialogOpen}
onClose={this.handleClosePaymentDialog}
title={isRetoure ? 'Erstattung buchen' : 'Zahlung erfassen'}
>
<Box component="form" onSubmit={this.handleSubmitPayment} sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Betrag (€)"
type="text"
autoFocus
value={this.state.paymentAmount}
onChange={(e) => this.setState({ paymentAmount: e.target.value })}
helperText={isRetoure ? 'Negative Beträge für Gutschrift/Erstattung (z.B. -50.00)' : 'Zahlungsbetrag'}
fullWidth
required
/>
<TextField
select
label="Zahlungsart"
value={this.state.paymentType}
onChange={(e) => this.setState({ paymentType: e.target.value })}
fullWidth
>
{PAYMENT_TYPES.map((t) => (
<MenuItem key={t.value} value={t.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{typeIcon(t.value, { fontSize: 18 })}
<span>{t.label}</span>
</Box>
</MenuItem>
))}
</TextField>
<TextField
label="Datum"
type="date"
value={this.state.paymentDate}
onChange={(e) => this.setState({ paymentDate: e.target.value })}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
<Button onClick={this.handleClosePaymentDialog} disabled={this.state.paymentSaving}>
Abbrechen
</Button>
<Button type="submit" variant="contained" disabled={this.state.paymentSaving}>
{this.state.paymentSaving ? 'Bucht…' : 'Buchen'}
</Button>
</Box>
</Box>
</AppDialog>
</>
);
}
}
export default OrderDocumentsTiles;