u
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"cert": "./scripts/generate-cert.sh",
|
"cert": "./scripts/generate-cert.sh",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"preview:https": "VITE_HTTPS=1 vite preview",
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -192,6 +192,22 @@ export class ApiClient {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.ledger = {
|
||||||
|
/** POST /api/ledger → 201 { ledger: LedgerEntry } */
|
||||||
|
add: (body) => this.#json('POST', '/api/ledger', { body }),
|
||||||
|
/** GET /api/ledger/manifestarticle/{id} → { ledger: LedgerEntry[] } */
|
||||||
|
listByManifestarticleId: (manifestarticleId, transactionType) =>
|
||||||
|
this.#json('GET', `/api/ledger/manifestarticle/${enc(manifestarticleId)}`, {
|
||||||
|
query: transactionType ? { transaction_type: transactionType } : undefined,
|
||||||
|
}),
|
||||||
|
/** GET /api/ledger/sku/{sku} → { ledger: LedgerEntry[] } */
|
||||||
|
listBySku: (sku, transactionType) =>
|
||||||
|
this.#json('GET', `/api/ledger/sku/${enc(sku)}`, {
|
||||||
|
query: transactionType ? { transaction_type: transactionType } : undefined,
|
||||||
|
}),
|
||||||
|
/** GET /api/ledger → { ledger: LedgerEntry[] } */
|
||||||
|
list: (query) => this.#json('GET', '/api/ledger', { query }),
|
||||||
|
};
|
||||||
|
|
||||||
this.quote = {
|
this.quote = {
|
||||||
/** GET /api/quote → { quotes } */
|
/** GET /api/quote → { quotes } */
|
||||||
|
|||||||
@@ -782,6 +782,58 @@ export const delivery = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only stock and payment ledger.
|
||||||
|
* Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }
|
||||||
|
* Input = { manifestarticle_id, transaction_type, amount, transaction_text }
|
||||||
|
*/
|
||||||
|
export const ledger = {
|
||||||
|
/**
|
||||||
|
* POST /api/ledger
|
||||||
|
* Body: { manifestarticle_id, transaction_type, amount, transaction_text }
|
||||||
|
* → 201 { ledger: LedgerEntry }
|
||||||
|
*/
|
||||||
|
add(body) {
|
||||||
|
return http('/api/ledger', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ledger/manifestarticle/:manifestarticleId
|
||||||
|
* Query: ?transaction_type=...
|
||||||
|
* → 200 { ledger: LedgerEntry[] }
|
||||||
|
*/
|
||||||
|
listByManifestarticleId(manifestarticleId, transactionType) {
|
||||||
|
const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : '';
|
||||||
|
return http(`/api/ledger/manifestarticle/${encodeURIComponent(manifestarticleId)}${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ledger/sku/:sku
|
||||||
|
* Query: ?transaction_type=...
|
||||||
|
* → 200 { ledger: LedgerEntry[] }
|
||||||
|
*/
|
||||||
|
listBySku(sku, transactionType) {
|
||||||
|
const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : '';
|
||||||
|
return http(`/api/ledger/sku/${encodeURIComponent(sku)}${query}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ledger
|
||||||
|
* Query: { manifestarticle_id?, sku?, transaction_type? }
|
||||||
|
* → 200 { ledger: LedgerEntry[] }
|
||||||
|
*/
|
||||||
|
list(query) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query?.manifestarticle_id != null) params.set('manifestarticle_id', query.manifestarticle_id);
|
||||||
|
if (query?.manifestarticleId != null) params.set('manifestarticle_id', query.manifestarticleId);
|
||||||
|
if (query?.sku != null) params.set('sku', query.sku);
|
||||||
|
if (query?.transaction_type != null) params.set('transaction_type', query.transaction_type);
|
||||||
|
if (query?.transactionType != null) params.set('transaction_type', query.transactionType);
|
||||||
|
const qs = params.toString();
|
||||||
|
return http(`/api/ledger${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sales quotes (manifest type = quote).
|
* Sales quotes (manifest type = quote).
|
||||||
* Summary = { id, number, type: 'quote', entityId, date }
|
* Summary = { id, number, type: 'quote', entityId, date }
|
||||||
@@ -1204,6 +1256,7 @@ export default {
|
|||||||
customergroup,
|
customergroup,
|
||||||
delivery,
|
delivery,
|
||||||
entity,
|
entity,
|
||||||
|
ledger,
|
||||||
number,
|
number,
|
||||||
order,
|
order,
|
||||||
posterminal,
|
posterminal,
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ import { article as articleApi, supplier as supplierApi } from '../api';
|
|||||||
* @returns {DeliveryLine} Normalized line.
|
* @returns {DeliveryLine} Normalized line.
|
||||||
*/
|
*/
|
||||||
export function createEmptyLineitem(line = {}) {
|
export function createEmptyLineitem(line = {}) {
|
||||||
|
const netVal = line.netprice ?? line.net ?? null;
|
||||||
|
const taxVal = line.taxrate ?? line.tax ?? null;
|
||||||
return {
|
return {
|
||||||
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
name: line.name ?? null,
|
name: line.name ?? null,
|
||||||
@@ -62,8 +64,10 @@ export function createEmptyLineitem(line = {}) {
|
|||||||
amount: line.amount ?? null,
|
amount: line.amount ?? null,
|
||||||
pu: line.pu ?? null,
|
pu: line.pu ?? null,
|
||||||
unit: line.unit ?? null,
|
unit: line.unit ?? null,
|
||||||
netprice: line.netprice ?? null,
|
netprice: netVal,
|
||||||
taxrate: line.taxrate ?? null,
|
net: netVal,
|
||||||
|
taxrate: taxVal,
|
||||||
|
tax: taxVal,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +361,20 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
automat.actions.removeLineitem(payload);
|
automat.actions.removeLineitem(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Clears all lines from the editor. @returns {void} */
|
||||||
|
clearLineitems() {
|
||||||
|
automat.setState({
|
||||||
|
lines: [],
|
||||||
|
lineitems: [],
|
||||||
|
supplierArticlesByLineKey: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Clears all lines from the editor. @returns {void} */
|
||||||
|
clearLines() {
|
||||||
|
automat.actions.clearLineitems();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads supplier articles for all current SKUs and rebuilds line matches.
|
* Loads supplier articles for all current SKUs and rebuilds line matches.
|
||||||
* @param {string|number|null} [targetEntityId] Supplier to query.
|
* @param {string|number|null} [targetEntityId] Supplier to query.
|
||||||
@@ -658,6 +676,8 @@ export const {
|
|||||||
updateLine,
|
updateLine,
|
||||||
removeLineitem,
|
removeLineitem,
|
||||||
removeLine,
|
removeLine,
|
||||||
|
clearLineitems,
|
||||||
|
clearLines,
|
||||||
fetchSupplierArticles,
|
fetchSupplierArticles,
|
||||||
fetchSupplierArticleForSku,
|
fetchSupplierArticleForSku,
|
||||||
unlinkSupplierArticle,
|
unlinkSupplierArticle,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SyncIcon from '@mui/icons-material/Sync';
|
|||||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
||||||
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
|
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
|
||||||
import { generateTextThumbnail } from '../../utils/textThumbnail';
|
import { generateTextThumbnail } from '../../utils/textThumbnail';
|
||||||
|
import { notifyErrorMessage } from '../../utils/notify';
|
||||||
|
|
||||||
export class Attachment extends PureComponent {
|
export class Attachment extends PureComponent {
|
||||||
state = {
|
state = {
|
||||||
@@ -47,6 +48,12 @@ export class Attachment extends PureComponent {
|
|||||||
|
|
||||||
handleRescan = (e) => {
|
handleRescan = (e) => {
|
||||||
e?.stopPropagation?.();
|
e?.stopPropagation?.();
|
||||||
|
if (this.props.hasArticles) {
|
||||||
|
notifyErrorMessage(
|
||||||
|
'Neu analysieren nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { attachment, onRescan } = this.props;
|
const { attachment, onRescan } = this.props;
|
||||||
if (attachment && onRescan) {
|
if (attachment && onRescan) {
|
||||||
onRescan(attachment);
|
onRescan(attachment);
|
||||||
@@ -90,7 +97,7 @@ export class Attachment extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { attachment, disabled, sx } = this.props;
|
const { attachment, disabled, sx, hasArticles } = this.props;
|
||||||
const thumbnail = attachment?.thumbnail || this.state.generatedThumbnail;
|
const thumbnail = attachment?.thumbnail || this.state.generatedThumbnail;
|
||||||
const name = attachment?.name || 'Dokument';
|
const name = attachment?.name || 'Dokument';
|
||||||
const size = attachment?.size;
|
const size = attachment?.size;
|
||||||
@@ -211,18 +218,39 @@ export class Attachment extends PureComponent {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{isPdf || isText ? (
|
{isPdf || isText ? (
|
||||||
<Tooltip title="Neu analysieren">
|
<Tooltip
|
||||||
<span>
|
title={
|
||||||
|
hasArticles
|
||||||
|
? 'Neu analysieren nicht möglich, solange Artikel vorhanden sind'
|
||||||
|
: 'Neu analysieren'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ display: 'inline-block' }}
|
||||||
|
onClick={
|
||||||
|
hasArticles
|
||||||
|
? (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
notifyErrorMessage(
|
||||||
|
'Neu analysieren nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
disabled={disabled}
|
disabled={disabled || hasArticles}
|
||||||
onClick={this.handleRescan}
|
onClick={this.handleRescan}
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||||||
color: '#fff',
|
color: hasArticles ? 'rgba(255, 255, 255, 0.3)' : '#fff',
|
||||||
p: 0.5,
|
p: 0.5,
|
||||||
backdropFilter: 'blur(2px)',
|
backdropFilter: 'blur(2px)',
|
||||||
'&:hover': { bgcolor: 'rgba(0, 0, 0, 0.85)' },
|
cursor: hasArticles ? 'not-allowed' : 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: hasArticles ? 'rgba(0, 0, 0, 0.6)' : 'rgba(0, 0, 0, 0.85)',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SyncIcon sx={{ fontSize: 16 }} />
|
<SyncIcon sx={{ fontSize: 16 }} />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Attachment from './Attachment';
|
|||||||
|
|
||||||
export class AttachmentList extends PureComponent {
|
export class AttachmentList extends PureComponent {
|
||||||
render() {
|
render() {
|
||||||
const { attachments, onRescan, onDelete, onClick, disabled } = this.props;
|
const { attachments, onRescan, onDelete, onClick, disabled, hasArticles } = this.props;
|
||||||
if (!Array.isArray(attachments) || attachments.length === 0) {
|
if (!Array.isArray(attachments) || attachments.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ export class AttachmentList extends PureComponent {
|
|||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
sx={{ mb: 1, display: 'block', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
sx={{ mb: 1, display: 'block', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||||
>
|
>
|
||||||
Dateianhänge ({attachments.length})
|
Dateianhänge
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1.5} sx={{ flexWrap: 'wrap', gap: 1.5 }}>
|
<Stack direction="row" spacing={1.5} sx={{ flexWrap: 'wrap', gap: 1.5 }}>
|
||||||
{attachments.map((attachment, index) => (
|
{attachments.map((attachment, index) => (
|
||||||
@@ -30,6 +30,7 @@ export class AttachmentList extends PureComponent {
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
hasArticles={hasArticles}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
589
src/components/manifest/BookDeliveryDialog.jsx
Normal file
589
src/components/manifest/BookDeliveryDialog.jsx
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
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 TableHead from '@mui/material/TableHead';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
|
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
|
import { formatDate } from './manifestConfig';
|
||||||
|
import {
|
||||||
|
fetchDeliveryLedgerSummary,
|
||||||
|
bookDeliveryLines,
|
||||||
|
} from '../../utils/deliveryLedger';
|
||||||
|
|
||||||
|
export default class BookDeliveryDialog extends PureComponent {
|
||||||
|
static contextType = StorageManagerContext;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
let initialAmounts = {};
|
||||||
|
if (Array.isArray(props.summary?.lines)) {
|
||||||
|
for (const line of props.summary.lines) {
|
||||||
|
if (props.prefillLineId != null) {
|
||||||
|
initialAmounts[line.id] =
|
||||||
|
line.id === props.prefillLineId
|
||||||
|
? line.remainingAmount > 0
|
||||||
|
? line.remainingAmount
|
||||||
|
: 0
|
||||||
|
: 0;
|
||||||
|
} else {
|
||||||
|
initialAmounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
summary: props.summary ?? null,
|
||||||
|
loading: !props.summary,
|
||||||
|
error: null,
|
||||||
|
amountsToBook: initialAmounts,
|
||||||
|
transactionText: props.delivery?.number
|
||||||
|
? `Lieferung ${props.delivery.number}`
|
||||||
|
: 'Lieferung Wareneingang',
|
||||||
|
isBooking: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleAmountChange = this.handleAmountChange.bind(this);
|
||||||
|
this.handleTransactionTextChange = this.handleTransactionTextChange.bind(this);
|
||||||
|
this.handleBookAll = this.handleBookAll.bind(this);
|
||||||
|
this.handleResetAmounts = this.handleResetAmounts.bind(this);
|
||||||
|
this.handleConfirmBook = this.handleConfirmBook.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (this.props.open) {
|
||||||
|
void this.loadSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (this.props.open && !prevProps.open) {
|
||||||
|
this.setState(
|
||||||
|
{
|
||||||
|
summary: this.props.summary ?? null,
|
||||||
|
loading: !this.props.summary,
|
||||||
|
error: null,
|
||||||
|
transactionText: this.props.delivery?.number
|
||||||
|
? `Lieferung ${this.props.delivery.number}`
|
||||||
|
: 'Lieferung Wareneingang',
|
||||||
|
isBooking: false,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (this.props.summary) {
|
||||||
|
this.initAmountsFromSummary(this.props.summary, this.props.prefillLineId);
|
||||||
|
} else {
|
||||||
|
void this.loadSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
this.props.open &&
|
||||||
|
(this.props.prefillLineId !== prevProps.prefillLineId ||
|
||||||
|
(this.props.summary && this.props.summary !== prevProps.summary)) &&
|
||||||
|
!this.state.isBooking
|
||||||
|
) {
|
||||||
|
const activeSummary = this.state.summary || this.props.summary;
|
||||||
|
if (activeSummary) {
|
||||||
|
this.initAmountsFromSummary(activeSummary, this.props.prefillLineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSummary() {
|
||||||
|
const { delivery, prefillLineId } = this.props;
|
||||||
|
if (!delivery?.id) return;
|
||||||
|
|
||||||
|
this.setState({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const ledgerApi = this.context?.ledger;
|
||||||
|
const deliveryApi = this.context?.delivery;
|
||||||
|
const summary = await fetchDeliveryLedgerSummary(delivery.id, {
|
||||||
|
ledgerApi,
|
||||||
|
deliveryApi,
|
||||||
|
});
|
||||||
|
this.initAmountsFromSummary(summary, prefillLineId);
|
||||||
|
this.setState({ summary, loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BookDeliveryDialog] Failed to load ledger summary:', err);
|
||||||
|
this.setState({ error: err.message || 'Laden fehlgeschlagen', loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initAmountsFromSummary(summary, prefillLineId = this.props.prefillLineId) {
|
||||||
|
const amounts = {};
|
||||||
|
if (Array.isArray(summary?.lines)) {
|
||||||
|
for (const line of summary.lines) {
|
||||||
|
if (prefillLineId != null) {
|
||||||
|
amounts[line.id] =
|
||||||
|
line.id === prefillLineId
|
||||||
|
? line.remainingAmount > 0
|
||||||
|
? line.remainingAmount
|
||||||
|
: 0
|
||||||
|
: 0;
|
||||||
|
} else {
|
||||||
|
amounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setState({ amountsToBook: amounts });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAmountChange(lineId, value) {
|
||||||
|
const num = Math.max(0, Number(value) || 0);
|
||||||
|
this.setState((prev) => ({
|
||||||
|
amountsToBook: {
|
||||||
|
...prev.amountsToBook,
|
||||||
|
[lineId]: num,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTransactionTextChange(e) {
|
||||||
|
this.setState({ transactionText: e.target.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBookAll() {
|
||||||
|
const { summary } = this.state;
|
||||||
|
if (!summary?.lines) return;
|
||||||
|
const amounts = {};
|
||||||
|
for (const line of summary.lines) {
|
||||||
|
amounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0;
|
||||||
|
}
|
||||||
|
this.setState({ amountsToBook: amounts });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResetAmounts() {
|
||||||
|
const { summary } = this.state;
|
||||||
|
if (!summary?.lines) return;
|
||||||
|
const amounts = {};
|
||||||
|
for (const line of summary.lines) {
|
||||||
|
amounts[line.id] = 0;
|
||||||
|
}
|
||||||
|
this.setState({ amountsToBook: amounts });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleConfirmBook() {
|
||||||
|
const { delivery, onBookSuccess, onClose } = this.props;
|
||||||
|
const { summary, amountsToBook, transactionText } = this.state;
|
||||||
|
if (!summary?.lines) return;
|
||||||
|
|
||||||
|
const linesToBook = summary.lines
|
||||||
|
.map((line) => ({
|
||||||
|
manifestarticleId: line.id,
|
||||||
|
sku: line.sku,
|
||||||
|
name: line.name,
|
||||||
|
amount: Number(amountsToBook[line.id] || 0),
|
||||||
|
}))
|
||||||
|
.filter((l) => l.amount > 0);
|
||||||
|
|
||||||
|
if (linesToBook.length === 0) {
|
||||||
|
enqueueSnackbar('Keine Mengen zum Buchen angegeben.', { variant: 'info' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ isBooking: true, error: null });
|
||||||
|
try {
|
||||||
|
const ledgerApi = this.context?.ledger;
|
||||||
|
const deliveryApi = this.context?.delivery;
|
||||||
|
|
||||||
|
await bookDeliveryLines({
|
||||||
|
delivery: summary.delivery || delivery,
|
||||||
|
linesToBook,
|
||||||
|
ledgerApi,
|
||||||
|
transactionText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalBookedQty = linesToBook.reduce((sum, l) => sum + l.amount, 0);
|
||||||
|
enqueueSnackbar(
|
||||||
|
`${linesToBook.length} Position${linesToBook.length === 1 ? '' : 'en'} (${totalBookedQty} Stk.) erfolgreich ins Lager gebucht.`,
|
||||||
|
{ variant: 'success' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh summary
|
||||||
|
const updatedSummary = await fetchDeliveryLedgerSummary(delivery.id, {
|
||||||
|
ledgerApi,
|
||||||
|
deliveryApi,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onBookSuccess) {
|
||||||
|
onBookSuccess(updatedSummary);
|
||||||
|
}
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BookDeliveryDialog] Booking failed:', err);
|
||||||
|
notifyError(err);
|
||||||
|
this.setState({
|
||||||
|
error: err.message || 'Buchung fehlgeschlagen',
|
||||||
|
isBooking: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeaderInfo() {
|
||||||
|
const { delivery, partyName } = this.props;
|
||||||
|
const { summary } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
mb: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 3,
|
||||||
|
backgroundColor: '#f8fafc',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Nummer
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{delivery?.number || '–'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{delivery?.externalNumber && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Referenz
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{delivery.externalNumber}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Lieferdatum
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{formatDate(delivery?.date)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{partyName && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Lieferant
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{partyName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Aktueller Status:
|
||||||
|
</Typography>
|
||||||
|
{summary?.status === 'booked' && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<CheckCircleOutlineOutlinedIcon />}
|
||||||
|
label="Vollständig gebucht"
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{summary?.status === 'partial' && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<Inventory2OutlinedIcon />}
|
||||||
|
label={`Teilweise gebucht (${summary.bookedPositions}/${summary.totalPositions})`}
|
||||||
|
color="warning"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{summary?.status === 'unbooked' && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`Offen (${summary.totalPositions} Pos.)`}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ color: 'text.secondary', borderColor: 'divider' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{summary?.status === 'empty' && (
|
||||||
|
<Chip size="small" label="Keine Positionen" variant="outlined" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLinesTable() {
|
||||||
|
const { summary, amountsToBook, isBooking } = this.state;
|
||||||
|
const lines = summary?.lines || [];
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert severity="info" sx={{ my: 2 }}>
|
||||||
|
Diese Lieferung enthält keine Artikelpositionen zum Einbuchen.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableContainer component={Paper} variant="outlined" sx={{ mb: 2 }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead sx={{ backgroundColor: '#f1f5f9' }}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>SKU</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Artikel</TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontWeight: 600 }}>Liefermenge</TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontWeight: 600 }}>Bereits gebucht</TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontWeight: 600, width: 140 }}>Jetzt einbuchen</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{lines.map((line) => {
|
||||||
|
const toBook = amountsToBook[line.id] ?? 0;
|
||||||
|
const isAlreadyFullyBooked = line.isBooked && line.remainingAmount <= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={line.id} hover>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||||
|
{line.sku || '–'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ maxWidth: 280 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
|
||||||
|
{line.name || '–'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{line.targetAmount}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-end' }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color={line.bookedAmount > 0 ? 'success.main' : 'text.secondary'}
|
||||||
|
sx={{ fontWeight: line.bookedAmount > 0 ? 600 : 400 }}
|
||||||
|
>
|
||||||
|
{line.bookedAmount}
|
||||||
|
</Typography>
|
||||||
|
{isAlreadyFullyBooked && (
|
||||||
|
<CheckCircleOutlineOutlinedIcon color="success" sx={{ fontSize: 16 }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{isAlreadyFullyBooked ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label="Gebucht"
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: '0.75rem', height: 24 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
value={toBook}
|
||||||
|
onChange={(e) => this.handleAmountChange(line.id, e.target.value)}
|
||||||
|
disabled={isBooking}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
inputProps: {
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
style: { textAlign: 'right', padding: '4px 8px' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{ width: 90 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { open, onClose, delivery } = this.props;
|
||||||
|
const {
|
||||||
|
summary,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
amountsToBook,
|
||||||
|
transactionText,
|
||||||
|
isBooking,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
const lines = summary?.lines || [];
|
||||||
|
const prefillLine =
|
||||||
|
this.props.prefillLineId != null
|
||||||
|
? lines.find((l) => l.id === this.props.prefillLineId)
|
||||||
|
: null;
|
||||||
|
const totalToBook = lines.reduce(
|
||||||
|
(sum, l) => sum + (Number(amountsToBook[l.id]) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const countLinesToBook = lines.filter(
|
||||||
|
(l) => Number(amountsToBook[l.id]) > 0
|
||||||
|
).length;
|
||||||
|
const isFullyBooked = summary?.status === 'booked';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(open)}
|
||||||
|
onClose={isBooking ? undefined : onClose}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
pb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Inventory2OutlinedIcon color="primary" />
|
||||||
|
<Typography variant="h6" component="span" sx={{ fontWeight: 600 }}>
|
||||||
|
Lieferung {delivery?.number || ''} ins Lager buchen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="small" onClick={onClose} disabled={isBooking}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers sx={{ pt: 2 }}>
|
||||||
|
{this.renderHeaderInfo()}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6, gap: 2, alignItems: 'center' }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
<Typography color="text.secondary">Lade Positionen und Ledger-Status...</Typography>
|
||||||
|
</Box>
|
||||||
|
) : error ? (
|
||||||
|
<Alert severity="error" sx={{ my: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{prefillLine && (
|
||||||
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
|
Vorausgewählt zum Einbuchen: Position{' '}
|
||||||
|
<strong>{prefillLine.sku ? `SKU ${prefillLine.sku}` : prefillLine.name}</strong> ({amountsToBook[prefillLine.id] || 0} Stk.). Andere Positionen stehen auf 0.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{this.renderLinesTable()}
|
||||||
|
|
||||||
|
{!isFullyBooked && lines.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
onClick={this.handleBookAll}
|
||||||
|
disabled={isBooking}
|
||||||
|
>
|
||||||
|
Alle offenen Mengen übernehmen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="inherit"
|
||||||
|
onClick={this.handleResetAmounts}
|
||||||
|
disabled={isBooking}
|
||||||
|
>
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Buchungstext (Ledger-Referenz)"
|
||||||
|
value={transactionText}
|
||||||
|
onChange={this.handleTransactionTextChange}
|
||||||
|
disabled={isBooking || isFullyBooked}
|
||||||
|
helperText="Wird als Transaktionstext in das Ledger übernommen"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isFullyBooked ? (
|
||||||
|
<Alert severity="success" icon={<CheckCircleOutlineOutlinedIcon />}>
|
||||||
|
Alle Artikel dieser Lieferung sind bereits vollständig im Lager verbucht.
|
||||||
|
</Alert>
|
||||||
|
) : totalToBook > 0 ? (
|
||||||
|
<Alert severity="info">
|
||||||
|
Es werden <strong>{totalToBook} Stück</strong> für{' '}
|
||||||
|
<strong>{countLinesToBook} Position{countLinesToBook === 1 ? '' : 'en'}</strong> als Lagerzugang (DELIVERY) im Ledger gebucht.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert severity="warning">
|
||||||
|
Wählen Sie mindestens eine Menge größer als 0 aus, um den Lagerzugang zu buchen.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
|
<Button onClick={onClose} disabled={isBooking} color="inherit">
|
||||||
|
{isFullyBooked ? 'Schließen' : 'Abbrechen'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!isFullyBooked && lines.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={this.handleConfirmBook}
|
||||||
|
disabled={isBooking || totalToBook <= 0 || loading}
|
||||||
|
startIcon={
|
||||||
|
isBooking ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<Inventory2OutlinedIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{ minWidth: 160, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{isBooking ? 'Wird gebucht...' : 'Jetzt ins Lager buchen'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ class ManifestForm extends PureComponent {
|
|||||||
this.handleLineChange = this.handleLineChange.bind(this);
|
this.handleLineChange = this.handleLineChange.bind(this);
|
||||||
this.handleAddLine = this.handleAddLine.bind(this);
|
this.handleAddLine = this.handleAddLine.bind(this);
|
||||||
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
||||||
|
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
||||||
this.handleSave = this.handleSave.bind(this);
|
this.handleSave = this.handleSave.bind(this);
|
||||||
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
||||||
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
||||||
@@ -215,6 +216,10 @@ class ManifestForm extends PureComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleRemoveAllLines() {
|
||||||
|
this.setState({ lines: [] });
|
||||||
|
}
|
||||||
|
|
||||||
async handlePdfUpload(file) {
|
async handlePdfUpload(file) {
|
||||||
if (!file || this.state.pdfUploading) return;
|
if (!file || this.state.pdfUploading) return;
|
||||||
if (this._parseAbortController) {
|
if (this._parseAbortController) {
|
||||||
@@ -410,33 +415,20 @@ class ManifestForm extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderArticles() {
|
renderArticles() {
|
||||||
const { mode } = this.props;
|
|
||||||
const cfg = this.cfg;
|
const cfg = this.cfg;
|
||||||
const { partyId, lines, saving } = this.state;
|
const { lines, saving } = this.state;
|
||||||
|
|
||||||
if (
|
|
||||||
cfg.requirePartyBeforeLines &&
|
|
||||||
mode === 'create' &&
|
|
||||||
partyId == null
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<PanelTile wide={3} minHeight={160} color="#e2e8f0">
|
|
||||||
<Typography color="text.secondary">
|
|
||||||
{cfg.partyGateMessage}
|
|
||||||
</Typography>
|
|
||||||
</PanelTile>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManifestLines
|
<ManifestLines
|
||||||
columns={cfg.columns}
|
columns={cfg.columns}
|
||||||
lines={lines}
|
lines={lines}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
|
wide={4}
|
||||||
linesColor={cfg.linesColor}
|
linesColor={cfg.linesColor}
|
||||||
onLineChange={this.handleLineChange}
|
onLineChange={this.handleLineChange}
|
||||||
onAddLine={this.handleAddLine}
|
onAddLine={this.handleAddLine}
|
||||||
onRemoveLine={this.handleRemoveLine}
|
onRemoveLine={this.handleRemoveLine}
|
||||||
|
onRemoveAllLines={this.handleRemoveAllLines}
|
||||||
onSave={() => void this.handleSave()}
|
onSave={() => void this.handleSave()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -475,18 +467,6 @@ class ManifestForm extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{status === 'ready' && (
|
|
||||||
<PageTitleRight>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={this.handleSave}
|
|
||||||
>
|
|
||||||
{saving ? 'Speichern…' : 'Speichern'}
|
|
||||||
</Button>
|
|
||||||
</PageTitleRight>
|
|
||||||
)}
|
|
||||||
<PanelTileGrid>
|
<PanelTileGrid>
|
||||||
<ManifestHeader
|
<ManifestHeader
|
||||||
kind={kind}
|
kind={kind}
|
||||||
@@ -501,7 +481,9 @@ class ManifestForm extends PureComponent {
|
|||||||
externalNumber={externalNumber}
|
externalNumber={externalNumber}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
pdfUploading={pdfUploading}
|
pdfUploading={pdfUploading}
|
||||||
|
lines={lines}
|
||||||
onPdfUpload={kind === 'delivery' ? this.handlePdfUpload : undefined}
|
onPdfUpload={kind === 'delivery' ? this.handlePdfUpload : undefined}
|
||||||
|
onSave={this.handleSave}
|
||||||
onPartyChange={this.handlePartyChange}
|
onPartyChange={this.handlePartyChange}
|
||||||
onDateChange={this.handleDateChange}
|
onDateChange={this.handleDateChange}
|
||||||
onNumberChange={this.handleNumberChange}
|
onNumberChange={this.handleNumberChange}
|
||||||
|
|||||||
@@ -13,18 +13,24 @@ import Typography from '@mui/material/Typography';
|
|||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
|
||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined';
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import LinkOffOutlinedIcon from '@mui/icons-material/LinkOffOutlined';
|
import LinkOffOutlinedIcon from '@mui/icons-material/LinkOffOutlined';
|
||||||
import { LINE_COLUMNS } from './manifestConfig';
|
import { getFirstVal, parseNum } from './manifestConfig';
|
||||||
import ArticlePhoto from '../article/ArticlePhoto';
|
import ArticlePhoto from '../article/ArticlePhoto';
|
||||||
import { formatPrice } from '../../utils/price';
|
import { formatPrice } from '../../utils/price';
|
||||||
|
import { primaryPrice, priceBrutto } from '../../utils/articleDetail';
|
||||||
|
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
|
||||||
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
|
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
|
||||||
|
|
||||||
class ManifestFormLine extends PureComponent {
|
class ManifestFormLine extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
const sku = props.supplierArticle?.article?.sku;
|
||||||
|
this.state = {
|
||||||
|
cachedArticle: sku ? getArticle(sku) : null,
|
||||||
|
};
|
||||||
this.handleRemove = this.handleRemove.bind(this);
|
this.handleRemove = this.handleRemove.bind(this);
|
||||||
this.handleBlur = this.handleBlur.bind(this);
|
this.handleBlur = this.handleBlur.bind(this);
|
||||||
this.handleCreateArticle = this.handleCreateArticle.bind(this);
|
this.handleCreateArticle = this.handleCreateArticle.bind(this);
|
||||||
@@ -32,6 +38,41 @@ class ManifestFormLine extends PureComponent {
|
|||||||
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
|
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.subscribeArticle(this.props.supplierArticle?.article?.sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const prevSku = prevProps.supplierArticle?.article?.sku;
|
||||||
|
const nextSku = this.props.supplierArticle?.article?.sku;
|
||||||
|
if (prevSku !== nextSku) {
|
||||||
|
this.subscribeArticle(nextSku);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubArticle?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeArticle(sku) {
|
||||||
|
this.unsubArticle?.();
|
||||||
|
this.unsubArticle = null;
|
||||||
|
if (!sku) {
|
||||||
|
if (this.state.cachedArticle) {
|
||||||
|
this.setState({ cachedArticle: null });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = getArticle(sku);
|
||||||
|
this.setState({ cachedArticle: current });
|
||||||
|
if (!current) {
|
||||||
|
artikelAutomat.actions.loadArticle(sku).catch(() => {});
|
||||||
|
}
|
||||||
|
this.unsubArticle = artikelAutomat.subscribe(this, (state) => ({
|
||||||
|
cachedArticle: createArticleSelector(sku)(state),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
handleRemove() {
|
handleRemove() {
|
||||||
this.props.onRemove(this.props.line.key);
|
this.props.onRemove(this.props.line.key);
|
||||||
}
|
}
|
||||||
@@ -63,15 +104,112 @@ class ManifestFormLine extends PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderField(column) {
|
renderLedgerChip() {
|
||||||
|
const { ledgerLine, onBookLine } = this.props;
|
||||||
|
if (!ledgerLine) return null;
|
||||||
|
const isBooked = ledgerLine.isBooked;
|
||||||
|
const isPartial = !isBooked && ledgerLine.bookedAmount > 0;
|
||||||
|
const isClickable = Boolean(onBookLine);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
isBooked
|
||||||
|
? `Vollständig im Lager gebucht: ${ledgerLine.bookedAmount} Stück (Klicken für Details)`
|
||||||
|
: isPartial
|
||||||
|
? `Teilweise im Lager gebucht: ${ledgerLine.bookedAmount} von ${ledgerLine.targetAmount} Stück (Klicken zum Einbuchen dieser Position)`
|
||||||
|
: `Noch nicht im Lager gebucht (${ledgerLine.targetAmount} Stück – Klicken zum Einbuchen dieser Position)`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
clickable={isClickable}
|
||||||
|
onClick={
|
||||||
|
isClickable
|
||||||
|
? (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onBookLine(ledgerLine);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
isBooked ? (
|
||||||
|
<CheckCircleOutlineOutlinedIcon sx={{ '&&': { fontSize: 13 } }} />
|
||||||
|
) : (
|
||||||
|
<Inventory2OutlinedIcon sx={{ '&&': { fontSize: 13 } }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
isBooked
|
||||||
|
? `Lager: ${ledgerLine.bookedAmount} Stk. gebucht`
|
||||||
|
: isPartial
|
||||||
|
? `Lager: ${ledgerLine.bookedAmount}/${ledgerLine.targetAmount} Stk.`
|
||||||
|
: 'Lager: offen'
|
||||||
|
}
|
||||||
|
color={isBooked ? 'success' : isPartial ? 'warning' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
height: 20,
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: isClickable ? 'pointer' : 'default',
|
||||||
|
bgcolor: isBooked ? 'success.50' : undefined,
|
||||||
|
'&:hover': isClickable
|
||||||
|
? {
|
||||||
|
bgcolor: isBooked
|
||||||
|
? 'success.100'
|
||||||
|
: isPartial
|
||||||
|
? 'warning.100'
|
||||||
|
: 'action.hover',
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderField(column, colIdx, totalCols) {
|
||||||
const { id, align, type } = column;
|
const { id, align, type } = column;
|
||||||
const { line, saving, onChange } = this.props;
|
const { line, saving, onChange } = this.props;
|
||||||
|
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 ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isError = false;
|
||||||
|
if (id === 'name') {
|
||||||
|
isError = !String(line.name ?? '').trim();
|
||||||
|
} else if (id === 'amount') {
|
||||||
|
const raw = getFirstVal(line.amount, line.count);
|
||||||
|
isError = raw == null || parseNum(raw) == null;
|
||||||
|
} else if (id === 'pu') {
|
||||||
|
const raw = getFirstVal(line.pu, line.vpe);
|
||||||
|
isError = raw == null || parseNum(raw) == null;
|
||||||
|
} else if (id === 'tax' || id === 'taxrate') {
|
||||||
|
const raw = getFirstVal(line.tax, line.taxrate, line.taxRate, line.mwst);
|
||||||
|
isError = raw == null || parseNum(raw) == null;
|
||||||
|
} 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 === 'sku') {
|
||||||
|
isError = !String(line.sku ?? '').trim() && !String(line.name ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirst = colIdx === 0;
|
||||||
|
const isLast = colIdx === totalCols - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
type={type}
|
type={type}
|
||||||
value={line[id] ?? ''}
|
value={val ?? ''}
|
||||||
|
error={isError}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={(e) => onChange(line.key, id, e.target.value)}
|
onChange={(e) => onChange(line.key, id, e.target.value)}
|
||||||
@@ -86,6 +224,16 @@ class ManifestFormLine extends PureComponent {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
borderRadius: isFirst ? '4px 0 0 4px' : isLast ? '0 4px 4px 0' : 0,
|
||||||
|
ml: isFirst ? 0 : '-1px',
|
||||||
|
'&:hover': {
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
'&.Mui-focused': {
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
'& input[type=number]': {
|
'& input[type=number]': {
|
||||||
MozAppearance: 'textfield',
|
MozAppearance: 'textfield',
|
||||||
},
|
},
|
||||||
@@ -116,18 +264,28 @@ class ManifestFormLine extends PureComponent {
|
|||||||
|
|
||||||
if (supplierArticle) {
|
if (supplierArticle) {
|
||||||
const article = supplierArticle.article || {};
|
const article = supplierArticle.article || {};
|
||||||
const photo = article.photos?.[0] || article.articlepic?.[0];
|
const fullArticle = this.state.cachedArticle || article;
|
||||||
|
const photo = fullArticle.photos?.[0] || fullArticle.articlepic?.[0] || article.photos?.[0] || article.articlepic?.[0];
|
||||||
const fullHash = photo?.hash || photo?.fullHash;
|
const fullHash = photo?.hash || photo?.fullHash;
|
||||||
const thumbHash = photo?.thumbhash || photo?.thumbHash;
|
const thumbHash = photo?.thumbhash || photo?.thumbHash;
|
||||||
const thumbW = photo?.thumbw || photo?.thumbW;
|
const thumbW = photo?.thumbw || photo?.thumbW;
|
||||||
const thumbH = photo?.thumbh || photo?.thumbH;
|
const thumbH = photo?.thumbh || photo?.thumbH;
|
||||||
const hasPhoto = fullHash != null || thumbHash != null;
|
const hasPhoto = fullHash != null || thumbHash != null;
|
||||||
|
|
||||||
const eans = article.eans ?? article.articleean ?? [];
|
const eans = fullArticle.eans ?? fullArticle.articleean ?? article.eans ?? article.articleean ?? [];
|
||||||
const primaryEan = Array.isArray(eans) ? eans[0] : eans;
|
const primaryEan = Array.isArray(eans) ? eans[0] : eans;
|
||||||
const priceNet = supplierArticle.price?.net;
|
const priceNet = supplierArticle.price?.net;
|
||||||
const taxRate = supplierArticle.price?.taxRate;
|
const taxRate = supplierArticle.price?.taxRate;
|
||||||
|
|
||||||
|
const vkPrice = primaryPrice(fullArticle);
|
||||||
|
const vkNetto = vkPrice?.net;
|
||||||
|
const vkBrutto = vkPrice
|
||||||
|
? priceBrutto(vkPrice) ??
|
||||||
|
(vkPrice.net != null && vkPrice.taxrate != null
|
||||||
|
? vkPrice.net * (1 + vkPrice.taxrate / 100)
|
||||||
|
: null)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -138,6 +296,24 @@ class ManifestFormLine extends PureComponent {
|
|||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Unlink button at the front */}
|
||||||
|
{this.props.onRemoveSupplierArticle && supplierArticle.id != null && (
|
||||||
|
<Tooltip title="Lieferanten-Verknüpfung aufheben">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
aria-label="Lieferanten-Verknüpfung entfernen"
|
||||||
|
disabled={this.props.saving || this.props.supplierArticleBusy}
|
||||||
|
onClick={this.handleRemoveSupplierArticle}
|
||||||
|
sx={{ p: 0.25, mr: -0.25 }}
|
||||||
|
>
|
||||||
|
<LinkOffOutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Article Photo Thumbnail */}
|
{/* Article Photo Thumbnail */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -166,9 +342,6 @@ class ManifestFormLine extends PureComponent {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Match Indicator */}
|
|
||||||
<CheckCircleOutlinedIcon sx={{ fontSize: 16, color: 'success.main', flexShrink: 0 }} />
|
|
||||||
|
|
||||||
{/* Internal Article SKU */}
|
{/* Internal Article SKU */}
|
||||||
{article.sku && (
|
{article.sku && (
|
||||||
<Chip
|
<Chip
|
||||||
@@ -195,15 +368,15 @@ class ManifestFormLine extends PureComponent {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Article Name */}
|
{/* Article Name */}
|
||||||
{article.name && (
|
{(fullArticle.name || article.name) && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
noWrap
|
noWrap
|
||||||
sx={{ color: 'text.primary', maxWidth: { xs: 200, sm: 300, md: 400 } }}
|
sx={{ color: 'text.primary', maxWidth: { xs: 200, sm: 300, md: 400 } }}
|
||||||
title={article.name}
|
title={fullArticle.name || article.name}
|
||||||
>
|
>
|
||||||
{article.name}
|
{fullArticle.name || article.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -225,32 +398,49 @@ class ManifestFormLine extends PureComponent {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stored Purchase Price (Netto) */}
|
{/* Stored Purchase Price (Netto), VK Netto & VK Brutto */}
|
||||||
{priceNet != null && (
|
{(priceNet != null || vkNetto != null || vkBrutto != null) && (
|
||||||
<Typography
|
<Box
|
||||||
variant="caption"
|
sx={{
|
||||||
fontWeight={600}
|
display: 'flex',
|
||||||
sx={{ color: 'success.dark', ml: 'auto', fontSize: '0.72rem', whiteSpace: 'nowrap' }}
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
ml: 'auto',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
EK Netto: {formatPrice(priceNet)}
|
{priceNet != null && (
|
||||||
{taxRate != null ? ` (${taxRate}% MwSt)` : ''}
|
<Typography
|
||||||
</Typography>
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ color: 'success.dark', fontSize: '0.72rem' }}
|
||||||
|
>
|
||||||
|
EK Netto: {formatPrice(priceNet)}
|
||||||
|
{taxRate != null ? ` (${taxRate}% MwSt)` : ''}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{vkNetto != null && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ color: 'text.secondary', fontSize: '0.72rem' }}
|
||||||
|
>
|
||||||
|
VK Netto: {formatPrice(vkNetto)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{vkBrutto != null && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ color: 'primary.dark', fontSize: '0.72rem' }}
|
||||||
|
>
|
||||||
|
VK Brutto: {formatPrice(vkBrutto)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Remove supplier-article connection */}
|
{this.renderLedgerChip()}
|
||||||
{this.props.onRemoveSupplierArticle && supplierArticle.id != null && (
|
|
||||||
<span style={{ marginLeft: priceNet != null ? 0 : 'auto' }}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
aria-label="Lieferanten-Verknüpfung entfernen"
|
|
||||||
disabled={this.props.saving || this.props.supplierArticleBusy}
|
|
||||||
onClick={this.handleRemoveSupplierArticle}
|
|
||||||
>
|
|
||||||
<LinkOffOutlinedIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -258,10 +448,6 @@ class ManifestFormLine extends PureComponent {
|
|||||||
if (rawSku) {
|
if (rawSku) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
|
||||||
<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.
|
|
||||||
</Typography>
|
|
||||||
{this.props.onCreateArticle && (
|
{this.props.onCreateArticle && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -306,34 +492,44 @@ class ManifestFormLine extends PureComponent {
|
|||||||
Artikel suchen
|
Artikel suchen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<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.
|
||||||
|
</Typography>
|
||||||
|
{this.renderLedgerChip()}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (this.props.ledgerLine) {
|
||||||
<Typography variant="caption" color="text.disabled" sx={{ fontStyle: 'italic', fontSize: '0.7rem' }}>
|
return (
|
||||||
SKU eingeben oder Dokument scannen für automatische Artikelzuordnung
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
|
||||||
</Typography>
|
{this.renderLedgerChip()}
|
||||||
);
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { columns = LINE_COLUMNS, saving } = this.props;
|
const { columns = LINE_COLUMNS, saving } = this.props;
|
||||||
|
const secondLine = this.renderSecondLine();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{/* Row 1: Editable Line Inputs */}
|
{/* Row 1: Editable Line Inputs */}
|
||||||
<TableRow hover sx={{ '& > td': { borderBottom: 'none', py: 0.75 } }}>
|
<TableRow hover sx={{ '& > td': { borderBottom: 'none', p: 0 } }}>
|
||||||
{columns.map((col) => (
|
{columns.map((col, colIdx) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={col.id}
|
key={col.id}
|
||||||
align={col.align}
|
align={col.align}
|
||||||
sx={{ py: 0.75, verticalAlign: 'middle' }}
|
sx={{ p: 0, width: col.width, verticalAlign: 'middle' }}
|
||||||
>
|
>
|
||||||
{this.renderField(col)}
|
{this.renderField(col, colIdx, columns.length)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
<TableCell padding="checkbox" sx={{ py: 0.75 }}>
|
<TableCell padding="none" sx={{ py: 0.25, pl: 0.75, pr: 0, verticalAlign: 'middle', width: 40 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="Zeile entfernen"
|
aria-label="Zeile entfernen"
|
||||||
@@ -346,18 +542,20 @@ class ManifestFormLine extends PureComponent {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
{/* Row 2: Matched Supplier Article Details */}
|
{/* Row 2: Matched Supplier Article Details */}
|
||||||
<TableRow sx={{ '& > td': { pt: 0, pb: 0.75, borderBottom: 'none' } }}>
|
{secondLine && (
|
||||||
<TableCell
|
<TableRow sx={{ '& > td': { pt: 0.25, pb: 0.75, borderBottom: 'none' } }}>
|
||||||
colSpan={columns.length + 1}
|
<TableCell
|
||||||
sx={{
|
colSpan={columns.length + 1}
|
||||||
py: 0.4,
|
sx={{
|
||||||
px: 2,
|
py: 0.4,
|
||||||
bgcolor: 'transparent',
|
px: 1,
|
||||||
}}
|
bgcolor: 'transparent',
|
||||||
>
|
}}
|
||||||
{this.renderSecondLine()}
|
>
|
||||||
</TableCell>
|
{secondLine}
|
||||||
</TableRow>
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { PureComponent, createRef } from 'react';
|
import { PureComponent, createRef } from 'react';
|
||||||
import Autocomplete from '@mui/material/Autocomplete';
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
import { PanelTile } from 'dashgui/panels';
|
import { PanelTile } from 'dashgui/panels';
|
||||||
import FileDropzone from '../FileDropzone';
|
import FileDropzone from '../FileDropzone';
|
||||||
import AttachmentList from './AttachmentList';
|
import AttachmentList from './AttachmentList';
|
||||||
import { manifestConfig, partyDisplayName } from './manifestConfig';
|
import { hasArticleLines, manifestConfig, partyDisplayName } from './manifestConfig';
|
||||||
|
import { notifyErrorMessage } from '../../utils/notify';
|
||||||
|
|
||||||
class ManifestHeader extends PureComponent {
|
class ManifestHeader extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -18,20 +23,50 @@ class ManifestHeader extends PureComponent {
|
|||||||
this.handlePartyChange = this.handlePartyChange.bind(this);
|
this.handlePartyChange = this.handlePartyChange.bind(this);
|
||||||
this.handleUploadClick = this.handleUploadClick.bind(this);
|
this.handleUploadClick = this.handleUploadClick.bind(this);
|
||||||
this.handleFileChange = this.handleFileChange.bind(this);
|
this.handleFileChange = this.handleFileChange.bind(this);
|
||||||
|
this.handleFileDrop = this.handleFileDrop.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePartyChange(_event, option) {
|
handlePartyChange(_event, option) {
|
||||||
this.props.onPartyChange(option?.id ?? null);
|
this.props.onPartyChange(option?.id ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasArticles() {
|
||||||
|
return Boolean(
|
||||||
|
this.props.hasArticles ??
|
||||||
|
(Array.isArray(this.props.lines) && hasArticleLines(this.props.lines)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
handleUploadClick() {
|
handleUploadClick() {
|
||||||
|
if (this.hasArticles()) {
|
||||||
|
notifyErrorMessage(
|
||||||
|
'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.fileInputRef.current?.click();
|
this.fileInputRef.current?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleFileDrop(file) {
|
||||||
|
if (this.hasArticles()) {
|
||||||
|
notifyErrorMessage(
|
||||||
|
'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.props.onPdfUpload?.(file);
|
||||||
|
}
|
||||||
|
|
||||||
handleFileChange(event) {
|
handleFileChange(event) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (this.hasArticles()) {
|
||||||
|
notifyErrorMessage(
|
||||||
|
'Datei-Upload nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.props.onPdfUpload?.(file);
|
this.props.onPdfUpload?.(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +85,7 @@ class ManifestHeader extends PureComponent {
|
|||||||
saving,
|
saving,
|
||||||
pdfUploading,
|
pdfUploading,
|
||||||
attachments,
|
attachments,
|
||||||
|
lines,
|
||||||
onPdfUpload,
|
onPdfUpload,
|
||||||
onPdfRescan,
|
onPdfRescan,
|
||||||
onPdfDelete,
|
onPdfDelete,
|
||||||
@@ -58,8 +94,15 @@ class ManifestHeader extends PureComponent {
|
|||||||
onDateChange,
|
onDateChange,
|
||||||
onNumberChange,
|
onNumberChange,
|
||||||
onExternalNumberChange,
|
onExternalNumberChange,
|
||||||
|
onSave,
|
||||||
|
actionsColor,
|
||||||
|
docId,
|
||||||
|
ledgerSummary,
|
||||||
|
onOpenBookDialog,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
const hasArticles = this.hasArticles();
|
||||||
|
|
||||||
// Delivery: number is server-allocated/read-only; the caller reference
|
// Delivery: number is server-allocated/read-only; the caller reference
|
||||||
// lives in the editable externalNumber field.
|
// lives in the editable externalNumber field.
|
||||||
const cfg = manifestConfig(kind);
|
const cfg = manifestConfig(kind);
|
||||||
@@ -68,117 +111,243 @@ class ManifestHeader extends PureComponent {
|
|||||||
const selected =
|
const selected =
|
||||||
parties.find((row) => row.id === partyId) ?? null;
|
parties.find((row) => row.id === partyId) ?? null;
|
||||||
|
|
||||||
|
const isDateEmpty = !date || String(date).trim() === '';
|
||||||
|
const isPartyEmpty = partyId == null || partyId === '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelTile wide={3} minHeight={160} color={headerColor} title="Kopfdaten">
|
<>
|
||||||
<Stack
|
{/* Tile 1: 3-wide Kopfdaten Tile */}
|
||||||
direction={{ xs: 'column', sm: 'row' }}
|
<PanelTile wide={3} minHeight={160} color={headerColor} title="Kopfdaten">
|
||||||
spacing={2}
|
<Box
|
||||||
sx={{ alignItems: { xs: 'stretch', sm: 'center' } }}
|
sx={{
|
||||||
>
|
display: 'flex',
|
||||||
<TextField
|
flexWrap: 'wrap',
|
||||||
size="small"
|
gap: 1.5,
|
||||||
type="date"
|
alignItems: 'center',
|
||||||
label={dateLabel}
|
|
||||||
value={date}
|
|
||||||
disabled={saving || pdfUploading}
|
|
||||||
onChange={(e) => onDateChange(e.target.value)}
|
|
||||||
autoComplete="off"
|
|
||||||
slotProps={{
|
|
||||||
inputLabel: { shrink: true },
|
|
||||||
htmlInput: { spellCheck: false },
|
|
||||||
}}
|
}}
|
||||||
sx={{ minWidth: 220 }}
|
>
|
||||||
/>
|
<TextField
|
||||||
<Autocomplete
|
size="small"
|
||||||
size="small"
|
type="date"
|
||||||
options={parties}
|
label={dateLabel}
|
||||||
value={selected}
|
value={date}
|
||||||
loading={Boolean(partiesLoading)}
|
error={isDateEmpty}
|
||||||
disabled={saving || pdfUploading}
|
disabled={saving || pdfUploading}
|
||||||
getOptionLabel={(option) => partyDisplayName(kind, option)}
|
onChange={(e) => onDateChange(e.target.value)}
|
||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
autoComplete="off"
|
||||||
onChange={this.handlePartyChange}
|
slotProps={{
|
||||||
sx={{ flex: 1, minWidth: 220 }}
|
inputLabel: { shrink: true },
|
||||||
renderInput={(params) => (
|
htmlInput: { spellCheck: false },
|
||||||
<TextField
|
}}
|
||||||
{...params}
|
sx={{
|
||||||
label={partyLabel}
|
flex: { xs: '1 1 100%', sm: '1 1 150px' },
|
||||||
slotProps={{
|
minWidth: { sm: 130 },
|
||||||
...params.slotProps,
|
}}
|
||||||
htmlInput: {
|
/>
|
||||||
...params.slotProps?.htmlInput,
|
<Autocomplete
|
||||||
spellCheck: false,
|
size="small"
|
||||||
autoComplete: 'off',
|
options={parties}
|
||||||
},
|
value={selected}
|
||||||
|
loading={Boolean(partiesLoading)}
|
||||||
|
disabled={saving || pdfUploading}
|
||||||
|
getOptionLabel={(option) => partyDisplayName(kind, option)}
|
||||||
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
|
onChange={this.handlePartyChange}
|
||||||
|
sx={{
|
||||||
|
flex: { xs: '1 1 100%', sm: '2 1 200px' },
|
||||||
|
minWidth: { sm: 160 },
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={partyLabel}
|
||||||
|
error={isPartyEmpty}
|
||||||
|
slotProps={{
|
||||||
|
...params.slotProps,
|
||||||
|
htmlInput: {
|
||||||
|
...params.slotProps?.htmlInput,
|
||||||
|
spellCheck: false,
|
||||||
|
autoComplete: 'off',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SingleLineTextEdit
|
||||||
|
size="small"
|
||||||
|
label="Nummer"
|
||||||
|
value={number}
|
||||||
|
disabled={saving || pdfUploading || useExternalNumber}
|
||||||
|
onChange={onNumberChange}
|
||||||
|
sx={{
|
||||||
|
flex: { xs: '1 1 100%', sm: '1 1 120px' },
|
||||||
|
minWidth: { sm: 100 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{useExternalNumber ? (
|
||||||
|
<SingleLineTextEdit
|
||||||
|
size="small"
|
||||||
|
label="Referenz"
|
||||||
|
value={externalNumber ?? ''}
|
||||||
|
disabled={saving || pdfUploading}
|
||||||
|
onChange={onExternalNumberChange}
|
||||||
|
sx={{
|
||||||
|
flex: { xs: '1 1 100%', sm: '1 1 120px' },
|
||||||
|
minWidth: { sm: 100 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{docId != null && kind === 'delivery' && ledgerSummary && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={
|
||||||
|
ledgerSummary.status === 'booked' ? (
|
||||||
|
<CheckCircleOutlineOutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
|
) : (
|
||||||
|
<Inventory2OutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
ledgerSummary.status === 'booked'
|
||||||
|
? `Lager: Gebucht (${ledgerSummary.bookedPositions}/${ledgerSummary.totalPositions})`
|
||||||
|
: ledgerSummary.status === 'partial'
|
||||||
|
? `Lager: Teilweise (${ledgerSummary.bookedPositions}/${ledgerSummary.totalPositions})`
|
||||||
|
: ledgerSummary.status === 'empty'
|
||||||
|
? 'Lager: Keine Pos.'
|
||||||
|
: `Lager: Offen (${ledgerSummary.totalPositions} Pos.)`
|
||||||
|
}
|
||||||
|
color={
|
||||||
|
ledgerSummary.status === 'booked'
|
||||||
|
? 'success'
|
||||||
|
: ledgerSummary.status === 'partial'
|
||||||
|
? 'warning'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onOpenBookDialog}
|
||||||
|
sx={{
|
||||||
|
cursor: onOpenBookDialog ? 'pointer' : 'default',
|
||||||
|
fontWeight: 600,
|
||||||
|
height: 32,
|
||||||
|
px: 0.5,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</Box>
|
||||||
|
<AttachmentList
|
||||||
|
attachments={attachments}
|
||||||
|
onRescan={onPdfRescan}
|
||||||
|
onDelete={onPdfDelete}
|
||||||
|
onClick={onAttachmentClick ?? onPdfClick}
|
||||||
|
disabled={saving || pdfUploading}
|
||||||
|
hasArticles={hasArticles}
|
||||||
/>
|
/>
|
||||||
<SingleLineTextEdit
|
</PanelTile>
|
||||||
size="small"
|
|
||||||
label="Nummer"
|
{/* Tile 2: 1-wide Action Tile (4th tile in row) with Speichern & Datei hochladen */}
|
||||||
value={number}
|
<PanelTile
|
||||||
disabled={saving || pdfUploading || useExternalNumber}
|
wide={1}
|
||||||
onChange={onNumberChange}
|
minHeight={160}
|
||||||
sx={{ minWidth: 180, flex: 1 }}
|
color={actionsColor ?? '#f1f5f9'}
|
||||||
/>
|
sx={{
|
||||||
{useExternalNumber ? (
|
gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 1' },
|
||||||
<SingleLineTextEdit
|
}}
|
||||||
size="small"
|
>
|
||||||
label="Referenz"
|
<Stack spacing={1.5} sx={{ pt: 0.5 }}>
|
||||||
value={externalNumber ?? ''}
|
<Button
|
||||||
disabled={saving || pdfUploading}
|
variant="contained"
|
||||||
onChange={onExternalNumberChange}
|
color="primary"
|
||||||
sx={{ minWidth: 180, flex: 1 }}
|
disabled={saving}
|
||||||
/>
|
onClick={onSave}
|
||||||
) : null}
|
|
||||||
{onPdfUpload ? (
|
|
||||||
<FileDropzone
|
|
||||||
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
|
|
||||||
disabled={saving || pdfUploading}
|
|
||||||
onFileDrop={onPdfUpload}
|
|
||||||
sx={{
|
sx={{
|
||||||
alignSelf: { xs: 'stretch', sm: 'center' },
|
height: 40,
|
||||||
flexShrink: 0,
|
fontWeight: 700,
|
||||||
width: { xs: '100%', sm: 175 },
|
fontSize: '0.875rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ isDragging }) => (
|
{saving ? 'Speichern…' : 'Speichern'}
|
||||||
<Button
|
</Button>
|
||||||
variant="outlined"
|
{docId != null && kind === 'delivery' && onOpenBookDialog && (
|
||||||
size="small"
|
<Button
|
||||||
startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />}
|
variant={ledgerSummary?.status === 'booked' ? 'outlined' : 'contained'}
|
||||||
disabled={saving || pdfUploading}
|
color={
|
||||||
onClick={this.handleUploadClick}
|
ledgerSummary?.status === 'booked'
|
||||||
title="PDF, TXT oder CSV hochladen"
|
? 'success'
|
||||||
sx={{
|
: ledgerSummary?.status === 'partial'
|
||||||
whiteSpace: 'nowrap',
|
? 'warning'
|
||||||
height: 40,
|
: 'primary'
|
||||||
width: '100%',
|
}
|
||||||
px: 2,
|
startIcon={
|
||||||
boxSizing: 'border-box',
|
ledgerSummary?.status === 'booked' ? (
|
||||||
bgcolor: isDragging ? 'action.hover' : 'transparent',
|
<CheckCircleOutlineOutlinedIcon />
|
||||||
borderColor: isDragging ? 'primary.main' : undefined,
|
) : (
|
||||||
transition: 'background-color 0.15s, border-color 0.15s',
|
<Inventory2OutlinedIcon />
|
||||||
}}
|
)
|
||||||
>
|
}
|
||||||
{pdfUploading
|
disabled={saving || pdfUploading}
|
||||||
? 'Wird analysiert…'
|
onClick={onOpenBookDialog}
|
||||||
: isDragging
|
sx={{
|
||||||
? 'Datei hier ablegen'
|
height: 40,
|
||||||
: 'Datei hochladen'}
|
fontWeight: 700,
|
||||||
</Button>
|
fontSize: '0.8125rem',
|
||||||
)}
|
whiteSpace: 'nowrap',
|
||||||
</FileDropzone>
|
}}
|
||||||
) : null}
|
>
|
||||||
</Stack>
|
{ledgerSummary?.status === 'booked'
|
||||||
<AttachmentList
|
? 'Lager gebucht'
|
||||||
attachments={attachments}
|
: ledgerSummary?.status === 'partial'
|
||||||
onRescan={onPdfRescan}
|
? 'Rest einbuchen'
|
||||||
onDelete={onPdfDelete}
|
: 'Ins Lager buchen'}
|
||||||
onClick={onAttachmentClick ?? onPdfClick}
|
</Button>
|
||||||
disabled={saving || pdfUploading}
|
)}
|
||||||
/>
|
{onPdfUpload ? (
|
||||||
|
<FileDropzone
|
||||||
|
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
|
||||||
|
disabled={saving || pdfUploading}
|
||||||
|
onFileDrop={this.handleFileDrop}
|
||||||
|
sx={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{({ isDragging }) => (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />}
|
||||||
|
disabled={saving || pdfUploading}
|
||||||
|
onClick={this.handleUploadClick}
|
||||||
|
title={
|
||||||
|
hasArticles
|
||||||
|
? 'Datei-Upload nicht möglich, solange Artikel vorhanden sind'
|
||||||
|
: 'PDF, TXT oder CSV hochladen'
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
height: 40,
|
||||||
|
width: '100%',
|
||||||
|
px: 1.5,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
bgcolor: isDragging ? 'action.hover' : 'transparent',
|
||||||
|
borderColor: isDragging
|
||||||
|
? 'primary.main'
|
||||||
|
: hasArticles
|
||||||
|
? 'action.disabled'
|
||||||
|
: undefined,
|
||||||
|
color: hasArticles ? 'text.disabled' : undefined,
|
||||||
|
opacity: hasArticles ? 0.6 : 1,
|
||||||
|
cursor: hasArticles ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'background-color 0.15s, border-color 0.15s, opacity 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pdfUploading
|
||||||
|
? 'Wird analysiert…'
|
||||||
|
: isDragging
|
||||||
|
? 'Datei hier ablegen'
|
||||||
|
: 'Datei hochladen'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileDropzone>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</PanelTile>
|
||||||
{onPdfUpload ? (
|
{onPdfUpload ? (
|
||||||
<input
|
<input
|
||||||
ref={this.fileInputRef}
|
ref={this.fileInputRef}
|
||||||
@@ -188,7 +357,7 @@ class ManifestHeader extends PureComponent {
|
|||||||
onChange={this.handleFileChange}
|
onChange={this.handleFileChange}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</PanelTile>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import TableContainer from '@mui/material/TableContainer';
|
|||||||
import TableHead from '@mui/material/TableHead';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
|
||||||
import { PanelTile } from 'dashgui/panels';
|
import { PanelTile } from 'dashgui/panels';
|
||||||
import { LINE_COLUMNS } from './manifestConfig';
|
import { LINE_COLUMNS, hasArticleLines } from './manifestConfig';
|
||||||
import ManifestFormLine from './ManifestFormLine';
|
import ManifestFormLine from './ManifestFormLine';
|
||||||
|
|
||||||
class ManifestLines extends PureComponent {
|
class ManifestLines extends PureComponent {
|
||||||
@@ -22,6 +23,7 @@ class ManifestLines extends PureComponent {
|
|||||||
onLineChange,
|
onLineChange,
|
||||||
onAddLine,
|
onAddLine,
|
||||||
onRemoveLine,
|
onRemoveLine,
|
||||||
|
onRemoveAllLines,
|
||||||
onSave,
|
onSave,
|
||||||
columns = LINE_COLUMNS,
|
columns = LINE_COLUMNS,
|
||||||
supplierArticlesMap = {},
|
supplierArticlesMap = {},
|
||||||
@@ -33,11 +35,29 @@ class ManifestLines extends PureComponent {
|
|||||||
onSearchArticle,
|
onSearchArticle,
|
||||||
onRemoveSupplierArticle,
|
onRemoveSupplierArticle,
|
||||||
busySupplierArticleLineKeys = {},
|
busySupplierArticleLineKeys = {},
|
||||||
wide = 3,
|
wide = 4,
|
||||||
|
ledgerLinesById = {},
|
||||||
|
onBookLine,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
const hasArticles = hasArticleLines(lines);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelTile wide={wide} minHeight={280} color={linesColor} title="Artikel">
|
<PanelTile
|
||||||
|
wide={wide}
|
||||||
|
minHeight={280}
|
||||||
|
color={linesColor}
|
||||||
|
title="Artikel"
|
||||||
|
sx={{
|
||||||
|
...(wide >= 4 ? { gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } } : {}),
|
||||||
|
...(!hasArticles
|
||||||
|
? {
|
||||||
|
border: '1.5px solid',
|
||||||
|
borderColor: 'error.main',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<Table size="small" stickyHeader>
|
<Table size="small" stickyHeader>
|
||||||
@@ -52,47 +72,53 @@ class ManifestLines extends PureComponent {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
bgcolor: linesColor,
|
bgcolor: linesColor,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
|
px: 1,
|
||||||
|
py: 0.75,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{col.label}
|
{col.label}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
<TableCell padding="checkbox" sx={{ bgcolor: linesColor, borderBottom: 'none' }} />
|
<TableCell padding="none" sx={{ bgcolor: linesColor, borderBottom: 'none', width: 40 }} />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{lines.map((line, index) => {
|
{Array.isArray(lines) &&
|
||||||
const lineKey = line.key ?? index;
|
lines.map((line, index) => {
|
||||||
const rawSku = line.sku != null ? String(line.sku).trim() : '';
|
const lineKey = line.key ?? index;
|
||||||
const matched =
|
const rawSku = line.sku != null ? String(line.sku).trim() : '';
|
||||||
supplierArticlesByLineKey[lineKey] ||
|
const matched =
|
||||||
(rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null);
|
supplierArticlesByLineKey[lineKey] ||
|
||||||
const isLineLoading =
|
(rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null);
|
||||||
loadingLineKeys[lineKey] ||
|
const isLineLoading =
|
||||||
(supplierArticlesLoading && !matched);
|
loadingLineKeys[lineKey] ||
|
||||||
|
(supplierArticlesLoading && !matched);
|
||||||
|
const ledgerLine = line.id != null ? ledgerLinesById[line.id] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManifestFormLine
|
<ManifestFormLine
|
||||||
key={lineKey}
|
key={lineKey}
|
||||||
line={line.key != null ? line : { ...line, key: index }}
|
line={line.key != null ? line : { ...line, key: index }}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
supplierArticle={matched}
|
supplierArticle={matched}
|
||||||
loading={Boolean(isLineLoading)}
|
loading={Boolean(isLineLoading)}
|
||||||
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}
|
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}
|
||||||
onChange={onLineChange}
|
onChange={onLineChange}
|
||||||
onRemove={onRemoveLine}
|
onRemove={onRemoveLine}
|
||||||
onSkuBlur={onSkuBlur}
|
onSkuBlur={onSkuBlur}
|
||||||
onCreateArticle={onCreateArticle}
|
onCreateArticle={onCreateArticle}
|
||||||
onSearchArticle={onSearchArticle}
|
onSearchArticle={onSearchArticle}
|
||||||
onRemoveSupplierArticle={onRemoveSupplierArticle}
|
onRemoveSupplierArticle={onRemoveSupplierArticle}
|
||||||
/>
|
ledgerLine={ledgerLine}
|
||||||
);
|
onBookLine={onBookLine}
|
||||||
})}
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={<AddIcon />}
|
startIcon={<AddIcon />}
|
||||||
@@ -101,6 +127,15 @@ class ManifestLines extends PureComponent {
|
|||||||
>
|
>
|
||||||
Zeile hinzufügen
|
Zeile hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
startIcon={<DeleteSweepOutlinedIcon />}
|
||||||
|
disabled={saving || !lines || lines.length === 0}
|
||||||
|
onClick={onRemoveAllLines}
|
||||||
|
>
|
||||||
|
Alle Zeilen entfernen
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</PanelTile>
|
</PanelTile>
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { PureComponent } from 'react';
|
import { 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 Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from '../BackendError';
|
import BackendError from '../BackendError';
|
||||||
import { RecordList } from 'dashgui/lists';
|
import { RecordList } from 'dashgui/lists';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||||
|
import { ledger, delivery } from '../../api';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
|
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
|
||||||
|
import BookDeliveryDialog from './BookDeliveryDialog';
|
||||||
import withNavigate from '../withNavigate';
|
import withNavigate from '../withNavigate';
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -27,12 +38,18 @@ class ManifestList extends PureComponent {
|
|||||||
docs: [],
|
docs: [],
|
||||||
partiesById: {},
|
partiesById: {},
|
||||||
busyId: null,
|
busyId: null,
|
||||||
|
ledgerSummaries: {},
|
||||||
|
bookingDoc: null,
|
||||||
|
bookingOpen: false,
|
||||||
};
|
};
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
this.handleRetry = this.handleRetry.bind(this);
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
this.handleCreate = this.handleCreate.bind(this);
|
this.handleCreate = this.handleCreate.bind(this);
|
||||||
this.handleEdit = this.handleEdit.bind(this);
|
this.handleEdit = this.handleEdit.bind(this);
|
||||||
this.handleDelete = this.handleDelete.bind(this);
|
this.handleDelete = this.handleDelete.bind(this);
|
||||||
|
this.handleOpenBookDialog = this.handleOpenBookDialog.bind(this);
|
||||||
|
this.handleCloseBookDialog = this.handleCloseBookDialog.bind(this);
|
||||||
|
this.handleBookSuccess = this.handleBookSuccess.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -53,17 +70,59 @@ class ManifestList extends PureComponent {
|
|||||||
loadParties(this.props.kind, this.context),
|
loadParties(this.props.kind, this.context),
|
||||||
]);
|
]);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
|
const sortedDocs = sortDocs(docs);
|
||||||
this.setState({
|
this.setState({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
docs: sortDocs(docs),
|
docs: sortedDocs,
|
||||||
partiesById: cfg.partiesByIdFromList(parties),
|
partiesById: cfg.partiesByIdFromList(parties),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.props.kind === 'delivery') {
|
||||||
|
void this.loadLedgerSummaries(sortedDocs);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadLedgerSummaries(docs) {
|
||||||
|
if (!Array.isArray(docs) || docs.length === 0) return;
|
||||||
|
const ledgerApi = this.context?.ledger || ledger;
|
||||||
|
const deliveryApi = this.context?.delivery || delivery;
|
||||||
|
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState((prev) => ({
|
||||||
|
ledgerSummaries: {
|
||||||
|
...prev.ledgerSummaries,
|
||||||
|
[doc.id]: prev.ledgerSummaries[doc.id] || { status: 'loading' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
fetchDeliveryLedgerSummary(doc.id, { ledgerApi, deliveryApi })
|
||||||
|
.then((summary) => {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState((prev) => ({
|
||||||
|
ledgerSummaries: {
|
||||||
|
...prev.ledgerSummaries,
|
||||||
|
[doc.id]: summary,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (this._cancelled) return;
|
||||||
|
console.error(`[ManifestList] Ledger summary failed for delivery ${doc.id}:`, err);
|
||||||
|
this.setState((prev) => ({
|
||||||
|
ledgerSummaries: {
|
||||||
|
...prev.ledgerSummaries,
|
||||||
|
[doc.id]: { status: 'error', error: err.message },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleRetry() {
|
handleRetry() {
|
||||||
void this.load();
|
void this.load();
|
||||||
}
|
}
|
||||||
@@ -86,6 +145,24 @@ class ManifestList extends PureComponent {
|
|||||||
this.go(this.cfg.editPath(doc.id));
|
this.go(this.cfg.editPath(doc.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleOpenBookDialog(doc) {
|
||||||
|
this.setState({ bookingDoc: doc, bookingOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseBookDialog() {
|
||||||
|
this.setState({ bookingDoc: null, bookingOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBookSuccess(updatedSummary) {
|
||||||
|
if (!updatedSummary?.deliveryId) return;
|
||||||
|
this.setState((prev) => ({
|
||||||
|
ledgerSummaries: {
|
||||||
|
...prev.ledgerSummaries,
|
||||||
|
[updatedSummary.deliveryId]: updatedSummary,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async handleDelete(doc) {
|
async handleDelete(doc) {
|
||||||
if (this.state.busyId != null) return;
|
if (this.state.busyId != null) return;
|
||||||
if (!window.confirm(this.cfg.confirmDelete(doc.number))) return;
|
if (!window.confirm(this.cfg.confirmDelete(doc.number))) return;
|
||||||
@@ -96,9 +173,14 @@ class ManifestList extends PureComponent {
|
|||||||
await this.context[cfg.api].delete(doc.id);
|
await this.context[cfg.api].delete(doc.id);
|
||||||
const list = await this.context[cfg.updateCache]();
|
const list = await this.context[cfg.updateCache]();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({
|
this.setState((prev) => {
|
||||||
docs: sortDocs(list),
|
const nextSummaries = { ...prev.ledgerSummaries };
|
||||||
busyId: null,
|
delete nextSummaries[doc.id];
|
||||||
|
return {
|
||||||
|
docs: sortDocs(list),
|
||||||
|
busyId: null,
|
||||||
|
ledgerSummaries: nextSummaries,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
|
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -116,8 +198,141 @@ class ManifestList extends PureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderLedgerSummary(row) {
|
||||||
|
const summary = this.state.ledgerSummaries[row.id];
|
||||||
|
|
||||||
|
if (!summary || summary.status === 'loading') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<CircularProgress size={14} color="inherit" sx={{ opacity: 0.6 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Prüfe...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.status === 'error') {
|
||||||
|
return (
|
||||||
|
<Tooltip title={summary.error || 'Fehler beim Laden des Lagerstatus'}>
|
||||||
|
<Chip size="small" label="Statusfehler" color="error" variant="outlined" />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.status === 'empty') {
|
||||||
|
return (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Keine Positionen
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.status === 'booked') {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={`${summary.totalPositions} Positionen (${summary.bookedQuantity} Stk.) vollständig ins Lager gebucht`}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<CheckCircleOutlineOutlinedIcon sx={{ '&&': { fontSize: 16 } }} />}
|
||||||
|
label={`Gebucht (${summary.bookedPositions}/${summary.totalPositions})`}
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleOpenBookDialog(row);
|
||||||
|
}}
|
||||||
|
sx={{ fontWeight: 600, cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.status === 'partial') {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
title={`${summary.bookedPositions} von ${summary.totalPositions} Positionen gebucht (${summary.bookedQuantity} / ${summary.totalQuantity} Stk.)`}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<Inventory2OutlinedIcon sx={{ '&&': { fontSize: 16 } }} />}
|
||||||
|
label={`Teilweise (${summary.bookedPositions}/${summary.totalPositions})`}
|
||||||
|
color="warning"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => this.handleOpenBookDialog(row)}
|
||||||
|
sx={{ fontWeight: 600, cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="warning"
|
||||||
|
onClick={() => this.handleOpenBookDialog(row)}
|
||||||
|
sx={{
|
||||||
|
py: 0.2,
|
||||||
|
px: 1,
|
||||||
|
minHeight: 26,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rest buchen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: 'unbooked'
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
title={`Noch nicht ins Lager gebucht (${summary.totalPositions} Positionen, ${summary.totalQuantity} Stk.)`}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`Offen (${summary.totalPositions} Pos.)`}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => this.handleOpenBookDialog(row)}
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
borderColor: 'divider',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<Inventory2OutlinedIcon sx={{ fontSize: 16 }} />}
|
||||||
|
onClick={() => this.handleOpenBookDialog(row)}
|
||||||
|
sx={{
|
||||||
|
py: 0.2,
|
||||||
|
px: 1.2,
|
||||||
|
minHeight: 26,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ins Lager buchen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { status, error, docs, busyId } = this.state;
|
const { status, error, docs, busyId, bookingDoc, bookingOpen, ledgerSummaries } = this.state;
|
||||||
const cfg = this.cfg;
|
const cfg = this.cfg;
|
||||||
const partyField = cfg.partyField;
|
const partyField = cfg.partyField;
|
||||||
|
|
||||||
@@ -142,22 +357,43 @@ class ManifestList extends PureComponent {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.props.kind === 'delivery') {
|
||||||
|
columns.push({
|
||||||
|
key: 'ledgerStatus',
|
||||||
|
label: 'Lagerbuchung',
|
||||||
|
render: (row) => this.renderLedgerSummary(row),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RecordList
|
<>
|
||||||
status={status}
|
<RecordList
|
||||||
error={<BackendError error={error} onRetry={this.handleRetry} />}
|
status={status}
|
||||||
rows={docs}
|
error={<BackendError error={error} onRetry={this.handleRetry} />}
|
||||||
columns={columns}
|
rows={docs}
|
||||||
createLabel={cfg.createButton}
|
columns={columns}
|
||||||
onCreate={this.handleCreate}
|
createLabel={cfg.createButton}
|
||||||
onEdit={this.handleEdit}
|
onCreate={this.handleCreate}
|
||||||
onDelete={this.handleDelete}
|
onEdit={this.handleEdit}
|
||||||
deleteDisabled={busyId != null}
|
onDelete={this.handleDelete}
|
||||||
emptyMessage={cfg.emptyList}
|
deleteDisabled={busyId != null}
|
||||||
color={cfg.listColor}
|
emptyMessage={cfg.emptyList}
|
||||||
getEditLabel={(row) => `„${row.number}“ bearbeiten`}
|
color={cfg.listColor}
|
||||||
getDeleteLabel={(row) => `„${row.number}“ löschen`}
|
getEditLabel={(row) => `„${row.number}“ bearbeiten`}
|
||||||
/>
|
getDeleteLabel={(row) => `„${row.number}“ löschen`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{this.props.kind === 'delivery' && (
|
||||||
|
<BookDeliveryDialog
|
||||||
|
open={bookingOpen}
|
||||||
|
delivery={bookingDoc}
|
||||||
|
summary={bookingDoc ? ledgerSummaries[bookingDoc.id] : null}
|
||||||
|
partyName={bookingDoc ? this.partyName(bookingDoc[partyField]) : null}
|
||||||
|
onClose={this.handleCloseBookDialog}
|
||||||
|
onBookSuccess={this.handleBookSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export { default as AttachmentList } from './AttachmentList';
|
|||||||
export { default as TextEditorPanel } from './TextEditorPanel';
|
export { default as TextEditorPanel } from './TextEditorPanel';
|
||||||
export { default as PdfViewerPanel } from './PdfViewerPanel';
|
export { default as PdfViewerPanel } from './PdfViewerPanel';
|
||||||
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
|
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
|
||||||
|
export { default as BookDeliveryDialog } from './BookDeliveryDialog';
|
||||||
export { withManifestId } from './withManifestId';
|
export { withManifestId } from './withManifestId';
|
||||||
export {
|
export {
|
||||||
manifestConfig,
|
manifestConfig,
|
||||||
|
|||||||
@@ -80,44 +80,101 @@ export function emptyLine() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function lineFromApi(row) {
|
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) : '';
|
||||||
return {
|
return {
|
||||||
|
id: row.id,
|
||||||
key: `line-${row.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`}`,
|
key: `line-${row.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`}`,
|
||||||
amount: row.amount == null ? '' : String(row.amount),
|
amount: row.amount == null ? '' : String(row.amount),
|
||||||
sku: row.sku ?? '',
|
sku: row.sku ?? '',
|
||||||
tax: row.tax == null ? '' : String(row.tax),
|
tax: taxVal,
|
||||||
rebate: row.rebate == null ? '' : String(row.rebate),
|
rebate: row.rebate == null ? '' : String(row.rebate),
|
||||||
net: row.net == null ? '' : String(row.net),
|
net: netVal,
|
||||||
name: row.name ?? '',
|
name: row.name ?? '',
|
||||||
pu: row.pu == null ? '' : String(row.pu),
|
pu: row.pu == null ? '' : String(row.pu),
|
||||||
unit: row.unit ?? '',
|
unit: row.unit ?? '',
|
||||||
netprice: row.netprice != null ? String(row.netprice) : row.net != null ? String(row.net) : '',
|
netprice: netVal,
|
||||||
taxrate: row.taxrate != null ? String(row.taxrate) : row.tax != null ? String(row.tax) : '',
|
taxrate: taxVal,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNum(value) {
|
export function parseNum(value) {
|
||||||
if (value == null || value === '') return null;
|
if (value == null || value === '') return null;
|
||||||
const n = Number(String(value).trim().replace(',', '.'));
|
const cleaned = String(value).trim().replace('%', '').trim().replace(',', '.');
|
||||||
|
if (cleaned === '') return null;
|
||||||
|
const n = Number(cleaned);
|
||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFirstVal(...vals) {
|
||||||
|
for (const v of vals) {
|
||||||
|
if (v != null && String(v).trim() !== '') return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasArticleLines(lines) {
|
||||||
|
if (!Array.isArray(lines) || lines.length === 0) return false;
|
||||||
|
return lines.some((l) => {
|
||||||
|
if (!l) return false;
|
||||||
|
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();
|
||||||
|
return Boolean(sku || name || amount || net);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Build create/update line array. Throws Error on client validation. */
|
/** Build create/update line array. Throws Error on client validation. */
|
||||||
export function buildLines(lines, { requireAtLeastOne }) {
|
export function buildLines(lines, { requireAtLeastOne }) {
|
||||||
const outLines = [];
|
const outLines = [];
|
||||||
for (const line of lines) {
|
const errors = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
const sku = String(line.sku ?? '').trim();
|
const sku = String(line.sku ?? '').trim();
|
||||||
const name = String(line.name ?? '').trim();
|
const name = String(line.name ?? '').trim();
|
||||||
const amount = parseNum(line.amount);
|
const rawAmount = getFirstVal(line.amount, line.count);
|
||||||
const net = parseNum(line.net ?? line.netprice);
|
const amount = parseNum(rawAmount);
|
||||||
const tax = parseNum(line.tax ?? line.taxrate);
|
const rawNet = getFirstVal(line.net, line.netprice, line.netPrice, line.eknetto);
|
||||||
const rebate = parseNum(line.rebate);
|
const net = parseNum(rawNet);
|
||||||
const pu = parseNum(line.pu);
|
const rawTax = getFirstVal(line.tax, line.taxrate, line.taxRate, line.mwst);
|
||||||
|
const tax = parseNum(rawTax);
|
||||||
|
const rebate = parseNum(getFirstVal(line.rebate));
|
||||||
|
const rawPu = getFirstVal(line.pu, line.vpe);
|
||||||
|
const pu = parseNum(rawPu);
|
||||||
const blank =
|
const blank =
|
||||||
!sku && !name && amount == null && net == null && tax == null && rebate == null;
|
!sku && !name && amount == null && net == null && tax == null && rebate == null;
|
||||||
if (blank) continue;
|
if (blank) continue;
|
||||||
if ((!sku && !name) || amount == null || net == null || tax == null) {
|
|
||||||
throw new Error('Jede Zeile braucht SKU oder Name, Amount, Net und Taxrate.');
|
const missing = [];
|
||||||
|
if (!sku && !name) {
|
||||||
|
missing.push('Artikel (SKU oder Name)');
|
||||||
}
|
}
|
||||||
|
if (amount == null) {
|
||||||
|
missing.push(rawAmount != null ? 'gültige Menge' : 'Menge');
|
||||||
|
}
|
||||||
|
if ('pu' in line && pu == null) {
|
||||||
|
missing.push(rawPu != null ? 'gültige VPE (PU)' : 'VPE (PU)');
|
||||||
|
}
|
||||||
|
if (net == null) {
|
||||||
|
missing.push(rawNet != null ? 'gültiger Nettopreis' : 'Nettopreis');
|
||||||
|
}
|
||||||
|
if (tax == null) {
|
||||||
|
missing.push(rawTax != null ? 'gültiger Steuersatz' : 'Steuersatz');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const identifier = name || sku;
|
||||||
|
const rowLabel = `Zeile ${i + 1}${identifier ? ` („${identifier}“)` : ''}`;
|
||||||
|
const fieldsText =
|
||||||
|
missing.length === 1
|
||||||
|
? `${missing[0]} fehlt`
|
||||||
|
: `${missing.slice(0, -1).join(', ')} und ${missing[missing.length - 1]} fehlen`;
|
||||||
|
errors.push(`${rowLabel}: ${fieldsText}.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const row = { amount, net, taxrate: tax };
|
const row = { amount, net, taxrate: tax };
|
||||||
if (sku) row.sku = sku;
|
if (sku) row.sku = sku;
|
||||||
if (name) row.name = name;
|
if (name) row.name = name;
|
||||||
@@ -125,6 +182,18 @@ export function buildLines(lines, { requireAtLeastOne }) {
|
|||||||
if (pu != null) row.pu = pu;
|
if (pu != null) row.pu = pu;
|
||||||
outLines.push(row);
|
outLines.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
if (errors.length <= 3) {
|
||||||
|
throw new Error(errors.join(' '));
|
||||||
|
}
|
||||||
|
const visibleErrors = errors.slice(0, 3).join(' ');
|
||||||
|
const remainingCount = errors.length - 3;
|
||||||
|
throw new Error(
|
||||||
|
`${visibleErrors} (und ${remainingCount} weitere fehlerhafte Zeile${remainingCount === 1 ? '' : 'n'})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (requireAtLeastOne && outLines.length === 0) {
|
if (requireAtLeastOne && outLines.length === 0) {
|
||||||
throw new Error('Mindestens eine Artikelzeile nötig.');
|
throw new Error('Mindestens eine Artikelzeile nötig.');
|
||||||
}
|
}
|
||||||
@@ -480,14 +549,14 @@ export function manifestConfig(kind) {
|
|||||||
deletedMsg: 'Lieferung gelöscht.',
|
deletedMsg: 'Lieferung gelöscht.',
|
||||||
confirmDelete: (number) => `Lieferung „${number}“ wirklich löschen?`,
|
confirmDelete: (number) => `Lieferung „${number}“ wirklich löschen?`,
|
||||||
invalidId: 'Ungültige Lieferungs-ID.',
|
invalidId: 'Ungültige Lieferungs-ID.',
|
||||||
partyGateMessage: 'Lieferant wählen, um Artikel zu erfassen.',
|
partyGateMessage: null,
|
||||||
getPathLabel: 'GET /api/delivery/:id',
|
getPathLabel: 'GET /api/delivery/:id',
|
||||||
postPathLabel: 'POST /api/delivery',
|
postPathLabel: 'POST /api/delivery',
|
||||||
patchPathLabel: 'PATCH /api/delivery/:id',
|
patchPathLabel: 'PATCH /api/delivery/:id',
|
||||||
listColor: '#fed7aa',
|
listColor: '#fed7aa',
|
||||||
headerColor: '#fed7aa',
|
headerColor: '#fed7aa',
|
||||||
linesColor: '#bbf7d0',
|
linesColor: '#bbf7d0',
|
||||||
requirePartyBeforeLines: true,
|
requirePartyBeforeLines: false,
|
||||||
updateCache: 'updateDelivery',
|
updateCache: 'updateDelivery',
|
||||||
listPromise: (ctx) => ctx.deliveries ?? ctx.deliveryPromise,
|
listPromise: (ctx) => ctx.deliveries ?? ctx.deliveryPromise,
|
||||||
partiesByIdFromList: (parties) =>
|
partiesByIdFromList: (parties) =>
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ import CircularProgress from '@mui/material/CircularProgress';
|
|||||||
|
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
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 TableHead from '@mui/material/TableHead';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||||
import BackendError from '../../components/BackendError';
|
import BackendError from '../../components/BackendError';
|
||||||
@@ -24,6 +35,7 @@ import {
|
|||||||
TextEditorPanel,
|
TextEditorPanel,
|
||||||
PdfViewerPanel,
|
PdfViewerPanel,
|
||||||
ArticleSearchDialog,
|
ArticleSearchDialog,
|
||||||
|
BookDeliveryDialog,
|
||||||
} from '../../components/manifest';
|
} from '../../components/manifest';
|
||||||
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
|
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
|
||||||
import { loadParties } from '../../components/manifest/manifestConfig';
|
import { loadParties } from '../../components/manifest/manifestConfig';
|
||||||
@@ -36,7 +48,8 @@ import { generateTextThumbnail } from '../../utils/textThumbnail';
|
|||||||
import { extractPdfText } from '../../utils/pdfText';
|
import { extractPdfText } from '../../utils/pdfText';
|
||||||
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
||||||
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
|
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
|
||||||
import { delivery as deliveryApi } from '../../api';
|
import { delivery as deliveryApi, ledger as defaultLedgerApi } from '../../api';
|
||||||
|
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
|
||||||
|
|
||||||
class LieferungNeuPage extends PureComponent {
|
class LieferungNeuPage extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
static contextType = StorageManagerContext;
|
||||||
@@ -63,18 +76,26 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
articleSearchDialogOpen: false,
|
articleSearchDialogOpen: false,
|
||||||
articleSearchLine: null,
|
articleSearchLine: null,
|
||||||
unmatchedSupplierName: null,
|
unmatchedSupplierName: null,
|
||||||
|
ledgerSummary: null,
|
||||||
|
bookDeliveryDialogOpen: false,
|
||||||
|
bookDeliveryPrefillLineId: null,
|
||||||
};
|
};
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
this.handleRetry = this.handleRetry.bind(this);
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
this.handlePartyChange = this.handlePartyChange.bind(this);
|
this.handlePartyChange = this.handlePartyChange.bind(this);
|
||||||
this.handleCreateSupplierFromPdf = this.handleCreateSupplierFromPdf.bind(this);
|
this.handleCreateSupplierFromPdf = this.handleCreateSupplierFromPdf.bind(this);
|
||||||
this.handleDateChange = this.handleDateChange.bind(this);
|
this.handleDateChange = this.handleDateChange.bind(this);
|
||||||
|
this.handleOpenBookDialog = this.handleOpenBookDialog.bind(this);
|
||||||
|
this.handleBookLine = this.handleBookLine.bind(this);
|
||||||
|
this.handleCloseBookDialog = this.handleCloseBookDialog.bind(this);
|
||||||
|
this.handleBookSuccess = this.handleBookSuccess.bind(this);
|
||||||
|
|
||||||
this.handleNumberChange = this.handleNumberChange.bind(this);
|
this.handleNumberChange = this.handleNumberChange.bind(this);
|
||||||
this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this);
|
this.handleExternalNumberChange = this.handleExternalNumberChange.bind(this);
|
||||||
this.handleLineChange = this.handleLineChange.bind(this);
|
this.handleLineChange = this.handleLineChange.bind(this);
|
||||||
this.handleAddLine = this.handleAddLine.bind(this);
|
this.handleAddLine = this.handleAddLine.bind(this);
|
||||||
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
||||||
|
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
||||||
this.handleSave = this.handleSave.bind(this);
|
this.handleSave = this.handleSave.bind(this);
|
||||||
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
||||||
this.handlePdfRescan = this.handlePdfRescan.bind(this);
|
this.handlePdfRescan = this.handlePdfRescan.bind(this);
|
||||||
@@ -206,9 +227,22 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
|
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.attachments);
|
||||||
this.automat.actions.setAttachments(attachments);
|
this.automat.actions.setAttachments(attachments);
|
||||||
|
|
||||||
|
let ledgerSummary = null;
|
||||||
|
try {
|
||||||
|
const ledgerClient = this.context?.ledger || defaultLedgerApi;
|
||||||
|
const deliveryClient = this.context?.[cfg.api] || deliveryApi;
|
||||||
|
ledgerSummary = await fetchDeliveryLedgerSummary(docId, {
|
||||||
|
ledgerApi: ledgerClient,
|
||||||
|
deliveryApi: deliveryClient,
|
||||||
|
});
|
||||||
|
} catch (lErr) {
|
||||||
|
console.error('[neu] Failed to load ledger summary:', lErr);
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
partiesLoading: false,
|
partiesLoading: false,
|
||||||
|
ledgerSummary,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
@@ -220,6 +254,43 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleOpenBookDialog() {
|
||||||
|
this.setState({ bookDeliveryDialogOpen: true, bookDeliveryPrefillLineId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBookLine(ledgerLine) {
|
||||||
|
this.setState({
|
||||||
|
bookDeliveryDialogOpen: true,
|
||||||
|
bookDeliveryPrefillLineId: ledgerLine?.id ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseBookDialog() {
|
||||||
|
this.setState({ bookDeliveryDialogOpen: false, bookDeliveryPrefillLineId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadLedgerSummary() {
|
||||||
|
const { docId } = this.props;
|
||||||
|
if (docId == null) return;
|
||||||
|
try {
|
||||||
|
const ledgerClient = this.context?.ledger || defaultLedgerApi;
|
||||||
|
const deliveryClient = this.context?.[this.cfg.api] || deliveryApi;
|
||||||
|
const ledgerSummary = await fetchDeliveryLedgerSummary(docId, {
|
||||||
|
ledgerApi: ledgerClient,
|
||||||
|
deliveryApi: deliveryClient,
|
||||||
|
});
|
||||||
|
if (this._cancelled) return;
|
||||||
|
this.setState({ ledgerSummary });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[neu] Failed to reload ledger summary:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBookSuccess(updatedSummary) {
|
||||||
|
this.setState({ ledgerSummary: updatedSummary });
|
||||||
|
void this.reloadLedgerSummary();
|
||||||
|
}
|
||||||
|
|
||||||
handleRetry() {
|
handleRetry() {
|
||||||
if (this.props.docId != null) void this.loadEdit();
|
if (this.props.docId != null) void this.loadEdit();
|
||||||
else void this.loadPartiesOnly();
|
else void this.loadPartiesOnly();
|
||||||
@@ -271,7 +342,9 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
key,
|
key,
|
||||||
[field]: value,
|
[field]: value,
|
||||||
...(field === 'net' ? { netprice: value } : {}),
|
...(field === 'net' ? { netprice: value } : {}),
|
||||||
|
...(field === 'netprice' ? { net: value } : {}),
|
||||||
...(field === 'tax' ? { taxrate: value } : {}),
|
...(field === 'tax' ? { taxrate: value } : {}),
|
||||||
|
...(field === 'taxrate' ? { tax: value } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (field === 'sku') {
|
if (field === 'sku') {
|
||||||
@@ -339,7 +412,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
entityId: partyId,
|
entityId: partyId,
|
||||||
sku: supplierSku || undefined,
|
sku: supplierSku || undefined,
|
||||||
pu: line.pu ? Number(line.pu) : undefined,
|
pu: line.pu ? Number(line.pu) : undefined,
|
||||||
stock: line.amount ? Number(line.amount) : undefined,
|
stock: 0,
|
||||||
netPrice: line.netprice ? Number(line.netprice) : undefined,
|
netPrice: line.netprice ? Number(line.netprice) : undefined,
|
||||||
taxRate: line.taxrate ? Number(line.taxrate) : undefined,
|
taxRate: line.taxrate ? Number(line.taxrate) : undefined,
|
||||||
});
|
});
|
||||||
@@ -389,7 +462,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
entityId: partyId,
|
entityId: partyId,
|
||||||
sku: supplierSku || undefined,
|
sku: supplierSku || undefined,
|
||||||
pu: line?.pu ? Number(line.pu) : undefined,
|
pu: line?.pu ? Number(line.pu) : undefined,
|
||||||
stock: line?.amount ? Number(line.amount) : undefined,
|
stock: 0,
|
||||||
netPrice: line?.netprice ? Number(line.netprice) : undefined,
|
netPrice: line?.netprice ? Number(line.netprice) : undefined,
|
||||||
taxRate: line?.taxrate ? Number(line.taxrate) : undefined,
|
taxRate: line?.taxrate ? Number(line.taxrate) : undefined,
|
||||||
});
|
});
|
||||||
@@ -445,6 +518,10 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
this.automat.actions.removeLineitem(index >= 0 ? index : key);
|
this.automat.actions.removeLineitem(index >= 0 ? index : key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleRemoveAllLines() {
|
||||||
|
this.automat.actions.clearLineitems();
|
||||||
|
}
|
||||||
|
|
||||||
async handlePdfUpload(file) {
|
async handlePdfUpload(file) {
|
||||||
if (!file || this.state.pdfUploading) return;
|
if (!file || this.state.pdfUploading) return;
|
||||||
|
|
||||||
@@ -661,18 +738,26 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
? result.items.items
|
? result.items.items
|
||||||
: null;
|
: null;
|
||||||
const newLines = Array.isArray(rawItems)
|
const newLines = Array.isArray(rawItems)
|
||||||
? rawItems.map((l, idx) => ({
|
? rawItems.map((l, idx) => {
|
||||||
key: l.key ?? `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
const taxVal = (l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst) != null
|
||||||
name: l.name ?? l.articleName ?? null,
|
? String(l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst)
|
||||||
sku: l.sku ?? l.liefArtNr ?? '',
|
: '';
|
||||||
amount: (l.amount ?? l.count) != null ? String(l.amount ?? l.count) : '',
|
const netVal = (l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
|
||||||
pu: (l.pu ?? l.vpe) != null ? String(l.pu ?? l.vpe) : '',
|
? String(l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
|
||||||
unit: l.unit ?? '',
|
: '';
|
||||||
taxrate: (l.taxrate ?? l.tax ?? l.mwst) != null ? String(l.taxrate ?? l.tax ?? l.mwst) : '',
|
return {
|
||||||
netprice: (l.netprice ?? l.net ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
|
key: l.key ?? `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
? String(l.netprice ?? l.net ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
|
name: l.name ?? l.articleName ?? null,
|
||||||
: '',
|
sku: l.sku ?? l.liefArtNr ?? '',
|
||||||
}))
|
amount: (l.amount ?? l.count) != null ? String(l.amount ?? l.count) : '',
|
||||||
|
pu: (l.pu ?? l.vpe) != null ? String(l.pu ?? l.vpe) : '',
|
||||||
|
unit: l.unit ?? '',
|
||||||
|
taxrate: taxVal,
|
||||||
|
tax: taxVal,
|
||||||
|
netprice: netVal,
|
||||||
|
net: netVal,
|
||||||
|
};
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
this.setState((prev) => ({
|
this.setState((prev) => ({
|
||||||
@@ -1108,6 +1193,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
if (doc.number != null) this.automat.actions.setNumber(doc.number);
|
if (doc.number != null) this.automat.actions.setNumber(doc.number);
|
||||||
if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
|
if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
|
||||||
this.automat.actions.setLineitems(lines);
|
this.automat.actions.setLineitems(lines);
|
||||||
|
void this.reloadLedgerSummary();
|
||||||
|
|
||||||
// Restore attachments with thumbnails
|
// Restore attachments with thumbnails
|
||||||
const apiFiles = doc.files ?? [];
|
const apiFiles = doc.files ?? [];
|
||||||
@@ -1157,18 +1243,15 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ledgerLinesById = {};
|
||||||
|
if (Array.isArray(this.state.ledgerSummary?.lines)) {
|
||||||
|
for (const l of this.state.ledgerSummary.lines) {
|
||||||
|
ledgerLinesById[l.id] = l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitleRight>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
disabled={saving}
|
|
||||||
onClick={this.handleSave}
|
|
||||||
>
|
|
||||||
{saving ? 'Speichern…' : 'Speichern'}
|
|
||||||
</Button>
|
|
||||||
</PageTitleRight>
|
|
||||||
<PanelTileGrid>
|
<PanelTileGrid>
|
||||||
<ManifestHeader
|
<ManifestHeader
|
||||||
kind="delivery"
|
kind="delivery"
|
||||||
@@ -1184,95 +1267,73 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
saving={saving}
|
saving={saving}
|
||||||
pdfUploading={pdfUploading}
|
pdfUploading={pdfUploading}
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
|
lines={lines}
|
||||||
onPdfUpload={this.handlePdfUpload}
|
onPdfUpload={this.handlePdfUpload}
|
||||||
onPdfRescan={this.handlePdfRescan}
|
onPdfRescan={this.handlePdfRescan}
|
||||||
onPdfDelete={this.handlePdfDelete}
|
onPdfDelete={this.handlePdfDelete}
|
||||||
onAttachmentClick={this.handleAttachmentClick}
|
onAttachmentClick={this.handleAttachmentClick}
|
||||||
|
onSave={this.handleSave}
|
||||||
onPartyChange={this.handlePartyChange}
|
onPartyChange={this.handlePartyChange}
|
||||||
onDateChange={this.handleDateChange}
|
onDateChange={this.handleDateChange}
|
||||||
onNumberChange={this.handleNumberChange}
|
onNumberChange={this.handleNumberChange}
|
||||||
onExternalNumberChange={this.handleExternalNumberChange}
|
onExternalNumberChange={this.handleExternalNumberChange}
|
||||||
|
docId={this.props.docId}
|
||||||
|
ledgerSummary={this.state.ledgerSummary}
|
||||||
|
onOpenBookDialog={this.handleOpenBookDialog}
|
||||||
/>
|
/>
|
||||||
{this.state.viewerPosition === 'top' && this.renderViewerPanels(3)}
|
{this.state.viewerPosition === 'top' && this.renderViewerPanels(4)}
|
||||||
{partyId == null && (!lines || lines.length === 0) ? (
|
{this.state.unmatchedSupplierName && (
|
||||||
<PanelTile wide={3} minHeight={160} color="#e2e8f0">
|
<PanelTile wide={4} color="#fef3c7" sx={{ gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } }}>
|
||||||
{this.state.unmatchedSupplierName ? (
|
<Alert
|
||||||
<Stack spacing={2} sx={{ p: 1 }}>
|
severity="warning"
|
||||||
<Typography color="text.secondary">
|
action={
|
||||||
Lieferant <strong>„{this.state.unmatchedSupplierName}“</strong> wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
|
<Button
|
||||||
</Typography>
|
color="inherit"
|
||||||
<Box>
|
size="small"
|
||||||
<Button
|
variant="outlined"
|
||||||
variant="contained"
|
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
|
||||||
size="small"
|
|
||||||
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
|
|
||||||
>
|
|
||||||
Lieferant „{this.state.unmatchedSupplierName}“ anlegen
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<Typography color="text.secondary">
|
|
||||||
{cfg.partyGateMessage}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</PanelTile>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{partyId == null && (
|
|
||||||
<PanelTile wide={3} color="#fef3c7">
|
|
||||||
<Alert
|
|
||||||
severity="warning"
|
|
||||||
action={
|
|
||||||
this.state.unmatchedSupplierName ? (
|
|
||||||
<Button
|
|
||||||
color="inherit"
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
|
|
||||||
>
|
|
||||||
Lieferant anlegen
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
sx={{ py: 0.5, alignItems: 'center' }}
|
|
||||||
>
|
>
|
||||||
{this.state.unmatchedSupplierName
|
Lieferant anlegen
|
||||||
? `Lieferant „${this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.`
|
</Button>
|
||||||
: 'Bitte wählen Sie oben einen Lieferanten aus, um die erfassten Positionen speichern zu können.'}
|
|
||||||
</Alert>
|
|
||||||
</PanelTile>
|
|
||||||
)}
|
|
||||||
<ManifestLines
|
|
||||||
columns={DELIVERY_LINE_COLUMNS}
|
|
||||||
lines={lines}
|
|
||||||
saving={saving}
|
|
||||||
wide={
|
|
||||||
this.state.viewerPosition === 'right' && this.isViewerOpen()
|
|
||||||
? 2
|
|
||||||
: 3
|
|
||||||
}
|
}
|
||||||
linesColor={cfg.linesColor}
|
sx={{ py: 0.5, alignItems: 'center' }}
|
||||||
supplierArticlesMap={this.state.supplierArticlesMap}
|
>
|
||||||
supplierArticlesByLineKey={this.state.supplierArticlesByLineKey}
|
Lieferant „{this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
|
||||||
supplierArticlesLoading={this.state.supplierArticlesLoading}
|
</Alert>
|
||||||
loadingLineKeys={this.state.loadingLineKeys}
|
</PanelTile>
|
||||||
onLineChange={this.handleLineChange}
|
|
||||||
onSkuBlur={this.handleSkuBlur}
|
|
||||||
onAddLine={this.handleAddLine}
|
|
||||||
onRemoveLine={this.handleRemoveLine}
|
|
||||||
onSave={() => void this.handleSave()}
|
|
||||||
onCreateArticle={this.handleCreateArticleForLine}
|
|
||||||
onSearchArticle={this.handleOpenArticleSearchForLine}
|
|
||||||
onRemoveSupplierArticle={this.handleRemoveSupplierArticle}
|
|
||||||
busySupplierArticleLineKeys={this.state.busySupplierArticleLineKeys}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<ManifestLines
|
||||||
|
columns={DELIVERY_LINE_COLUMNS}
|
||||||
|
lines={lines}
|
||||||
|
saving={saving}
|
||||||
|
wide={
|
||||||
|
this.state.viewerPosition === 'right' && this.isViewerOpen()
|
||||||
|
? 2
|
||||||
|
: 4
|
||||||
|
}
|
||||||
|
linesColor={cfg.linesColor}
|
||||||
|
supplierArticlesMap={this.state.supplierArticlesMap}
|
||||||
|
supplierArticlesByLineKey={this.state.supplierArticlesByLineKey}
|
||||||
|
supplierArticlesLoading={this.state.supplierArticlesLoading}
|
||||||
|
loadingLineKeys={this.state.loadingLineKeys}
|
||||||
|
onLineChange={this.handleLineChange}
|
||||||
|
onSkuBlur={this.handleSkuBlur}
|
||||||
|
onAddLine={this.handleAddLine}
|
||||||
|
onRemoveLine={this.handleRemoveLine}
|
||||||
|
onRemoveAllLines={this.handleRemoveAllLines}
|
||||||
|
onSave={() => void this.handleSave()}
|
||||||
|
onCreateArticle={this.handleCreateArticleForLine}
|
||||||
|
onSearchArticle={this.handleOpenArticleSearchForLine}
|
||||||
|
onRemoveSupplierArticle={this.handleRemoveSupplierArticle}
|
||||||
|
busySupplierArticleLineKeys={this.state.busySupplierArticleLineKeys}
|
||||||
|
ledgerLinesById={ledgerLinesById}
|
||||||
|
onBookLine={this.handleBookLine}
|
||||||
|
/>
|
||||||
{this.state.viewerPosition === 'right' && this.renderViewerPanels(2)}
|
{this.state.viewerPosition === 'right' && this.renderViewerPanels(2)}
|
||||||
{this.state.viewerPosition === 'below' && this.renderViewerPanels(3)}
|
{this.state.viewerPosition === 'below' && this.renderViewerPanels(4)}
|
||||||
|
{this.props.docId != null && this.renderLedgerPanel()}
|
||||||
{notes && notes.length > 0 && (
|
{notes && notes.length > 0 && (
|
||||||
<PanelTile wide={3} color="#fffbeb">
|
<PanelTile wide={4} color="#fffbeb" sx={{ gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } }}>
|
||||||
<Typography variant="subtitle2" fontWeight={700} color="warning.dark" sx={{ mb: 1.5 }}>
|
<Typography variant="subtitle2" fontWeight={700} color="warning.dark" sx={{ mb: 1.5 }}>
|
||||||
Hinweise zur PDF-Analyse ({notes.length})
|
Hinweise zur PDF-Analyse ({notes.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -1302,9 +1363,240 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
onClose={this.handleCloseArticleSearch}
|
onClose={this.handleCloseArticleSearch}
|
||||||
onAssign={this.handleAssignArticleForLine}
|
onAssign={this.handleAssignArticleForLine}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{this.props.docId != null && (
|
||||||
|
<BookDeliveryDialog
|
||||||
|
open={this.state.bookDeliveryDialogOpen}
|
||||||
|
delivery={{
|
||||||
|
id: this.props.docId,
|
||||||
|
number: this.state.number,
|
||||||
|
externalNumber: this.state.externalNumber,
|
||||||
|
date: this.state.date,
|
||||||
|
supplierId: this.state.partyId,
|
||||||
|
}}
|
||||||
|
summary={this.state.ledgerSummary}
|
||||||
|
prefillLineId={this.state.bookDeliveryPrefillLineId}
|
||||||
|
partyName={this.state.deliveryEntities?.find((p) => p.id === this.state.partyId)?.name}
|
||||||
|
onClose={this.handleCloseBookDialog}
|
||||||
|
onBookSuccess={this.handleBookSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderLedgerPanel() {
|
||||||
|
const { ledgerSummary } = this.state;
|
||||||
|
if (!ledgerSummary) return null;
|
||||||
|
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
totalPositions,
|
||||||
|
bookedPositions,
|
||||||
|
totalQuantity,
|
||||||
|
bookedQuantity,
|
||||||
|
lines,
|
||||||
|
} = ledgerSummary;
|
||||||
|
|
||||||
|
const allEntries = [];
|
||||||
|
if (Array.isArray(lines)) {
|
||||||
|
for (const line of lines) {
|
||||||
|
if (Array.isArray(line.ledgerEntries)) {
|
||||||
|
for (const entry of line.ledgerEntries) {
|
||||||
|
allEntries.push({
|
||||||
|
...entry,
|
||||||
|
lineSku: line.sku,
|
||||||
|
lineName: line.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allEntries.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelTile
|
||||||
|
wide={4}
|
||||||
|
title="Lagerstatus (Ledger)"
|
||||||
|
color="#f8fafc"
|
||||||
|
sx={{
|
||||||
|
gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' },
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor:
|
||||||
|
status === 'booked'
|
||||||
|
? 'success.light'
|
||||||
|
: status === 'partial'
|
||||||
|
? 'warning.light'
|
||||||
|
: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 3,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, alignItems: 'center' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Status
|
||||||
|
</Typography>
|
||||||
|
{status === 'booked' && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<CheckCircleOutlineOutlinedIcon />}
|
||||||
|
label="Vollständig gebucht"
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontWeight: 600, height: 26 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status === 'partial' && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<Inventory2OutlinedIcon />}
|
||||||
|
label={`Teilweise gebucht (${bookedPositions}/${totalPositions})`}
|
||||||
|
color="warning"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontWeight: 600, height: 26 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status === 'unbooked' && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`Offen (${totalPositions} Positionen)`}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ color: 'text.secondary', borderColor: 'divider', fontWeight: 500, height: 26 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status === 'empty' && (
|
||||||
|
<Chip size="small" label="Keine Artikel" variant="outlined" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Positionen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{bookedPositions} / {totalPositions} gebucht
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Menge
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{bookedQuantity} / {totalQuantity} Stk. gebucht
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{totalQuantity > bookedQuantity && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Noch offen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'warning.dark' }}>
|
||||||
|
{totalQuantity - bookedQuantity} Stk.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={status === 'booked' ? 'outlined' : 'contained'}
|
||||||
|
color={status === 'booked' ? 'success' : status === 'partial' ? 'warning' : 'primary'}
|
||||||
|
startIcon={
|
||||||
|
status === 'booked' ? (
|
||||||
|
<CheckCircleOutlineOutlinedIcon />
|
||||||
|
) : (
|
||||||
|
<Inventory2OutlinedIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={this.handleOpenBookDialog}
|
||||||
|
sx={{ fontWeight: 600, textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
{status === 'booked'
|
||||||
|
? 'Buchungsdetails anzeigen'
|
||||||
|
: status === 'partial'
|
||||||
|
? 'Restliche Mengen einbuchen'
|
||||||
|
: 'Ins Lager buchen'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{allEntries.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', mb: 1, display: 'block' }}>
|
||||||
|
Bisherige Ledger-Buchungen ({allEntries.length}):
|
||||||
|
</Typography>
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead sx={{ bgcolor: '#f1f5f9' }}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Zeitpunkt</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Typ</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>SKU / Artikel</TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontWeight: 600, py: 0.5 }}>Menge</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Buchungstext</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600, py: 0.5, fontFamily: 'monospace', fontSize: '0.75rem' }}>Prüfsumme (prev_hash)</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{allEntries.map((entry) => (
|
||||||
|
<TableRow key={entry.id} hover>
|
||||||
|
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
|
||||||
|
<Typography variant="caption">
|
||||||
|
{entry.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '–'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={entry.transaction_type}
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 0.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>
|
||||||
|
{entry.lineSku || entry.sku || '–'}
|
||||||
|
</Typography>
|
||||||
|
{entry.lineName && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
|
||||||
|
{entry.lineName}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" sx={{ py: 0.5, fontWeight: 700, color: 'success.main' }}>
|
||||||
|
+{entry.amount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 0.5 }}>
|
||||||
|
<Typography variant="caption">
|
||||||
|
{entry.transaction_text || '–'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 0.5, fontFamily: 'monospace', fontSize: '0.7rem', color: 'text.secondary' }}>
|
||||||
|
<Tooltip title={entry.prev_hash}>
|
||||||
|
<span>{entry.prev_hash ? `${entry.prev_hash.slice(0, 16)}…` : '–'}</span>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</PanelTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withNavigate(LieferungNeuPage);
|
export default withNavigate(LieferungNeuPage);
|
||||||
|
|||||||
162
src/utils/deliveryLedger.js
Normal file
162
src/utils/deliveryLedger.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { delivery as defaultDeliveryApi, ledger as defaultLedgerApi } from '../api';
|
||||||
|
import artikelAutomat from '../automats/artikelAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DeliveryLineLedgerStatus
|
||||||
|
* @property {number} id - Manifestarticle ID
|
||||||
|
* @property {string} sku - Article SKU
|
||||||
|
* @property {string} name - Article Name
|
||||||
|
* @property {number} targetAmount - Expected delivery quantity
|
||||||
|
* @property {number} bookedAmount - Quantity booked in ledger
|
||||||
|
* @property {number} remainingAmount - Quantity remaining to be booked
|
||||||
|
* @property {boolean} isBooked - Whether this position is fully booked
|
||||||
|
* @property {Array<Object>} ledgerEntries - Ledger entries for this line
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DeliveryLedgerSummary
|
||||||
|
* @property {number} deliveryId
|
||||||
|
* @property {Object} delivery - Full delivery object with lines
|
||||||
|
* @property {'empty' | 'booked' | 'partial' | 'unbooked'} status
|
||||||
|
* @property {number} totalPositions
|
||||||
|
* @property {number} bookedPositions
|
||||||
|
* @property {number} totalQuantity
|
||||||
|
* @property {number} bookedQuantity
|
||||||
|
* @property {Array<DeliveryLineLedgerStatus>} lines
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches delivery lines and checks ledger entries for each line to compute the booking status.
|
||||||
|
*
|
||||||
|
* @param {number|string} deliveryId
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {Object} [options.deliveryApi]
|
||||||
|
* @param {Object} [options.ledgerApi]
|
||||||
|
* @returns {Promise<DeliveryLedgerSummary>}
|
||||||
|
*/
|
||||||
|
export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = defaultDeliveryApi, ledgerApi = defaultLedgerApi } = {}) {
|
||||||
|
const res = await deliveryApi.get(deliveryId);
|
||||||
|
const delivery = res?.delivery ?? res;
|
||||||
|
const rawLines = Array.isArray(delivery?.lines) ? delivery.lines : [];
|
||||||
|
|
||||||
|
if (rawLines.length === 0) {
|
||||||
|
return {
|
||||||
|
deliveryId,
|
||||||
|
delivery,
|
||||||
|
status: 'empty',
|
||||||
|
totalPositions: 0,
|
||||||
|
bookedPositions: 0,
|
||||||
|
totalQuantity: 0,
|
||||||
|
bookedQuantity: 0,
|
||||||
|
lines: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch ledger entries for each line in parallel
|
||||||
|
const linesWithLedger = await Promise.all(
|
||||||
|
rawLines.map(async (line) => {
|
||||||
|
let ledgerEntries = [];
|
||||||
|
try {
|
||||||
|
const ledgerRes = await ledgerApi.listByManifestarticleId(line.id, 'DELIVERY');
|
||||||
|
ledgerEntries = Array.isArray(ledgerRes?.ledger) ? ledgerRes.ledger : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[deliveryLedger] Failed to fetch ledger for line ${line.id}:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliveryEntries = ledgerEntries.filter(
|
||||||
|
(e) => String(e.transaction_type).toUpperCase() === 'DELIVERY'
|
||||||
|
);
|
||||||
|
const bookedAmount = deliveryEntries.reduce((sum, e) => sum + Number(e.amount || 0), 0);
|
||||||
|
const targetAmount = Number(line.amount || 0);
|
||||||
|
const remainingAmount = Math.max(0, targetAmount - bookedAmount);
|
||||||
|
const isBooked = targetAmount > 0 ? bookedAmount >= targetAmount : bookedAmount > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
targetAmount,
|
||||||
|
bookedAmount,
|
||||||
|
remainingAmount,
|
||||||
|
isBooked,
|
||||||
|
ledgerEntries: deliveryEntries,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPositions = linesWithLedger.length;
|
||||||
|
const bookedPositions = linesWithLedger.filter((l) => l.isBooked).length;
|
||||||
|
const totalQuantity = linesWithLedger.reduce((sum, l) => sum + l.targetAmount, 0);
|
||||||
|
const bookedQuantity = linesWithLedger.reduce((sum, l) => sum + l.bookedAmount, 0);
|
||||||
|
|
||||||
|
let status = 'unbooked';
|
||||||
|
if (totalPositions === 0) {
|
||||||
|
status = 'empty';
|
||||||
|
} else if (bookedPositions === totalPositions && bookedQuantity >= totalQuantity) {
|
||||||
|
status = 'booked';
|
||||||
|
} else if (bookedPositions > 0 || bookedQuantity > 0) {
|
||||||
|
status = 'partial';
|
||||||
|
} else {
|
||||||
|
status = 'unbooked';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deliveryId,
|
||||||
|
delivery,
|
||||||
|
status,
|
||||||
|
totalPositions,
|
||||||
|
bookedPositions,
|
||||||
|
totalQuantity,
|
||||||
|
bookedQuantity,
|
||||||
|
lines: linesWithLedger,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Books specified delivery lines to the ledger as goods receipts (DELIVERY).
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {Object} params.delivery - The delivery object (used for transaction memo and fallback)
|
||||||
|
* @param {Array<{ manifestarticleId?: number, id?: number, amount: number, sku?: string }>} params.linesToBook
|
||||||
|
* @param {Object} [params.ledgerApi]
|
||||||
|
* @param {string} [params.transactionText]
|
||||||
|
* @returns {Promise<Array<Object>>}
|
||||||
|
*/
|
||||||
|
export async function bookDeliveryLines({
|
||||||
|
delivery,
|
||||||
|
linesToBook,
|
||||||
|
ledgerApi = defaultLedgerApi,
|
||||||
|
transactionText,
|
||||||
|
}) {
|
||||||
|
if (!Array.isArray(linesToBook) || linesToBook.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const memo = String(
|
||||||
|
transactionText != null && transactionText !== ''
|
||||||
|
? transactionText
|
||||||
|
: `Lieferung ${delivery?.number || delivery?.id || ''}`
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const item of linesToBook) {
|
||||||
|
const manifestarticleId = Number(item.manifestarticleId ?? item.id);
|
||||||
|
const amount = Number(item.amount);
|
||||||
|
|
||||||
|
if (!manifestarticleId || !Number.isFinite(amount) || amount <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ledgerApi.add({
|
||||||
|
manifestarticle_id: manifestarticleId,
|
||||||
|
transaction_type: 'DELIVERY',
|
||||||
|
amount,
|
||||||
|
transaction_text: memo,
|
||||||
|
});
|
||||||
|
results.push(res?.ledger ?? res);
|
||||||
|
|
||||||
|
if (item.sku) {
|
||||||
|
void artikelAutomat.actions.reloadArticle(item.sku).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -158,6 +158,55 @@ const sharedServer = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function nginxHeadersPreviewPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'nginx-headers-preview',
|
||||||
|
configurePreviewServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
const url = (req.url || '').split('?')[0];
|
||||||
|
|
||||||
|
// Never touch API requests (proxied to backend)
|
||||||
|
if (url.startsWith('/api')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Security Headers (from deploy/nginx-dashfront.conf lines 72-76) ---
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
res.setHeader('Permissions-Policy', 'camera=(self), microphone=(), geolocation=()');
|
||||||
|
|
||||||
|
// --- Caching Rules (from deploy/nginx-dashfront.conf lines 129-166) ---
|
||||||
|
if (/^\/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$/.test(url)) {
|
||||||
|
// Zero caching for Service Worker & registration scripts
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
res.setHeader('Expires', '0');
|
||||||
|
} else if (/^\/(manifest\.webmanifest|favicon\.svg|icons\/.*)$/.test(url)) {
|
||||||
|
// 1 hour for web manifest & icons
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||||
|
res.setHeader('Vary', 'Accept-Encoding');
|
||||||
|
} else if (url.startsWith('/assets/')) {
|
||||||
|
// 1 year immutable for hashed assets
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
res.setHeader('Vary', 'Accept-Encoding');
|
||||||
|
} else if (url.endsWith('.wasm')) {
|
||||||
|
// 1 year immutable for WASM binaries
|
||||||
|
res.setHeader('Content-Type', 'application/wasm');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
} else if (url === '/' || url === '/index.html' || !path.extname(url)) {
|
||||||
|
// HTML5 SPA Entry / Fallback: Zero caching
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
res.setHeader('Expires', '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig(({ command }) => ({
|
export default defineConfig(({ command }) => ({
|
||||||
resolve: sharedResolve,
|
resolve: sharedResolve,
|
||||||
build: {
|
build: {
|
||||||
@@ -231,6 +280,7 @@ export default defineConfig(({ command }) => ({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
nginxHeadersPreviewPlugin(),
|
||||||
...(command === 'build'
|
...(command === 'build'
|
||||||
? [
|
? [
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
|||||||
Reference in New Issue
Block a user