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

@@ -1475,9 +1475,17 @@ export const quote = {
* Line shape same as quote (no transactedAmount).
*/
export const order = {
/** GET /api/order → { orders: OrderSummary[] } */
list() {
return http('/api/order');
/**
* GET /api/order?offset=0&limit=20
* @param {{ offset?: number, limit?: number }} [params]
* → { orders: Order[], total: number, offset: number, limit: number }
*/
list({ offset = 0, limit = 20 } = {}) {
const params = new URLSearchParams();
if (offset != null) params.set('offset', String(offset));
if (limit != null) params.set('limit', String(limit));
const qs = params.toString();
return http(`/api/order${qs ? `?${qs}` : ''}`);
},
/** GET /api/order/:id → { order: Order } */

View File

@@ -65,7 +65,12 @@ export {
} from './articleIndexAutomat';
export { default as deliveryAutomat } from './deliveryAutomat';
export { default as quoteAutomat } from './quoteAutomat';
export { default as orderAutomat } from './orderAutomat';
export {
default as orderAutomat,
selectOrders,
selectOrderTotal,
selectOrderPagination,
} from './orderAutomat';
export { default as entityAutomat } from './entityAutomat';
export { default as customergroupAutomat } from './customergroupAutomat';
export { default as numberAutomat } from './numberAutomat';

View File

@@ -1,39 +1,236 @@
/**
* @file orderAutomat.js
* @description Manages cached company sales orders (`GET /api/order`).
* Built via `createListAutomat`.
* @description Manages cached and paginated company sales orders (`GET /api/order`).
*
* Upstream Triggers:
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
* a `selectedCompanyId`, and `reset()` on `'anon'`.
* - UI / Routes: Manual calls to `load()` or `reload()`.
* - `authAutomat`: Triggers `actions.load()` on `'authed'` with a `selectedCompanyId`,
* and `actions.reset()` on `'anon'`.
* - UI / Routes: Calls to `load({ offset, limit })`, `setPage(page)`, or `reload()`.
*/
import { Automat } from 'react-automat';
import { order as orderApi } from '../api';
import { createListAutomat } from './createListAutomat';
import authAutomat from './authAutomat';
/**
* @typedef {Object} OrderSummary
* @property {string|number} id Order id.
* @typedef {Object} OrderLine
* @property {number} id
* @property {string|null} sku
* @property {string|null} name
* @property {number} pu
* @property {number} amount
* @property {number} net
* @property {number} rebate
* @property {number} taxrate
* @property {string|null} comment
*/
/**
* @typedef {Object} Order
* @property {number} id Order id.
* @property {string} number Order number.
* @property {string} type Always `'order'`.
* @property {string|number|null} entityId Customer id.
* @property {'order'} type Always `'order'`.
* @property {number|null} entityId Customer id.
* @property {string|null} externalNumber External reference.
* @property {string} date Delivery date (ISO).
* @property {string} createdAt Creation timestamp (ISO).
* @property {OrderLine[]} lines Order line items.
*/
/**
* Company sales orders (`GET /api/order`).
*
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
* @typedef {Object} OrderAutomatState
* @property {Order[]} items Loaded orders for the current page.
* @property {number} total Total unpaged count for pagination calculation.
* @property {number} offset Current 0-based offset.
* @property {number} limit Current page limit.
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
* @property {Error|string|null} error Last load error.
*/
export const orderAutomat = createListAutomat({
name: 'order',
fetch: async () => {
const data = await orderApi.list();
return data.orders ?? [];
const initialState = {
items: [],
total: 0,
offset: 0,
limit: 20,
status: 'idle',
error: null,
};
let automat;
let inflightLoad = null;
const actions = {
/**
* Loads paginated orders, deduplicating concurrent callers.
*
* @param {{ offset?: number, limit?: number }} [params]
* @returns {Promise<Order[]>}
*/
load({ offset, limit } = {}) {
const currentOffset = offset ?? automat.state.offset ?? 0;
const currentLimit = limit ?? automat.state.limit ?? 20;
if (inflightLoad) return inflightLoad;
inflightLoad = (async () => {
automat.setState({ status: 'loading', error: null });
try {
const res = await orderApi.list({ offset: currentOffset, limit: currentLimit });
const orders = res?.orders ?? [];
automat.setState({
items: orders,
total: Number(res?.total ?? orders.length),
offset: Number(res?.offset ?? currentOffset),
limit: Number(res?.limit ?? currentLimit),
status: 'ready',
error: null,
});
return orders;
} catch (error) {
automat.setState({ status: 'failed', error });
throw error;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
},
sort: (items) =>
[...items].sort((a, b) => String(b.date).localeCompare(String(a.date))),
/**
* Silently re-fetches the current page of orders without flashing loading state.
*
* @returns {Promise<Order[]>}
*/
async reload() {
const { offset, limit } = automat.state;
try {
const res = await orderApi.list({ offset, limit });
const orders = res?.orders ?? [];
automat.setState({
items: orders,
total: Number(res?.total ?? orders.length),
offset: Number(res?.offset ?? offset),
limit: Number(res?.limit ?? limit),
status: 'ready',
error: null,
});
return orders;
} catch (error) {
automat.setState({ error });
throw error;
}
},
/**
* Navigates to a 1-based page using formula: offset = (page - 1) * limit
*
* @param {number} page 1-based page number.
* @returns {Promise<Order[]>}
*/
setPage(page) {
const limit = automat.state.limit || 20;
const offset = Math.max(0, (page - 1) * limit);
return actions.load({ offset, limit });
},
/**
* Sets pagination offset directly.
*
* @param {number} offset 0-based offset.
* @returns {Promise<Order[]>}
*/
setOffset(offset) {
return actions.load({ offset, limit: automat.state.limit });
},
/**
* Sets page limit (records to return) and resets offset to 0.
*
* @param {number} limit Records per page (1..100).
* @returns {Promise<Order[]>}
*/
setLimit(limit) {
return actions.load({ offset: 0, limit });
},
/**
* Ensures orders are loaded once the selected company is known.
*
* @returns {Promise<Order[]>}
*/
ensureLoaded() {
if (automat.state.status === 'ready') {
return Promise.resolve(automat.state.items);
}
return actions.load();
},
/**
* Replaces cached orders without network round-trip.
*
* @param {Order[]} items
* @returns {Order[]}
*/
replace(items) {
automat.setState({
items,
total: items.length,
status: 'ready',
error: null,
});
return items;
},
/**
* Resets to idle state (called on logout).
*/
reset() {
inflightLoad = null;
automat.setState(initialState);
},
};
export const orderAutomat = new Automat(initialState, actions, { name: 'order' });
automat = orderAutomat;
// Load on session/company establishment, reset on logout.
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void actions.load().catch(() => {});
} else if (auth.status === 'anon') {
actions.reset();
}
});
/** Selects current orders */
export function selectOrders(state = automat.state) {
return state.items;
}
/** Selects total unpaged count */
export function selectOrderTotal(state = automat.state) {
return state.total;
}
/**
* Calculates client pagination metadata.
* Current Page: Math.floor(offset / limit) + 1
* Total Pages: Math.ceil(total / limit)
* Next Offset: (page - 1) * limit
*/
export function selectOrderPagination(state = automat.state) {
const total = state.total ?? 0;
const offset = state.offset ?? 0;
const limit = state.limit || 20;
const currentPage = Math.floor(offset / limit) + 1;
const pageCount = Math.max(1, Math.ceil(total / limit));
return {
total,
offset,
limit,
currentPage,
pageCount,
};
}
export default orderAutomat;

View File

@@ -14,6 +14,7 @@ import ManifestLines from './ManifestLines';
import ManifestPdfDialog from './ManifestPdfDialog';
import ArticleSearchDialog from './ArticleSearchDialog';
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
import { netFromGross, getArticleGross } from '../../utils/price';
import {
buildManifestBody,
restoreAttachmentsFromApi,
@@ -23,6 +24,7 @@ import {
loadParties,
manifestConfig,
apiFor,
parseNum,
} from './manifestConfig';
class ManifestForm extends PureComponent {
@@ -195,18 +197,70 @@ class ManifestForm extends PureComponent {
handleLineChange(key, field, value) {
this.setState((prev) => ({
lines: prev.lines.map((line) =>
line.key === key
? {
...line,
[field]: value,
...(field === 'net' ? { netprice: value } : {}),
...(field === 'netprice' ? { net: value } : {}),
...(field === 'tax' ? { taxrate: value } : {}),
...(field === 'taxrate' ? { tax: value } : {}),
lines: prev.lines.map((line) => {
if (line.key !== key) return line;
if (field === 'gross') {
const taxVal = parseNum(line.tax ?? line.taxrate) ?? 0;
const grossNum = parseNum(value);
let updatedNet = line.net;
if (grossNum != null) {
const netNum = netFromGross(grossNum, taxVal);
if (netNum != null) {
updatedNet = String(netNum);
}
: line,
),
} else if (value === '') {
updatedNet = '';
}
return {
...line,
gross: value,
net: updatedNet,
netprice: updatedNet,
};
}
if (field === 'net' || field === 'netprice') {
const taxVal = parseNum(line.tax ?? line.taxrate) ?? 0;
const netNum = parseNum(value);
let updatedGross = line.gross;
if (netNum != null) {
const grossNum = Math.round((netNum * (1 + taxVal / 100) + Number.EPSILON) * 100) / 100;
updatedGross = String(grossNum);
} else if (value === '') {
updatedGross = '';
}
return {
...line,
[field]: value,
net: value,
netprice: value,
gross: updatedGross,
};
}
if (field === 'tax' || field === 'taxrate') {
const taxNum = parseNum(value) ?? 0;
const netNum = parseNum(line.net ?? line.netprice);
let updatedGross = line.gross;
if (netNum != null) {
const grossNum = Math.round((netNum * (1 + taxNum / 100) + Number.EPSILON) * 100) / 100;
updatedGross = String(grossNum);
}
return {
...line,
[field]: value,
tax: value,
taxrate: value,
gross: updatedGross,
};
}
return {
...line,
[field]: value,
};
}),
}));
}
@@ -233,6 +287,16 @@ class ManifestForm extends PureComponent {
: art.tax != null
? String(art.tax)
: '';
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const artGross = getArticleGross(art, taxNum ?? 0);
let grossVal = '';
if (artGross != null) {
grossVal = String(artGross);
} else if (netNum != null) {
const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100;
grossVal = String(g);
}
return {
...l,
sku: art.sku || l.sku,
@@ -241,6 +305,7 @@ class ManifestForm extends PureComponent {
taxrate: l.taxrate || taxVal,
net: l.net || netVal,
netprice: l.netprice || netVal,
gross: l.gross || grossVal,
unit: art.unit || l.unit || 'Stück',
};
}),
@@ -271,6 +336,16 @@ class ManifestForm extends PureComponent {
: article.tax != null
? String(article.tax)
: '';
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const artGross = getArticleGross(article, taxNum ?? 0);
let grossVal = '';
if (artGross != null) {
grossVal = String(artGross);
} else if (netNum != null) {
const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100;
grossVal = String(g);
}
const newLine = {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -281,6 +356,7 @@ class ManifestForm extends PureComponent {
taxrate: taxVal,
net: netVal,
netprice: netVal,
gross: grossVal,
rebate: '',
pu: '1',
unit: article.unit || 'Stück',
@@ -332,6 +408,16 @@ class ManifestForm extends PureComponent {
: article.tax != null
? String(article.tax)
: '';
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const artGross = getArticleGross(article, taxNum ?? 0);
let grossVal = '';
if (artGross != null) {
grossVal = String(artGross);
} else if (netNum != null) {
const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100;
grossVal = String(g);
}
this.setState((prev) => ({
lines: prev.lines.map((l) => {
@@ -344,6 +430,7 @@ class ManifestForm extends PureComponent {
taxrate: taxVal || l.taxrate,
net: netVal || l.net,
netprice: netVal || l.netprice,
gross: grossVal || l.gross,
unit: article.unit || l.unit || 'Stück',
};
}),

View File

@@ -199,19 +199,49 @@ class ManifestFormLine extends PureComponent {
renderField(column, colIdx, totalCols) {
const { id, align, type } = column;
const { line, saving, onChange } = this.props;
const { line, saving, onChange, columns } = this.props;
if (id === 'pos') {
const posVal =
this.props.positionIndex ??
(this.props.index != null ? this.props.index + 1 : colIdx + 1);
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 38,
fontWeight: 700,
fontSize: '0.82rem',
color: 'text.secondary',
userSelect: 'none',
}}
>
{posVal}
</Box>
);
}
let val = line[id];
if (val == null || val === '') {
if (id === 'tax' || id === 'taxrate') {
val = line.taxrate ?? line.tax ?? '';
} else if (id === 'net' || id === 'netprice') {
val = line.netprice ?? line.net ?? '';
} else if (id === 'gross') {
const netNum = parseNum(line.netprice ?? line.net);
const taxNum = parseNum(line.taxrate ?? line.tax) ?? 0;
if (netNum != null) {
val = String(Math.round((netNum * (1 + taxNum / 100) + Number.EPSILON) * 100) / 100);
}
}
}
let isError = false;
if (id === 'name') {
isError = !String(line.name ?? '').trim();
isError = !String(line.name ?? '').trim() && !String(line.sku ?? '').trim();
} else if (id === 'amount') {
const raw = getFirstVal(line.amount, line.count);
isError = raw == null || parseNum(raw) == null;
@@ -224,11 +254,15 @@ class ManifestFormLine extends PureComponent {
} else if (id === 'net' || id === 'netprice') {
const raw = getFirstVal(line.net, line.netprice, line.netPrice, line.eknetto);
isError = raw == null || parseNum(raw) == null;
} else if (id === 'gross') {
const raw = getFirstVal(line.gross, val);
isError = raw == null || parseNum(raw) == null;
} else if (id === 'sku') {
isError = !String(line.sku ?? '').trim() && !String(line.name ?? '').trim();
}
const isFirst = colIdx === 0;
const hasPosCol = columns?.[0]?.id === 'pos';
const isFirstInput = hasPosCol ? colIdx === 1 : colIdx === 0;
const isLast = colIdx === totalCols - 1;
return (
@@ -252,8 +286,8 @@ class ManifestFormLine extends PureComponent {
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: isFirst ? '4px 0 0 4px' : isLast ? '0 4px 4px 0' : 0,
ml: isFirst ? 0 : '-1px',
borderRadius: isFirstInput ? '4px 0 0 4px' : isLast ? '0 4px 4px 0' : 0,
ml: isFirstInput ? 0 : '-1px',
'&:hover': {
zIndex: 1,
},
@@ -285,7 +319,9 @@ class ManifestFormLine extends PureComponent {
}
renderArticleSecondLine(rawSku) {
const { loading } = this.props;
const { loading, kind, columns } = this.props;
const isOrder = kind === 'order';
const hasNameColumn = columns?.some((c) => c.id === 'name');
const { cachedArticle, loadingArticle } = this.state;
if (loading || loadingArticle) {
@@ -393,8 +429,8 @@ class ManifestFormLine extends PureComponent {
/>
)}
{/* Article Name */}
{cachedArticle.name && (
{/* Article Name: omit if already editable in line 1 or if kind is order */}
{!hasNameColumn && !isOrder && cachedArticle.name && (
<Typography
variant="caption"
fontWeight={600}
@@ -406,8 +442,8 @@ class ManifestFormLine extends PureComponent {
</Typography>
)}
{/* Primary EAN */}
{primaryEan && (
{/* Primary EAN: omit for order */}
{!isOrder && primaryEan && (
<Typography
variant="caption"
color="text.secondary"
@@ -424,8 +460,8 @@ class ManifestFormLine extends PureComponent {
</Typography>
)}
{/* VK Netto & VK Brutto */}
{(vkNetto != null || vkBrutto != null) && (
{/* VK Netto & VK Brutto: omit end on 2nd line for order */}
{!isOrder && (vkNetto != null || vkBrutto != null) && (
<Box
sx={{
display: 'flex',
@@ -779,7 +815,9 @@ class ManifestFormLine extends PureComponent {
)}
<HelpOutlineIcon sx={{ fontSize: 15, color: 'warning.main' }} />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }}>
Kein Lieferanten-Artikel für SKU &bdquo;{rawSku}&ldquo; gefunden.
{this.props.kind === 'delivery'
? `Kein Lieferanten-Artikel für SKU „${rawSku}“ gefunden.`
: `Kein Artikel für SKU „${rawSku}“ gefunden.`}
</Typography>
{this.renderLedgerChip()}
</Box>

View File

@@ -8,11 +8,13 @@ import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
import SearchIcon from '@mui/icons-material/Search';
import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
import { PanelTile } from 'dashgui/panels';
import { LINE_COLUMNS, hasArticleLines } from './manifestConfig';
import { LINE_COLUMNS, hasArticleLines, parseNum, getFirstVal } from './manifestConfig';
import { formatPrice } from '../../utils/price';
import ManifestFormLine from './ManifestFormLine';
class ManifestLines extends PureComponent {
@@ -43,6 +45,21 @@ class ManifestLines extends PureComponent {
const hasArticles = hasArticleLines(lines);
let totalNet = 0;
let totalGross = 0;
if (Array.isArray(lines)) {
for (const line of lines) {
const amount = parseNum(getFirstVal(line.amount, line.count)) ?? 0;
const net = parseNum(getFirstVal(line.net, line.netprice, line.netPrice)) ?? 0;
const tax = parseNum(getFirstVal(line.tax, line.taxrate, line.taxRate)) ?? 0;
const rebate = parseNum(getFirstVal(line.rebate)) ?? 0;
const lineNet = amount * net * (1 - rebate / 100);
const lineGross = lineNet * (1 + tax / 100);
totalNet += lineNet;
totalGross += lineGross;
}
}
return (
<PanelTile
wide={wide}
@@ -102,6 +119,8 @@ class ManifestLines extends PureComponent {
return (
<ManifestFormLine
key={lineKey}
index={index}
positionIndex={index + 1}
kind={kind}
line={line.key != null ? line : { ...line, key: index }}
columns={columns}
@@ -151,6 +170,22 @@ class ManifestLines extends PureComponent {
>
Alle Zeilen entfernen
</Button>
{(totalNet > 0 || totalGross > 0) && (
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 2, pr: 0.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 600, fontSize: '0.8rem' }}>
Netto:{' '}
<Box component="span" sx={{ color: 'text.primary', fontWeight: 700 }}>
{formatPrice(totalNet)}
</Box>
</Typography>
<Typography variant="body2" sx={{ color: 'primary.dark', fontWeight: 700, fontSize: '0.85rem' }}>
Brutto:{' '}
<Box component="span" sx={{ color: 'primary.dark', fontWeight: 800 }}>
{formatPrice(totalGross)}
</Box>
</Typography>
</Box>
)}
</Box>
</Stack>
</PanelTile>

View File

@@ -6,6 +6,7 @@ import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import withNavigate from '../withNavigate';
import DeliveryList from './DeliveryList';
import OrderList from './OrderList';
import {
formatDate,
loadParties,
@@ -36,7 +37,7 @@ class ManifestList extends PureComponent {
componentDidMount() {
this._cancelled = false;
if (this.props.kind !== 'delivery') {
if (this.props.kind !== 'delivery' && this.props.kind !== 'order') {
void this.load();
}
}
@@ -122,6 +123,9 @@ class ManifestList extends PureComponent {
if (this.props.kind === 'delivery') {
return <DeliveryList {...this.props} />;
}
if (this.props.kind === 'order') {
return <OrderList {...this.props} />;
}
const { status, error, docs, busyId } = this.state;
const cfg = this.cfg;

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

View File

@@ -1,5 +1,6 @@
export { default as ManifestList } from './ManifestList';
export { default as DeliveryList } from './DeliveryList';
export { default as OrderList } from './OrderList';
export { default as ManifestForm } from './ManifestForm';
export { default as ManifestHeader } from './ManifestHeader';
export { default as ManifestLines } from './ManifestLines';
@@ -14,6 +15,7 @@ export { withManifestId } from './withManifestId';
export {
manifestConfig,
LINE_COLUMNS,
ORDER_LINE_COLUMNS,
DELIVERY_LINE_COLUMNS,
emptyLine,
lineFromApi,

View File

@@ -9,6 +9,7 @@ import customergroupAutomat from '../../automats/customergroupAutomat';
import deliveryAutomat from '../../automats/deliveryAutomat';
import quoteAutomat from '../../automats/quoteAutomat';
import orderAutomat from '../../automats/orderAutomat';
import { netFromGross } from '../../utils/price';
import {
GREEN_PANEL,
ORANGE_PANEL,
@@ -87,6 +88,7 @@ export function emptyLine() {
tax: '',
rebate: '',
net: '',
gross: '',
name: '',
pu: '',
unit: '',
@@ -98,6 +100,16 @@ export function emptyLine() {
export function lineFromApi(row) {
const netVal = row.netprice != null ? String(row.netprice) : row.net != null ? String(row.net) : '';
const taxVal = row.taxrate != null ? String(row.taxrate) : row.tax != null ? String(row.tax) : '';
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
let grossVal = '';
if (row.gross != null && row.gross !== '') {
grossVal = String(row.gross);
} else if (netNum != null) {
const rate = taxNum ?? 0;
const g = Math.round((netNum * (1 + rate / 100) + Number.EPSILON) * 100) / 100;
grossVal = String(g);
}
return {
id: row.id,
key: `line-${row.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`}`,
@@ -106,6 +118,7 @@ export function lineFromApi(row) {
tax: taxVal,
rebate: row.rebate == null ? '' : String(row.rebate),
net: netVal,
gross: grossVal,
name: row.name ?? '',
pu: row.pu == null ? '' : String(row.pu),
unit: row.unit ?? '',
@@ -136,7 +149,7 @@ export function hasArticleLines(lines) {
const sku = String(l.sku ?? '').trim();
const name = String(l.name ?? '').trim();
const amount = String(l.amount ?? '').trim();
const net = String(l.net ?? l.netprice ?? '').trim();
const net = String(l.net ?? l.netprice ?? l.gross ?? '').trim();
return Boolean(sku || name || amount || net);
});
}
@@ -153,14 +166,19 @@ export function buildLines(lines, { requireAtLeastOne }) {
const rawAmount = getFirstVal(line.amount, line.count);
const amount = parseNum(rawAmount);
const rawNet = getFirstVal(line.net, line.netprice, line.netPrice, line.eknetto);
const net = parseNum(rawNet);
let net = parseNum(rawNet);
const rawTax = getFirstVal(line.tax, line.taxrate, line.taxRate, line.mwst);
const tax = parseNum(rawTax);
const rawGross = getFirstVal(line.gross);
const gross = parseNum(rawGross);
if (net == null && gross != null && tax != null) {
net = netFromGross(gross, tax);
}
const rebate = parseNum(getFirstVal(line.rebate));
const rawPu = getFirstVal(line.pu, line.vpe);
const pu = parseNum(rawPu);
const blank =
!sku && !name && amount == null && net == null && tax == null && rebate == null;
!sku && !name && amount == null && net == null && gross == null && tax == null && rebate == null;
if (blank) continue;
const missing = [];
@@ -479,11 +497,23 @@ export async function buildManifestBody(
}
export const LINE_COLUMNS = [
{ id: 'amount', label: 'Amount', width: 88, align: 'right', type: 'number' },
{ id: 'sku', label: 'SKU', width: 140, align: 'left', type: 'text' },
{ id: 'tax', label: 'Tax', width: 80, align: 'right', type: 'number' },
{ id: 'rebate', label: 'Rebate', width: 88, align: 'right', type: 'number' },
{ id: 'net', label: 'Net', width: 104, align: 'right', type: 'number' },
{ id: 'amount', label: 'Amount', width: 80, align: 'right', type: 'number' },
{ id: 'sku', label: 'SKU', width: 130, align: 'left', type: 'text' },
{ id: 'tax', label: 'Tax', width: 70, align: 'right', type: 'number' },
{ id: 'rebate', label: 'Rebate', width: 75, align: 'right', type: 'number' },
{ id: 'net', label: 'Net', width: 95, align: 'right', type: 'number' },
{ id: 'gross', label: 'Gross', width: 95, align: 'right', type: 'number' },
];
export const ORDER_LINE_COLUMNS = [
{ id: 'pos', label: 'Pos', width: 44, align: 'center' },
{ id: 'sku', label: 'SKU', width: 130, align: 'left', type: 'text' },
{ id: 'name', label: 'Name', width: 200, align: 'left', type: 'text' },
{ id: 'amount', label: 'Amount', width: 80, align: 'right', type: 'number' },
{ id: 'tax', label: 'Tax', width: 70, align: 'right', type: 'number' },
{ id: 'rebate', label: 'Rebate', width: 75, align: 'right', type: 'number' },
{ id: 'net', label: 'Net', width: 95, align: 'right', type: 'number' },
{ id: 'gross', label: 'Gross', width: 95, align: 'right', type: 'number' },
];
export const DELIVERY_LINE_COLUMNS = [
@@ -506,7 +536,8 @@ export const DELIVERY_LINE_COLUMNS = [
* @param {ManifestKind} kind Manifest kind.
* @returns {{get: Function, create: Function, update: Function, delete: Function}}
*/
export function apiFor(kind) {
export function apiFor(cfgOrKind) {
const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind;
if (kind === 'delivery') return deliveryApi;
if (kind === 'quote') return quoteApi;
if (kind === 'order') return orderApi;
@@ -515,9 +546,10 @@ export function apiFor(kind) {
/**
* Load party options for Autocomplete / list name lookup.
* @param {ManifestKind} kind
* @param {ManifestKind|object} cfgOrKind
*/
export async function loadParties(kind) {
export async function loadParties(cfgOrKind) {
const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind;
if (kind === 'delivery') {
if (supplierAutomat.state.status === 'ready') {
return sortSuppliers(supplierAutomat.state.suppliers);
@@ -532,16 +564,21 @@ export async function loadParties(kind) {
return filterCustomerEntities(entities, groups);
}
export function partyDisplayName(kind, party) {
export function partyDisplayName(cfgOrKind, party) {
if (!party) return '';
const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind;
if (kind === 'delivery') return String(party.name ?? '');
return entityDisplayName(party);
}
/**
* @param {ManifestKind} kind
* @param {ManifestKind|object} cfgOrKind
*/
export function manifestConfig(kind) {
export function manifestConfig(cfgOrKind) {
if (cfgOrKind && typeof cfgOrKind === 'object' && cfgOrKind.kind && cfgOrKind.api) {
return cfgOrKind;
}
const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind;
if (kind === 'delivery') {
return {
kind: 'delivery',
@@ -641,7 +678,7 @@ export function manifestConfig(kind) {
listAutomat: orderAutomat,
partiesByIdFromList: (parties) =>
Object.fromEntries(parties.map((row) => [row.id, row])),
columns: LINE_COLUMNS,
columns: ORDER_LINE_COLUMNS,
};
}
throw new Error(`Unknown manifest kind: ${kind}`);

View File

@@ -1,9 +1,9 @@
import { PureComponent } from 'react';
import { ManifestList } from '../../components/manifest';
import { OrderList } from '../../components/manifest';
class AuftragBestellungPage extends PureComponent {
render() {
return <ManifestList kind="order" />;
return <OrderList />;
}
}