u
This commit is contained in:
567
src/components/manifest/OrderList.jsx
Normal file
567
src/components/manifest/OrderList.jsx
Normal file
@@ -0,0 +1,567 @@
|
||||
import { Fragment, 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 Collapse from '@mui/material/Collapse';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableFooter from '@mui/material/TableFooter';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import orderAutomat from '../../automats/orderAutomat';
|
||||
import { order as orderApi } from '../../api';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import withNavigate from '../withNavigate';
|
||||
import ResultsPagination from '../article/ResultsPagination';
|
||||
import {
|
||||
formatDate,
|
||||
loadParties,
|
||||
manifestConfig,
|
||||
numberDisplay,
|
||||
partyDisplayName,
|
||||
} from './manifestConfig';
|
||||
|
||||
function tintDivider(color, amount = 0.15) {
|
||||
if (typeof color !== 'string') return 'divider';
|
||||
const m = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||
if (!m) return 'divider';
|
||||
|
||||
let hex = m[1];
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('');
|
||||
}
|
||||
const num = parseInt(hex, 16);
|
||||
const channels = [16, 8, 0].map((shift) => {
|
||||
const c = (num >> shift) & 0xff;
|
||||
return Math.max(0, Math.round(c * (1 - amount)));
|
||||
});
|
||||
const toHex = (v) => v.toString(16).padStart(2, '0');
|
||||
return `#${channels.map(toHex).join('')}`;
|
||||
}
|
||||
|
||||
function calculateOrderTotal(lines) {
|
||||
if (!Array.isArray(lines) || lines.length === 0) return 0;
|
||||
return lines.reduce((acc, line) => {
|
||||
const amount = Number(line.amount) || 0;
|
||||
const net = Number(line.net) || 0;
|
||||
const rebate = Number(line.rebate) || 0;
|
||||
const lineTotal = amount * net * (1 - rebate / 100);
|
||||
return acc + lineTotal;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function calculateOrderGrossTotal(lines) {
|
||||
if (!Array.isArray(lines) || lines.length === 0) return 0;
|
||||
return lines.reduce((acc, line) => {
|
||||
const amount = Number(line.amount) || 0;
|
||||
const net = Number(line.net) || 0;
|
||||
const rebate = Number(line.rebate) || 0;
|
||||
const taxrate = Number(line.taxrate) || 0;
|
||||
const lineNet = amount * net * (1 - rebate / 100);
|
||||
const lineGross = lineNet * (1 + taxrate / 100);
|
||||
return acc + lineGross;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export class OrderList extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.cfg = manifestConfig('order');
|
||||
const orderState = orderAutomat.state;
|
||||
this.state = {
|
||||
status: orderState.status === 'ready' ? 'ready' : 'loading',
|
||||
error: orderState.error,
|
||||
orders: orderState.items ?? [],
|
||||
total: orderState.total ?? 0,
|
||||
offset: orderState.offset ?? 0,
|
||||
limit: orderState.limit ?? 20,
|
||||
partiesById: {},
|
||||
busyId: null,
|
||||
expandedIds: new Set(),
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handleCreate = this.handleCreate.bind(this);
|
||||
this.handleEdit = this.handleEdit.bind(this);
|
||||
this.handleDelete = this.handleDelete.bind(this);
|
||||
this.handlePageChange = this.handlePageChange.bind(this);
|
||||
this.toggleExpand = this.toggleExpand.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
this.unsubOrder = orderAutomat.subscribe(this, (state) => ({
|
||||
orders: state.items ?? [],
|
||||
total: state.total ?? 0,
|
||||
offset: state.offset ?? 0,
|
||||
limit: state.limit ?? 20,
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
}));
|
||||
|
||||
void this.loadPartiesAndOrders();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
this.unsubOrder?.();
|
||||
}
|
||||
|
||||
async loadPartiesAndOrders() {
|
||||
try {
|
||||
const parties = await loadParties('order');
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
partiesById: this.cfg.partiesByIdFromList(parties),
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal, party names can fall back to IDs
|
||||
}
|
||||
|
||||
try {
|
||||
await orderAutomat.actions.ensureLoaded();
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
async handleRetry() {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
await orderAutomat.actions.reload();
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
go(to) {
|
||||
const { navigate } = this.props;
|
||||
if (typeof navigate === 'function') {
|
||||
navigate(to);
|
||||
return;
|
||||
}
|
||||
window.location.assign(to);
|
||||
}
|
||||
|
||||
handleCreate() {
|
||||
this.go(this.cfg.createPath);
|
||||
}
|
||||
|
||||
handleEdit(order) {
|
||||
this.go(this.cfg.editPath(order.id));
|
||||
}
|
||||
|
||||
async handleDelete(order) {
|
||||
if (this.state.busyId != null) return;
|
||||
if (!window.confirm(this.cfg.confirmDelete(order.number))) return;
|
||||
|
||||
this.setState({ busyId: order.id });
|
||||
try {
|
||||
await orderApi.delete(order.id);
|
||||
await orderAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ busyId: null });
|
||||
enqueueSnackbar(this.cfg.deletedMsg, { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busyId: null });
|
||||
}
|
||||
}
|
||||
|
||||
handlePageChange(page) {
|
||||
// Formula: Next Offset: (page - 1) * limit
|
||||
void orderAutomat.actions.setPage(page);
|
||||
}
|
||||
|
||||
toggleExpand(orderId) {
|
||||
this.setState((prev) => {
|
||||
const next = new Set(prev.expandedIds);
|
||||
if (next.has(orderId)) {
|
||||
next.delete(orderId);
|
||||
} else {
|
||||
next.add(orderId);
|
||||
}
|
||||
return { expandedIds: next };
|
||||
});
|
||||
}
|
||||
|
||||
partyName(partyId) {
|
||||
if (partyId == null) return '–';
|
||||
const party = this.state.partiesById[partyId];
|
||||
return partyDisplayName('order', party) || `Kunde #${partyId}`;
|
||||
}
|
||||
|
||||
renderLoading() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
renderLineItems(order, borderColor) {
|
||||
const lines = order.lines ?? [];
|
||||
if (lines.length === 0) {
|
||||
return (
|
||||
<Box sx={{ py: 1.5, px: 2, bgcolor: 'action.hover', borderRadius: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Positionen vorhanden.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const netTotal = calculateOrderTotal(lines);
|
||||
const grossTotal = calculateOrderGrossTotal(lines);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 1.5,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.45)',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor,
|
||||
}}
|
||||
>
|
||||
<Table size="small" aria-label={`Positionen für Bestellung ${order.number}`}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center" sx={{ fontWeight: 600, fontSize: '0.75rem', width: 44 }}>
|
||||
Pos
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, fontSize: '0.75rem' }}>SKU</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, fontSize: '0.75rem' }}>Artikel</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Menge
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Netto
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Brutto
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Rabatt
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
MwSt.
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Gesamt netto
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
|
||||
Gesamt brutto
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{lines.map((line, idx) => {
|
||||
const amount = Number(line.amount) || 0;
|
||||
const net = Number(line.net) || 0;
|
||||
const taxrate = Number(line.taxrate) || 0;
|
||||
const gross = net * (1 + taxrate / 100);
|
||||
const rebate = Number(line.rebate) || 0;
|
||||
const lineNet = amount * net * (1 - rebate / 100);
|
||||
const lineGross = lineNet * (1 + taxrate / 100);
|
||||
const puText = Number(line.pu) > 1 ? ` (VPE ${line.pu})` : '';
|
||||
|
||||
return (
|
||||
<TableRow key={line.id ?? `line-${idx}`}>
|
||||
<TableCell align="center" sx={{ fontSize: '0.75rem', color: 'text.secondary', fontWeight: 600 }}>
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
{line.sku || '–'}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: '0.75rem' }}>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 500 }}>
|
||||
{line.name || '–'}
|
||||
</Typography>
|
||||
{line.comment ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{line.comment}
|
||||
</Typography>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
|
||||
{amount}
|
||||
{puText}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
|
||||
{formatPrice(net)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
|
||||
{formatPrice(gross)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
|
||||
{rebate > 0 ? `${rebate}%` : '–'}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
|
||||
{line.taxrate != null ? `${line.taxrate}%` : '–'}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 600 }}>
|
||||
{formatPrice(lineNet)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 600, color: 'primary.dark' }}>
|
||||
{formatPrice(lineGross)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow sx={{ '& > td': { borderTop: '2px solid', borderColor, fontWeight: 700 } }}>
|
||||
<TableCell colSpan={8} align="right" sx={{ fontSize: '0.75rem', fontWeight: 700 }}>
|
||||
Gesamtsumme:
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 700 }}>
|
||||
{formatPrice(netTotal)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 700, color: 'primary.dark' }}>
|
||||
{formatPrice(grossTotal)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, error, orders, total, offset, limit, busyId, expandedIds } = this.state;
|
||||
const cfg = this.cfg;
|
||||
|
||||
if (status === 'loading') return this.renderLoading();
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<BackendError
|
||||
error={error}
|
||||
onRetry={this.handleRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const borderColor = tintDivider(cfg.listColor);
|
||||
|
||||
// Client Pagination Formula:
|
||||
// Current Page: Math.floor(offset / limit) + 1
|
||||
// Total Pages: Math.ceil(total / limit)
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
const pageCount = Math.max(1, Math.ceil(total / limit));
|
||||
const showPagination = total > limit;
|
||||
|
||||
return (
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={3} minHeight={240} color={cfg.listColor}>
|
||||
<Stack spacing={2}>
|
||||
{/* Create Header Button */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={this.handleCreate}
|
||||
>
|
||||
{cfg.createButton}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Empty State */}
|
||||
{orders.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{cfg.emptyList}
|
||||
</Typography>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table
|
||||
size="small"
|
||||
sx={{
|
||||
'& .MuiTableCell-root': {
|
||||
borderColor,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox" sx={{ width: 44 }} />
|
||||
<TableCell sx={{ width: 130, fontWeight: 600 }}>Nummer</TableCell>
|
||||
<TableCell sx={{ width: 130, fontWeight: 600 }}>Referenz</TableCell>
|
||||
<TableCell sx={{ width: 130, fontWeight: 600 }}>{cfg.dateLabel}</TableCell>
|
||||
<TableCell sx={{ minWidth: 160, fontWeight: 600 }}>{cfg.partyLabel}</TableCell>
|
||||
<TableCell sx={{ minWidth: 180, fontWeight: 600 }}>Positionen</TableCell>
|
||||
<TableCell padding="checkbox" align="right" sx={{ width: 84 }} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{orders.map((order) => {
|
||||
const isExpanded = expandedIds.has(order.id);
|
||||
const lines = order.lines ?? [];
|
||||
const netTotal = calculateOrderTotal(lines);
|
||||
const grossTotal = calculateOrderGrossTotal(lines);
|
||||
const hasLines = lines.length > 0;
|
||||
|
||||
return (
|
||||
<Fragment key={order.id}>
|
||||
<TableRow
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => this.handleEdit(order)}
|
||||
>
|
||||
{/* Expand / Collapse toggle */}
|
||||
<TableCell
|
||||
padding="checkbox"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (hasLines) this.toggleExpand(order.id);
|
||||
}}
|
||||
>
|
||||
{hasLines ? (
|
||||
<IconButton size="small" aria-label="Positionen ein-/ausblenden">
|
||||
{isExpanded ? (
|
||||
<KeyboardArrowUpIcon fontSize="small" />
|
||||
) : (
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
) : null}
|
||||
</TableCell>
|
||||
|
||||
{/* Number */}
|
||||
<TableCell sx={{ fontWeight: 600 }}>{order.number}</TableCell>
|
||||
|
||||
{/* Reference / External Number */}
|
||||
<TableCell>{numberDisplay(order.externalNumber)}</TableCell>
|
||||
|
||||
{/* Date */}
|
||||
<TableCell>{formatDate(order.date)}</TableCell>
|
||||
|
||||
{/* Customer */}
|
||||
<TableCell>{this.partyName(order.entityId)}</TableCell>
|
||||
|
||||
{/* Lines summary */}
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${lines.length} Pos.`}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.75rem',
|
||||
height: 22,
|
||||
}}
|
||||
/>
|
||||
{grossTotal > 0 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.8rem', lineHeight: 1.2 }}>
|
||||
{formatPrice(grossTotal)} brutto
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||
{formatPrice(netTotal)} netto
|
||||
</Typography>
|
||||
</Box>
|
||||
) : netTotal > 0 ? (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
{formatPrice(netTotal)}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
{/* Row Action buttons */}
|
||||
<TableCell
|
||||
padding="checkbox"
|
||||
align="right"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 0.5 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`„${order.number}“ bearbeiten`}
|
||||
onClick={() => this.handleEdit(order)}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`„${order.number}“ löschen`}
|
||||
disabled={busyId != null}
|
||||
onClick={() => this.handleDelete(order)}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Collapsible Line Details Row */}
|
||||
{hasLines && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
sx={{
|
||||
p: 0,
|
||||
borderBottom: isExpanded ? undefined : 'none',
|
||||
}}
|
||||
>
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ py: 1.5, px: 2 }}>
|
||||
{this.renderLineItems(order, borderColor)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{showPagination ? (
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<ResultsPagination
|
||||
offset={offset}
|
||||
totalCount={total}
|
||||
pageSize={limit}
|
||||
pageCount={pageCount}
|
||||
currentPage={currentPage}
|
||||
onPageChange={(e, page) => this.handlePageChange(page)}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withNavigate(OrderList);
|
||||
Reference in New Issue
Block a user