This commit is contained in:
seb
2026-10-08 22:44:51 +02:00
parent 22063e6944
commit ee93f71b27
11 changed files with 1047 additions and 67 deletions

View 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);