diff --git a/src/api/index.js b/src/api/index.js index 61b00bf..8e719e9 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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 } */ diff --git a/src/automats/index.js b/src/automats/index.js index 6f2b3cb..456c8a1 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -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'; diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js index 3792b48..4e2caf9 100644 --- a/src/automats/orderAutomat.js +++ b/src/automats/orderAutomat.js @@ -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} + * @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} + */ + 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} + */ + 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} + */ + 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} + */ + 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} + */ + setLimit(limit) { + return actions.load({ offset: 0, limit }); + }, + + /** + * Ensures orders are loaded once the selected company is known. + * + * @returns {Promise} + */ + 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; diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index 30da787..7ee6fd1 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -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', }; }), diff --git a/src/components/manifest/ManifestFormLine.jsx b/src/components/manifest/ManifestFormLine.jsx index d87f6f1..92fd554 100644 --- a/src/components/manifest/ManifestFormLine.jsx +++ b/src/components/manifest/ManifestFormLine.jsx @@ -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 ( + + {posVal} + + ); + } + 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 && ( )} - {/* Primary EAN */} - {primaryEan && ( + {/* Primary EAN: omit for order */} + {!isOrder && primaryEan && ( )} - {/* VK Netto & VK Brutto */} - {(vkNetto != null || vkBrutto != null) && ( + {/* VK Netto & VK Brutto: omit end on 2nd line for order */} + {!isOrder && (vkNetto != null || vkBrutto != null) && ( - 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.`} {this.renderLedgerChip()} diff --git a/src/components/manifest/ManifestLines.jsx b/src/components/manifest/ManifestLines.jsx index 671dc30..5025137 100644 --- a/src/components/manifest/ManifestLines.jsx +++ b/src/components/manifest/ManifestLines.jsx @@ -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 ( Alle Zeilen entfernen + {(totalNet > 0 || totalGross > 0) && ( + + + Netto:{' '} + + {formatPrice(totalNet)} + + + + Brutto:{' '} + + {formatPrice(totalGross)} + + + + )} diff --git a/src/components/manifest/ManifestList.jsx b/src/components/manifest/ManifestList.jsx index eb0e39a..ba610cb 100644 --- a/src/components/manifest/ManifestList.jsx +++ b/src/components/manifest/ManifestList.jsx @@ -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 ; } + if (this.props.kind === 'order') { + return ; + } const { status, error, docs, busyId } = this.state; const cfg = this.cfg; diff --git a/src/components/manifest/OrderList.jsx b/src/components/manifest/OrderList.jsx new file mode 100644 index 0000000..0795e8f --- /dev/null +++ b/src/components/manifest/OrderList.jsx @@ -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 ( + + + + ); + } + + renderLineItems(order, borderColor) { + const lines = order.lines ?? []; + if (lines.length === 0) { + return ( + + + Keine Positionen vorhanden. + + + ); + } + + const netTotal = calculateOrderTotal(lines); + const grossTotal = calculateOrderGrossTotal(lines); + + return ( + + + + + + Pos + + SKU + Artikel + + Menge + + + Netto + + + Brutto + + + Rabatt + + + MwSt. + + + Gesamt netto + + + Gesamt brutto + + + + + {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 ( + + + {idx + 1} + + + {line.sku || '–'} + + + + {line.name || '–'} + + {line.comment ? ( + + {line.comment} + + ) : null} + + + {amount} + {puText} + + + {formatPrice(net)} + + + {formatPrice(gross)} + + + {rebate > 0 ? `${rebate}%` : '–'} + + + {line.taxrate != null ? `${line.taxrate}%` : '–'} + + + {formatPrice(lineNet)} + + + {formatPrice(lineGross)} + + + ); + })} + + + td': { borderTop: '2px solid', borderColor, fontWeight: 700 } }}> + + Gesamtsumme: + + + {formatPrice(netTotal)} + + + {formatPrice(grossTotal)} + + + +
+
+ ); + } + + 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 ( + + ); + } + + 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 ( + + + + {/* Create Header Button */} + + + + + {/* Empty State */} + {orders.length === 0 ? ( + + {cfg.emptyList} + + ) : ( + + + + + + Nummer + Referenz + {cfg.dateLabel} + {cfg.partyLabel} + Positionen + + + + + {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 ( + + this.handleEdit(order)} + > + {/* Expand / Collapse toggle */} + { + e.stopPropagation(); + if (hasLines) this.toggleExpand(order.id); + }} + > + {hasLines ? ( + + {isExpanded ? ( + + ) : ( + + )} + + ) : null} + + + {/* Number */} + {order.number} + + {/* Reference / External Number */} + {numberDisplay(order.externalNumber)} + + {/* Date */} + {formatDate(order.date)} + + {/* Customer */} + {this.partyName(order.entityId)} + + {/* Lines summary */} + + + + {grossTotal > 0 ? ( + + + {formatPrice(grossTotal)} brutto + + + {formatPrice(netTotal)} netto + + + ) : netTotal > 0 ? ( + + {formatPrice(netTotal)} + + ) : null} + + + + {/* Row Action buttons */} + e.stopPropagation()} + > + + this.handleEdit(order)} + > + + + this.handleDelete(order)} + > + + + + + + + {/* Collapsible Line Details Row */} + {hasLines && ( + + + + + {this.renderLineItems(order, borderColor)} + + + + + )} + + ); + })} + +
+
+ )} + + {/* Pagination Controls */} + {showPagination ? ( + + this.handlePageChange(page)} + /> + + ) : null} +
+
+
+ ); + } +} + +export default withNavigate(OrderList); diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js index d8b44d4..6ce2b55 100644 --- a/src/components/manifest/index.js +++ b/src/components/manifest/index.js @@ -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, diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js index 25b31af..4c7fb9a 100644 --- a/src/components/manifest/manifestConfig.js +++ b/src/components/manifest/manifestConfig.js @@ -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}`); diff --git a/src/routes/auftrag/bestellung.jsx b/src/routes/auftrag/bestellung.jsx index 2a4cc5d..e0c213f 100644 --- a/src/routes/auftrag/bestellung.jsx +++ b/src/routes/auftrag/bestellung.jsx @@ -1,9 +1,9 @@ import { PureComponent } from 'react'; -import { ManifestList } from '../../components/manifest'; +import { OrderList } from '../../components/manifest'; class AuftragBestellungPage extends PureComponent { render() { - return ; + return ; } }