u
This commit is contained in:
@@ -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 = {
|
||||
/** 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).
|
||||
* Summary = { id, number, type: 'quote', entityId, date }
|
||||
@@ -1204,6 +1256,7 @@ export default {
|
||||
customergroup,
|
||||
delivery,
|
||||
entity,
|
||||
ledger,
|
||||
number,
|
||||
order,
|
||||
posterminal,
|
||||
|
||||
@@ -55,6 +55,8 @@ import { article as articleApi, supplier as supplierApi } from '../api';
|
||||
* @returns {DeliveryLine} Normalized line.
|
||||
*/
|
||||
export function createEmptyLineitem(line = {}) {
|
||||
const netVal = line.netprice ?? line.net ?? null;
|
||||
const taxVal = line.taxrate ?? line.tax ?? null;
|
||||
return {
|
||||
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: line.name ?? null,
|
||||
@@ -62,8 +64,10 @@ export function createEmptyLineitem(line = {}) {
|
||||
amount: line.amount ?? null,
|
||||
pu: line.pu ?? null,
|
||||
unit: line.unit ?? null,
|
||||
netprice: line.netprice ?? null,
|
||||
taxrate: line.taxrate ?? null,
|
||||
netprice: netVal,
|
||||
net: netVal,
|
||||
taxrate: taxVal,
|
||||
tax: taxVal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -357,6 +361,20 @@ export function createDeliveryEditorAutomat(options = {}) {
|
||||
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.
|
||||
* @param {string|number|null} [targetEntityId] Supplier to query.
|
||||
@@ -658,6 +676,8 @@ export const {
|
||||
updateLine,
|
||||
removeLineitem,
|
||||
removeLine,
|
||||
clearLineitems,
|
||||
clearLines,
|
||||
fetchSupplierArticles,
|
||||
fetchSupplierArticleForSku,
|
||||
unlinkSupplierArticle,
|
||||
|
||||
@@ -9,6 +9,7 @@ import SyncIcon from '@mui/icons-material/Sync';
|
||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
||||
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
|
||||
import { generateTextThumbnail } from '../../utils/textThumbnail';
|
||||
import { notifyErrorMessage } from '../../utils/notify';
|
||||
|
||||
export class Attachment extends PureComponent {
|
||||
state = {
|
||||
@@ -47,6 +48,12 @@ export class Attachment extends PureComponent {
|
||||
|
||||
handleRescan = (e) => {
|
||||
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;
|
||||
if (attachment && onRescan) {
|
||||
onRescan(attachment);
|
||||
@@ -90,7 +97,7 @@ export class Attachment extends PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { attachment, disabled, sx } = this.props;
|
||||
const { attachment, disabled, sx, hasArticles } = this.props;
|
||||
const thumbnail = attachment?.thumbnail || this.state.generatedThumbnail;
|
||||
const name = attachment?.name || 'Dokument';
|
||||
const size = attachment?.size;
|
||||
@@ -211,18 +218,39 @@ export class Attachment extends PureComponent {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isPdf || isText ? (
|
||||
<Tooltip title="Neu analysieren">
|
||||
<span>
|
||||
<Tooltip
|
||||
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
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
disabled={disabled || hasArticles}
|
||||
onClick={this.handleRescan}
|
||||
sx={{
|
||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: '#fff',
|
||||
color: hasArticles ? 'rgba(255, 255, 255, 0.3)' : '#fff',
|
||||
p: 0.5,
|
||||
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 }} />
|
||||
|
||||
@@ -6,7 +6,7 @@ import Attachment from './Attachment';
|
||||
|
||||
export class AttachmentList extends PureComponent {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export class AttachmentList extends PureComponent {
|
||||
fontWeight={600}
|
||||
sx={{ mb: 1, display: 'block', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
>
|
||||
Dateianhänge ({attachments.length})
|
||||
Dateianhänge
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1.5} sx={{ flexWrap: 'wrap', gap: 1.5 }}>
|
||||
{attachments.map((attachment, index) => (
|
||||
@@ -30,6 +30,7 @@ export class AttachmentList extends PureComponent {
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
hasArticles={hasArticles}
|
||||
/>
|
||||
))}
|
||||
</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.handleAddLine = this.handleAddLine.bind(this);
|
||||
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
||||
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
||||
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
||||
@@ -215,6 +216,10 @@ class ManifestForm extends PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
handleRemoveAllLines() {
|
||||
this.setState({ lines: [] });
|
||||
}
|
||||
|
||||
async handlePdfUpload(file) {
|
||||
if (!file || this.state.pdfUploading) return;
|
||||
if (this._parseAbortController) {
|
||||
@@ -410,33 +415,20 @@ class ManifestForm extends PureComponent {
|
||||
}
|
||||
|
||||
renderArticles() {
|
||||
const { mode } = this.props;
|
||||
const cfg = this.cfg;
|
||||
const { partyId, 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>
|
||||
);
|
||||
}
|
||||
const { lines, saving } = this.state;
|
||||
|
||||
return (
|
||||
<ManifestLines
|
||||
columns={cfg.columns}
|
||||
lines={lines}
|
||||
saving={saving}
|
||||
wide={4}
|
||||
linesColor={cfg.linesColor}
|
||||
onLineChange={this.handleLineChange}
|
||||
onAddLine={this.handleAddLine}
|
||||
onRemoveLine={this.handleRemoveLine}
|
||||
onRemoveAllLines={this.handleRemoveAllLines}
|
||||
onSave={() => void this.handleSave()}
|
||||
/>
|
||||
);
|
||||
@@ -475,18 +467,6 @@ class ManifestForm extends PureComponent {
|
||||
|
||||
return (
|
||||
<>
|
||||
{status === 'ready' && (
|
||||
<PageTitleRight>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={saving}
|
||||
onClick={this.handleSave}
|
||||
>
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</Button>
|
||||
</PageTitleRight>
|
||||
)}
|
||||
<PanelTileGrid>
|
||||
<ManifestHeader
|
||||
kind={kind}
|
||||
@@ -501,7 +481,9 @@ class ManifestForm extends PureComponent {
|
||||
externalNumber={externalNumber}
|
||||
saving={saving}
|
||||
pdfUploading={pdfUploading}
|
||||
lines={lines}
|
||||
onPdfUpload={kind === 'delivery' ? this.handlePdfUpload : undefined}
|
||||
onSave={this.handleSave}
|
||||
onPartyChange={this.handlePartyChange}
|
||||
onDateChange={this.handleDateChange}
|
||||
onNumberChange={this.handleNumberChange}
|
||||
|
||||
@@ -13,18 +13,24 @@ import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
|
||||
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 SearchIcon from '@mui/icons-material/Search';
|
||||
import LinkOffOutlinedIcon from '@mui/icons-material/LinkOffOutlined';
|
||||
import { LINE_COLUMNS } from './manifestConfig';
|
||||
import { getFirstVal, parseNum } from './manifestConfig';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { primaryPrice, priceBrutto } from '../../utils/articleDetail';
|
||||
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
|
||||
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
|
||||
|
||||
class ManifestFormLine extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const sku = props.supplierArticle?.article?.sku;
|
||||
this.state = {
|
||||
cachedArticle: sku ? getArticle(sku) : null,
|
||||
};
|
||||
this.handleRemove = this.handleRemove.bind(this);
|
||||
this.handleBlur = this.handleBlur.bind(this);
|
||||
this.handleCreateArticle = this.handleCreateArticle.bind(this);
|
||||
@@ -32,6 +38,41 @@ class ManifestFormLine extends PureComponent {
|
||||
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() {
|
||||
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 { 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 (
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
type={type}
|
||||
value={line[id] ?? ''}
|
||||
value={val ?? ''}
|
||||
error={isError}
|
||||
disabled={saving}
|
||||
autoComplete="off"
|
||||
onChange={(e) => onChange(line.key, id, e.target.value)}
|
||||
@@ -86,6 +224,16 @@ class ManifestFormLine extends PureComponent {
|
||||
},
|
||||
}}
|
||||
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]': {
|
||||
MozAppearance: 'textfield',
|
||||
},
|
||||
@@ -116,18 +264,28 @@ class ManifestFormLine extends PureComponent {
|
||||
|
||||
if (supplierArticle) {
|
||||
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 thumbHash = photo?.thumbhash || photo?.thumbHash;
|
||||
const thumbW = photo?.thumbw || photo?.thumbW;
|
||||
const thumbH = photo?.thumbh || photo?.thumbH;
|
||||
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 priceNet = supplierArticle.price?.net;
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -138,6 +296,24 @@ class ManifestFormLine extends PureComponent {
|
||||
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 */}
|
||||
<Box
|
||||
sx={{
|
||||
@@ -166,9 +342,6 @@ class ManifestFormLine extends PureComponent {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Match Indicator */}
|
||||
<CheckCircleOutlinedIcon sx={{ fontSize: 16, color: 'success.main', flexShrink: 0 }} />
|
||||
|
||||
{/* Internal Article SKU */}
|
||||
{article.sku && (
|
||||
<Chip
|
||||
@@ -195,15 +368,15 @@ class ManifestFormLine extends PureComponent {
|
||||
)}
|
||||
|
||||
{/* Article Name */}
|
||||
{article.name && (
|
||||
{(fullArticle.name || article.name) && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
noWrap
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -225,32 +398,49 @@ class ManifestFormLine extends PureComponent {
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Stored Purchase Price (Netto) */}
|
||||
{priceNet != null && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
sx={{ color: 'success.dark', ml: 'auto', fontSize: '0.72rem', whiteSpace: 'nowrap' }}
|
||||
{/* Stored Purchase Price (Netto), VK Netto & VK Brutto */}
|
||||
{(priceNet != null || vkNetto != null || vkBrutto != null) && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
ml: 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
EK Netto: {formatPrice(priceNet)}
|
||||
{taxRate != null ? ` (${taxRate}% MwSt)` : ''}
|
||||
</Typography>
|
||||
{priceNet != null && (
|
||||
<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.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>
|
||||
)}
|
||||
{this.renderLedgerChip()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -258,10 +448,6 @@ class ManifestFormLine extends PureComponent {
|
||||
if (rawSku) {
|
||||
return (
|
||||
<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 && (
|
||||
<Button
|
||||
size="small"
|
||||
@@ -306,34 +492,44 @@ class ManifestFormLine extends PureComponent {
|
||||
Artikel suchen
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ fontStyle: 'italic', fontSize: '0.7rem' }}>
|
||||
SKU eingeben oder Dokument scannen für automatische Artikelzuordnung
|
||||
</Typography>
|
||||
);
|
||||
if (this.props.ledgerLine) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
|
||||
{this.renderLedgerChip()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { columns = LINE_COLUMNS, saving } = this.props;
|
||||
const secondLine = this.renderSecondLine();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Row 1: Editable Line Inputs */}
|
||||
<TableRow hover sx={{ '& > td': { borderBottom: 'none', py: 0.75 } }}>
|
||||
{columns.map((col) => (
|
||||
<TableRow hover sx={{ '& > td': { borderBottom: 'none', p: 0 } }}>
|
||||
{columns.map((col, colIdx) => (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
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 padding="checkbox" sx={{ py: 0.75 }}>
|
||||
<TableCell padding="none" sx={{ py: 0.25, pl: 0.75, pr: 0, verticalAlign: 'middle', width: 40 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Zeile entfernen"
|
||||
@@ -346,18 +542,20 @@ class ManifestFormLine extends PureComponent {
|
||||
</TableRow>
|
||||
|
||||
{/* Row 2: Matched Supplier Article Details */}
|
||||
<TableRow sx={{ '& > td': { pt: 0, pb: 0.75, borderBottom: 'none' } }}>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
sx={{
|
||||
py: 0.4,
|
||||
px: 2,
|
||||
bgcolor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{this.renderSecondLine()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{secondLine && (
|
||||
<TableRow sx={{ '& > td': { pt: 0.25, pb: 0.75, borderBottom: 'none' } }}>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
sx={{
|
||||
py: 0.4,
|
||||
px: 1,
|
||||
bgcolor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{secondLine}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { PureComponent, createRef } from 'react';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Chip from '@mui/material/Chip';
|
||||
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 { PanelTile } from 'dashgui/panels';
|
||||
import FileDropzone from '../FileDropzone';
|
||||
import AttachmentList from './AttachmentList';
|
||||
import { manifestConfig, partyDisplayName } from './manifestConfig';
|
||||
import { hasArticleLines, manifestConfig, partyDisplayName } from './manifestConfig';
|
||||
import { notifyErrorMessage } from '../../utils/notify';
|
||||
|
||||
class ManifestHeader extends PureComponent {
|
||||
constructor(props) {
|
||||
@@ -18,20 +23,50 @@ class ManifestHeader extends PureComponent {
|
||||
this.handlePartyChange = this.handlePartyChange.bind(this);
|
||||
this.handleUploadClick = this.handleUploadClick.bind(this);
|
||||
this.handleFileChange = this.handleFileChange.bind(this);
|
||||
this.handleFileDrop = this.handleFileDrop.bind(this);
|
||||
}
|
||||
|
||||
handlePartyChange(_event, option) {
|
||||
this.props.onPartyChange(option?.id ?? null);
|
||||
}
|
||||
|
||||
hasArticles() {
|
||||
return Boolean(
|
||||
this.props.hasArticles ??
|
||||
(Array.isArray(this.props.lines) && hasArticleLines(this.props.lines)),
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -50,6 +85,7 @@ class ManifestHeader extends PureComponent {
|
||||
saving,
|
||||
pdfUploading,
|
||||
attachments,
|
||||
lines,
|
||||
onPdfUpload,
|
||||
onPdfRescan,
|
||||
onPdfDelete,
|
||||
@@ -58,8 +94,15 @@ class ManifestHeader extends PureComponent {
|
||||
onDateChange,
|
||||
onNumberChange,
|
||||
onExternalNumberChange,
|
||||
onSave,
|
||||
actionsColor,
|
||||
docId,
|
||||
ledgerSummary,
|
||||
onOpenBookDialog,
|
||||
} = this.props;
|
||||
|
||||
const hasArticles = this.hasArticles();
|
||||
|
||||
// Delivery: number is server-allocated/read-only; the caller reference
|
||||
// lives in the editable externalNumber field.
|
||||
const cfg = manifestConfig(kind);
|
||||
@@ -68,117 +111,243 @@ class ManifestHeader extends PureComponent {
|
||||
const selected =
|
||||
parties.find((row) => row.id === partyId) ?? null;
|
||||
|
||||
const isDateEmpty = !date || String(date).trim() === '';
|
||||
const isPartyEmpty = partyId == null || partyId === '';
|
||||
|
||||
return (
|
||||
<PanelTile wide={3} minHeight={160} color={headerColor} title="Kopfdaten">
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ alignItems: { xs: 'stretch', sm: 'center' } }}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label={dateLabel}
|
||||
value={date}
|
||||
disabled={saving || pdfUploading}
|
||||
onChange={(e) => onDateChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { spellCheck: false },
|
||||
<>
|
||||
{/* Tile 1: 3-wide Kopfdaten Tile */}
|
||||
<PanelTile wide={3} minHeight={160} color={headerColor} title="Kopfdaten">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1.5,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
sx={{ minWidth: 220 }}
|
||||
/>
|
||||
<Autocomplete
|
||||
size="small"
|
||||
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: 1, minWidth: 220 }}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={partyLabel}
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
htmlInput: {
|
||||
...params.slotProps?.htmlInput,
|
||||
spellCheck: false,
|
||||
autoComplete: 'off',
|
||||
},
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label={dateLabel}
|
||||
value={date}
|
||||
error={isDateEmpty}
|
||||
disabled={saving || pdfUploading}
|
||||
onChange={(e) => onDateChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { spellCheck: false },
|
||||
}}
|
||||
sx={{
|
||||
flex: { xs: '1 1 100%', sm: '1 1 150px' },
|
||||
minWidth: { sm: 130 },
|
||||
}}
|
||||
/>
|
||||
<Autocomplete
|
||||
size="small"
|
||||
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
|
||||
size="small"
|
||||
label="Nummer"
|
||||
value={number}
|
||||
disabled={saving || pdfUploading || useExternalNumber}
|
||||
onChange={onNumberChange}
|
||||
sx={{ minWidth: 180, flex: 1 }}
|
||||
/>
|
||||
{useExternalNumber ? (
|
||||
<SingleLineTextEdit
|
||||
size="small"
|
||||
label="Referenz"
|
||||
value={externalNumber ?? ''}
|
||||
disabled={saving || pdfUploading}
|
||||
onChange={onExternalNumberChange}
|
||||
sx={{ minWidth: 180, flex: 1 }}
|
||||
/>
|
||||
) : null}
|
||||
{onPdfUpload ? (
|
||||
<FileDropzone
|
||||
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"
|
||||
disabled={saving || pdfUploading}
|
||||
onFileDrop={onPdfUpload}
|
||||
</PanelTile>
|
||||
|
||||
{/* Tile 2: 1-wide Action Tile (4th tile in row) with Speichern & Datei hochladen */}
|
||||
<PanelTile
|
||||
wide={1}
|
||||
minHeight={160}
|
||||
color={actionsColor ?? '#f1f5f9'}
|
||||
sx={{
|
||||
gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 1' },
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.5} sx={{ pt: 0.5 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={saving}
|
||||
onClick={onSave}
|
||||
sx={{
|
||||
alignSelf: { xs: 'stretch', sm: 'center' },
|
||||
flexShrink: 0,
|
||||
width: { xs: '100%', sm: 175 },
|
||||
height: 40,
|
||||
fontWeight: 700,
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{({ isDragging }) => (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={pdfUploading ? <CircularProgress size={16} color="inherit" /> : <UploadFileIcon />}
|
||||
disabled={saving || pdfUploading}
|
||||
onClick={this.handleUploadClick}
|
||||
title="PDF, TXT oder CSV hochladen"
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
height: 40,
|
||||
width: '100%',
|
||||
px: 2,
|
||||
boxSizing: 'border-box',
|
||||
bgcolor: isDragging ? 'action.hover' : 'transparent',
|
||||
borderColor: isDragging ? 'primary.main' : undefined,
|
||||
transition: 'background-color 0.15s, border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
{pdfUploading
|
||||
? 'Wird analysiert…'
|
||||
: isDragging
|
||||
? 'Datei hier ablegen'
|
||||
: 'Datei hochladen'}
|
||||
</Button>
|
||||
)}
|
||||
</FileDropzone>
|
||||
) : null}
|
||||
</Stack>
|
||||
<AttachmentList
|
||||
attachments={attachments}
|
||||
onRescan={onPdfRescan}
|
||||
onDelete={onPdfDelete}
|
||||
onClick={onAttachmentClick ?? onPdfClick}
|
||||
disabled={saving || pdfUploading}
|
||||
/>
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</Button>
|
||||
{docId != null && kind === 'delivery' && onOpenBookDialog && (
|
||||
<Button
|
||||
variant={ledgerSummary?.status === 'booked' ? 'outlined' : 'contained'}
|
||||
color={
|
||||
ledgerSummary?.status === 'booked'
|
||||
? 'success'
|
||||
: ledgerSummary?.status === 'partial'
|
||||
? 'warning'
|
||||
: 'primary'
|
||||
}
|
||||
startIcon={
|
||||
ledgerSummary?.status === 'booked' ? (
|
||||
<CheckCircleOutlineOutlinedIcon />
|
||||
) : (
|
||||
<Inventory2OutlinedIcon />
|
||||
)
|
||||
}
|
||||
disabled={saving || pdfUploading}
|
||||
onClick={onOpenBookDialog}
|
||||
sx={{
|
||||
height: 40,
|
||||
fontWeight: 700,
|
||||
fontSize: '0.8125rem',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{ledgerSummary?.status === 'booked'
|
||||
? 'Lager gebucht'
|
||||
: ledgerSummary?.status === 'partial'
|
||||
? 'Rest einbuchen'
|
||||
: 'Ins Lager buchen'}
|
||||
</Button>
|
||||
)}
|
||||
{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 ? (
|
||||
<input
|
||||
ref={this.fileInputRef}
|
||||
@@ -188,7 +357,7 @@ class ManifestHeader extends PureComponent {
|
||||
onChange={this.handleFileChange}
|
||||
/>
|
||||
) : null}
|
||||
</PanelTile>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { LINE_COLUMNS } from './manifestConfig';
|
||||
import { LINE_COLUMNS, hasArticleLines } from './manifestConfig';
|
||||
import ManifestFormLine from './ManifestFormLine';
|
||||
|
||||
class ManifestLines extends PureComponent {
|
||||
@@ -22,6 +23,7 @@ class ManifestLines extends PureComponent {
|
||||
onLineChange,
|
||||
onAddLine,
|
||||
onRemoveLine,
|
||||
onRemoveAllLines,
|
||||
onSave,
|
||||
columns = LINE_COLUMNS,
|
||||
supplierArticlesMap = {},
|
||||
@@ -33,11 +35,29 @@ class ManifestLines extends PureComponent {
|
||||
onSearchArticle,
|
||||
onRemoveSupplierArticle,
|
||||
busySupplierArticleLineKeys = {},
|
||||
wide = 3,
|
||||
wide = 4,
|
||||
ledgerLinesById = {},
|
||||
onBookLine,
|
||||
} = this.props;
|
||||
|
||||
const hasArticles = hasArticleLines(lines);
|
||||
|
||||
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}>
|
||||
<TableContainer>
|
||||
<Table size="small" stickyHeader>
|
||||
@@ -52,47 +72,53 @@ class ManifestLines extends PureComponent {
|
||||
whiteSpace: 'nowrap',
|
||||
bgcolor: linesColor,
|
||||
fontWeight: 700,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
}}
|
||||
>
|
||||
{col.label}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell padding="checkbox" sx={{ bgcolor: linesColor, borderBottom: 'none' }} />
|
||||
<TableCell padding="none" sx={{ bgcolor: linesColor, borderBottom: 'none', width: 40 }} />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{lines.map((line, index) => {
|
||||
const lineKey = line.key ?? index;
|
||||
const rawSku = line.sku != null ? String(line.sku).trim() : '';
|
||||
const matched =
|
||||
supplierArticlesByLineKey[lineKey] ||
|
||||
(rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null);
|
||||
const isLineLoading =
|
||||
loadingLineKeys[lineKey] ||
|
||||
(supplierArticlesLoading && !matched);
|
||||
{Array.isArray(lines) &&
|
||||
lines.map((line, index) => {
|
||||
const lineKey = line.key ?? index;
|
||||
const rawSku = line.sku != null ? String(line.sku).trim() : '';
|
||||
const matched =
|
||||
supplierArticlesByLineKey[lineKey] ||
|
||||
(rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null);
|
||||
const isLineLoading =
|
||||
loadingLineKeys[lineKey] ||
|
||||
(supplierArticlesLoading && !matched);
|
||||
const ledgerLine = line.id != null ? ledgerLinesById[line.id] : null;
|
||||
|
||||
return (
|
||||
<ManifestFormLine
|
||||
key={lineKey}
|
||||
line={line.key != null ? line : { ...line, key: index }}
|
||||
columns={columns}
|
||||
saving={saving}
|
||||
supplierArticle={matched}
|
||||
loading={Boolean(isLineLoading)}
|
||||
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}
|
||||
onChange={onLineChange}
|
||||
onRemove={onRemoveLine}
|
||||
onSkuBlur={onSkuBlur}
|
||||
onCreateArticle={onCreateArticle}
|
||||
onSearchArticle={onSearchArticle}
|
||||
onRemoveSupplierArticle={onRemoveSupplierArticle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<ManifestFormLine
|
||||
key={lineKey}
|
||||
line={line.key != null ? line : { ...line, key: index }}
|
||||
columns={columns}
|
||||
saving={saving}
|
||||
supplierArticle={matched}
|
||||
loading={Boolean(isLineLoading)}
|
||||
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}
|
||||
onChange={onLineChange}
|
||||
onRemove={onRemoveLine}
|
||||
onSkuBlur={onSkuBlur}
|
||||
onCreateArticle={onCreateArticle}
|
||||
onSearchArticle={onSearchArticle}
|
||||
onRemoveSupplierArticle={onRemoveSupplierArticle}
|
||||
ledgerLine={ledgerLine}
|
||||
onBookLine={onBookLine}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
@@ -101,6 +127,15 @@ class ManifestLines extends PureComponent {
|
||||
>
|
||||
Zeile hinzufügen
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
startIcon={<DeleteSweepOutlinedIcon />}
|
||||
disabled={saving || !lines || lines.length === 0}
|
||||
onClick={onRemoveAllLines}
|
||||
>
|
||||
Alle Zeilen entfernen
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
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 BackendError from '../BackendError';
|
||||
import { RecordList } from 'dashgui/lists';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { ledger, delivery } from '../../api';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
|
||||
import BookDeliveryDialog from './BookDeliveryDialog';
|
||||
import withNavigate from '../withNavigate';
|
||||
import {
|
||||
formatDate,
|
||||
@@ -27,12 +38,18 @@ class ManifestList extends PureComponent {
|
||||
docs: [],
|
||||
partiesById: {},
|
||||
busyId: null,
|
||||
ledgerSummaries: {},
|
||||
bookingDoc: null,
|
||||
bookingOpen: false,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handleCreate = this.handleCreate.bind(this);
|
||||
this.handleEdit = this.handleEdit.bind(this);
|
||||
this.handleDelete = this.handleDelete.bind(this);
|
||||
this.handleOpenBookDialog = this.handleOpenBookDialog.bind(this);
|
||||
this.handleCloseBookDialog = this.handleCloseBookDialog.bind(this);
|
||||
this.handleBookSuccess = this.handleBookSuccess.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -53,17 +70,59 @@ class ManifestList extends PureComponent {
|
||||
loadParties(this.props.kind, this.context),
|
||||
]);
|
||||
if (this._cancelled) return;
|
||||
const sortedDocs = sortDocs(docs);
|
||||
this.setState({
|
||||
status: 'ready',
|
||||
docs: sortDocs(docs),
|
||||
docs: sortedDocs,
|
||||
partiesById: cfg.partiesByIdFromList(parties),
|
||||
});
|
||||
|
||||
if (this.props.kind === 'delivery') {
|
||||
void this.loadLedgerSummaries(sortedDocs);
|
||||
}
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
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() {
|
||||
void this.load();
|
||||
}
|
||||
@@ -86,6 +145,24 @@ class ManifestList extends PureComponent {
|
||||
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) {
|
||||
if (this.state.busyId != null) 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);
|
||||
const list = await this.context[cfg.updateCache]();
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
docs: sortDocs(list),
|
||||
busyId: null,
|
||||
this.setState((prev) => {
|
||||
const nextSummaries = { ...prev.ledgerSummaries };
|
||||
delete nextSummaries[doc.id];
|
||||
return {
|
||||
docs: sortDocs(list),
|
||||
busyId: null,
|
||||
ledgerSummaries: nextSummaries,
|
||||
};
|
||||
});
|
||||
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
|
||||
} 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() {
|
||||
const { status, error, docs, busyId } = this.state;
|
||||
const { status, error, docs, busyId, bookingDoc, bookingOpen, ledgerSummaries } = this.state;
|
||||
const cfg = this.cfg;
|
||||
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 (
|
||||
<RecordList
|
||||
status={status}
|
||||
error={<BackendError error={error} onRetry={this.handleRetry} />}
|
||||
rows={docs}
|
||||
columns={columns}
|
||||
createLabel={cfg.createButton}
|
||||
onCreate={this.handleCreate}
|
||||
onEdit={this.handleEdit}
|
||||
onDelete={this.handleDelete}
|
||||
deleteDisabled={busyId != null}
|
||||
emptyMessage={cfg.emptyList}
|
||||
color={cfg.listColor}
|
||||
getEditLabel={(row) => `„${row.number}“ bearbeiten`}
|
||||
getDeleteLabel={(row) => `„${row.number}“ löschen`}
|
||||
/>
|
||||
<>
|
||||
<RecordList
|
||||
status={status}
|
||||
error={<BackendError error={error} onRetry={this.handleRetry} />}
|
||||
rows={docs}
|
||||
columns={columns}
|
||||
createLabel={cfg.createButton}
|
||||
onCreate={this.handleCreate}
|
||||
onEdit={this.handleEdit}
|
||||
onDelete={this.handleDelete}
|
||||
deleteDisabled={busyId != null}
|
||||
emptyMessage={cfg.emptyList}
|
||||
color={cfg.listColor}
|
||||
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 PdfViewerPanel } from './PdfViewerPanel';
|
||||
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
|
||||
export { default as BookDeliveryDialog } from './BookDeliveryDialog';
|
||||
export { withManifestId } from './withManifestId';
|
||||
export {
|
||||
manifestConfig,
|
||||
|
||||
@@ -80,44 +80,101 @@ export function emptyLine() {
|
||||
}
|
||||
|
||||
export function lineFromApi(row) {
|
||||
const netVal = row.netprice != null ? String(row.netprice) : row.net != null ? String(row.net) : '';
|
||||
const taxVal = row.taxrate != null ? String(row.taxrate) : row.tax != null ? String(row.tax) : '';
|
||||
return {
|
||||
id: row.id,
|
||||
key: `line-${row.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`}`,
|
||||
amount: row.amount == null ? '' : String(row.amount),
|
||||
sku: row.sku ?? '',
|
||||
tax: row.tax == null ? '' : String(row.tax),
|
||||
tax: taxVal,
|
||||
rebate: row.rebate == null ? '' : String(row.rebate),
|
||||
net: row.net == null ? '' : String(row.net),
|
||||
net: netVal,
|
||||
name: row.name ?? '',
|
||||
pu: row.pu == null ? '' : String(row.pu),
|
||||
unit: row.unit ?? '',
|
||||
netprice: row.netprice != null ? String(row.netprice) : row.net != null ? String(row.net) : '',
|
||||
taxrate: row.taxrate != null ? String(row.taxrate) : row.tax != null ? String(row.tax) : '',
|
||||
netprice: netVal,
|
||||
taxrate: taxVal,
|
||||
};
|
||||
}
|
||||
|
||||
function parseNum(value) {
|
||||
export function parseNum(value) {
|
||||
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;
|
||||
}
|
||||
|
||||
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. */
|
||||
export function buildLines(lines, { requireAtLeastOne }) {
|
||||
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 name = String(line.name ?? '').trim();
|
||||
const amount = parseNum(line.amount);
|
||||
const net = parseNum(line.net ?? line.netprice);
|
||||
const tax = parseNum(line.tax ?? line.taxrate);
|
||||
const rebate = parseNum(line.rebate);
|
||||
const pu = parseNum(line.pu);
|
||||
const rawAmount = getFirstVal(line.amount, line.count);
|
||||
const amount = parseNum(rawAmount);
|
||||
const rawNet = getFirstVal(line.net, line.netprice, line.netPrice, line.eknetto);
|
||||
const net = parseNum(rawNet);
|
||||
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 =
|
||||
!sku && !name && amount == null && net == null && tax == null && rebate == null;
|
||||
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 };
|
||||
if (sku) row.sku = sku;
|
||||
if (name) row.name = name;
|
||||
@@ -125,6 +182,18 @@ export function buildLines(lines, { requireAtLeastOne }) {
|
||||
if (pu != null) row.pu = pu;
|
||||
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) {
|
||||
throw new Error('Mindestens eine Artikelzeile nötig.');
|
||||
}
|
||||
@@ -480,14 +549,14 @@ export function manifestConfig(kind) {
|
||||
deletedMsg: 'Lieferung gelöscht.',
|
||||
confirmDelete: (number) => `Lieferung „${number}“ wirklich löschen?`,
|
||||
invalidId: 'Ungültige Lieferungs-ID.',
|
||||
partyGateMessage: 'Lieferant wählen, um Artikel zu erfassen.',
|
||||
partyGateMessage: null,
|
||||
getPathLabel: 'GET /api/delivery/:id',
|
||||
postPathLabel: 'POST /api/delivery',
|
||||
patchPathLabel: 'PATCH /api/delivery/:id',
|
||||
listColor: '#fed7aa',
|
||||
headerColor: '#fed7aa',
|
||||
linesColor: '#bbf7d0',
|
||||
requirePartyBeforeLines: true,
|
||||
requirePartyBeforeLines: false,
|
||||
updateCache: 'updateDelivery',
|
||||
listPromise: (ctx) => ctx.deliveries ?? ctx.deliveryPromise,
|
||||
partiesByIdFromList: (parties) =>
|
||||
|
||||
@@ -6,6 +6,17 @@ import CircularProgress from '@mui/material/CircularProgress';
|
||||
|
||||
import Stack from '@mui/material/Stack';
|
||||
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 { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import BackendError from '../../components/BackendError';
|
||||
@@ -24,6 +35,7 @@ import {
|
||||
TextEditorPanel,
|
||||
PdfViewerPanel,
|
||||
ArticleSearchDialog,
|
||||
BookDeliveryDialog,
|
||||
} from '../../components/manifest';
|
||||
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
|
||||
import { loadParties } from '../../components/manifest/manifestConfig';
|
||||
@@ -36,7 +48,8 @@ import { generateTextThumbnail } from '../../utils/textThumbnail';
|
||||
import { extractPdfText } from '../../utils/pdfText';
|
||||
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
||||
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 {
|
||||
static contextType = StorageManagerContext;
|
||||
@@ -63,18 +76,26 @@ class LieferungNeuPage extends PureComponent {
|
||||
articleSearchDialogOpen: false,
|
||||
articleSearchLine: null,
|
||||
unmatchedSupplierName: null,
|
||||
ledgerSummary: null,
|
||||
bookDeliveryDialogOpen: false,
|
||||
bookDeliveryPrefillLineId: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handlePartyChange = this.handlePartyChange.bind(this);
|
||||
this.handleCreateSupplierFromPdf = this.handleCreateSupplierFromPdf.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.handleExternalNumberChange = this.handleExternalNumberChange.bind(this);
|
||||
this.handleLineChange = this.handleLineChange.bind(this);
|
||||
this.handleAddLine = this.handleAddLine.bind(this);
|
||||
this.handleRemoveLine = this.handleRemoveLine.bind(this);
|
||||
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
||||
this.handlePdfRescan = this.handlePdfRescan.bind(this);
|
||||
@@ -206,9 +227,22 @@ class LieferungNeuPage extends PureComponent {
|
||||
const attachments = await restoreAttachmentsFromApi(apiFiles, this.state.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({
|
||||
status: 'ready',
|
||||
partiesLoading: false,
|
||||
ledgerSummary,
|
||||
});
|
||||
} catch (err) {
|
||||
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() {
|
||||
if (this.props.docId != null) void this.loadEdit();
|
||||
else void this.loadPartiesOnly();
|
||||
@@ -271,7 +342,9 @@ class LieferungNeuPage extends PureComponent {
|
||||
key,
|
||||
[field]: value,
|
||||
...(field === 'net' ? { netprice: value } : {}),
|
||||
...(field === 'netprice' ? { net: value } : {}),
|
||||
...(field === 'tax' ? { taxrate: value } : {}),
|
||||
...(field === 'taxrate' ? { tax: value } : {}),
|
||||
});
|
||||
|
||||
if (field === 'sku') {
|
||||
@@ -339,7 +412,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
entityId: partyId,
|
||||
sku: supplierSku || undefined,
|
||||
pu: line.pu ? Number(line.pu) : undefined,
|
||||
stock: line.amount ? Number(line.amount) : undefined,
|
||||
stock: 0,
|
||||
netPrice: line.netprice ? Number(line.netprice) : undefined,
|
||||
taxRate: line.taxrate ? Number(line.taxrate) : undefined,
|
||||
});
|
||||
@@ -389,7 +462,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
entityId: partyId,
|
||||
sku: supplierSku || undefined,
|
||||
pu: line?.pu ? Number(line.pu) : undefined,
|
||||
stock: line?.amount ? Number(line.amount) : undefined,
|
||||
stock: 0,
|
||||
netPrice: line?.netprice ? Number(line.netprice) : undefined,
|
||||
taxRate: line?.taxrate ? Number(line.taxrate) : undefined,
|
||||
});
|
||||
@@ -445,6 +518,10 @@ class LieferungNeuPage extends PureComponent {
|
||||
this.automat.actions.removeLineitem(index >= 0 ? index : key);
|
||||
}
|
||||
|
||||
handleRemoveAllLines() {
|
||||
this.automat.actions.clearLineitems();
|
||||
}
|
||||
|
||||
async handlePdfUpload(file) {
|
||||
if (!file || this.state.pdfUploading) return;
|
||||
|
||||
@@ -661,18 +738,26 @@ class LieferungNeuPage extends PureComponent {
|
||||
? result.items.items
|
||||
: null;
|
||||
const newLines = Array.isArray(rawItems)
|
||||
? rawItems.map((l, idx) => ({
|
||||
key: l.key ?? `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
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: (l.taxrate ?? l.tax ?? l.mwst) != null ? String(l.taxrate ?? l.tax ?? l.mwst) : '',
|
||||
netprice: (l.netprice ?? l.net ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
|
||||
? String(l.netprice ?? l.net ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
|
||||
: '',
|
||||
}))
|
||||
? rawItems.map((l, idx) => {
|
||||
const taxVal = (l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst) != null
|
||||
? String(l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst)
|
||||
: '';
|
||||
const netVal = (l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
|
||||
? String(l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
|
||||
: '';
|
||||
return {
|
||||
key: l.key ?? `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
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;
|
||||
|
||||
this.setState((prev) => ({
|
||||
@@ -1108,6 +1193,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
if (doc.number != null) this.automat.actions.setNumber(doc.number);
|
||||
if (doc.externalNumber != null) this.automat.actions.setExternalNumber(String(doc.externalNumber));
|
||||
this.automat.actions.setLineitems(lines);
|
||||
void this.reloadLedgerSummary();
|
||||
|
||||
// Restore attachments with thumbnails
|
||||
const apiFiles = doc.files ?? [];
|
||||
@@ -1157,18 +1243,15 @@ class LieferungNeuPage extends PureComponent {
|
||||
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 (
|
||||
<>
|
||||
<PageTitleRight>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={saving}
|
||||
onClick={this.handleSave}
|
||||
>
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</Button>
|
||||
</PageTitleRight>
|
||||
<PanelTileGrid>
|
||||
<ManifestHeader
|
||||
kind="delivery"
|
||||
@@ -1184,95 +1267,73 @@ class LieferungNeuPage extends PureComponent {
|
||||
saving={saving}
|
||||
pdfUploading={pdfUploading}
|
||||
attachments={attachments}
|
||||
lines={lines}
|
||||
onPdfUpload={this.handlePdfUpload}
|
||||
onPdfRescan={this.handlePdfRescan}
|
||||
onPdfDelete={this.handlePdfDelete}
|
||||
onAttachmentClick={this.handleAttachmentClick}
|
||||
onSave={this.handleSave}
|
||||
onPartyChange={this.handlePartyChange}
|
||||
onDateChange={this.handleDateChange}
|
||||
onNumberChange={this.handleNumberChange}
|
||||
onExternalNumberChange={this.handleExternalNumberChange}
|
||||
docId={this.props.docId}
|
||||
ledgerSummary={this.state.ledgerSummary}
|
||||
onOpenBookDialog={this.handleOpenBookDialog}
|
||||
/>
|
||||
{this.state.viewerPosition === 'top' && this.renderViewerPanels(3)}
|
||||
{partyId == null && (!lines || lines.length === 0) ? (
|
||||
<PanelTile wide={3} minHeight={160} color="#e2e8f0">
|
||||
{this.state.unmatchedSupplierName ? (
|
||||
<Stack spacing={2} sx={{ p: 1 }}>
|
||||
<Typography color="text.secondary">
|
||||
Lieferant <strong>„{this.state.unmatchedSupplierName}“</strong> wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
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.viewerPosition === 'top' && this.renderViewerPanels(4)}
|
||||
{this.state.unmatchedSupplierName && (
|
||||
<PanelTile wide={4} color="#fef3c7" sx={{ gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } }}>
|
||||
<Alert
|
||||
severity="warning"
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => this.handleCreateSupplierFromPdf(this.state.unmatchedSupplierName)}
|
||||
>
|
||||
{this.state.unmatchedSupplierName
|
||||
? `Lieferant „${this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.`
|
||||
: '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
|
||||
Lieferant anlegen
|
||||
</Button>
|
||||
}
|
||||
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}
|
||||
onSave={() => void this.handleSave()}
|
||||
onCreateArticle={this.handleCreateArticleForLine}
|
||||
onSearchArticle={this.handleOpenArticleSearchForLine}
|
||||
onRemoveSupplierArticle={this.handleRemoveSupplierArticle}
|
||||
busySupplierArticleLineKeys={this.state.busySupplierArticleLineKeys}
|
||||
/>
|
||||
</>
|
||||
sx={{ py: 0.5, alignItems: 'center' }}
|
||||
>
|
||||
Lieferant „{this.state.unmatchedSupplierName}“ wurde im Dokument erkannt, existiert aber noch nicht in den Stammdaten.
|
||||
</Alert>
|
||||
</PanelTile>
|
||||
)}
|
||||
<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 === 'below' && this.renderViewerPanels(3)}
|
||||
{this.state.viewerPosition === 'below' && this.renderViewerPanels(4)}
|
||||
{this.props.docId != null && this.renderLedgerPanel()}
|
||||
{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 }}>
|
||||
Hinweise zur PDF-Analyse ({notes.length})
|
||||
</Typography>
|
||||
@@ -1302,9 +1363,240 @@ class LieferungNeuPage extends PureComponent {
|
||||
onClose={this.handleCloseArticleSearch}
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user