u
This commit is contained in:
@@ -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 } */
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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 „{rawSku}“ gefunden.
|
||||
{this.props.kind === 'delivery'
|
||||
? `Kein Lieferanten-Artikel für SKU „${rawSku}“ gefunden.`
|
||||
: `Kein Artikel für SKU „${rawSku}“ gefunden.`}
|
||||
</Typography>
|
||||
{this.renderLedgerChip()}
|
||||
</Box>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user