This commit is contained in:
seb
2026-09-25 07:27:37 +02:00
parent b331f8f535
commit a3db1742e7
17 changed files with 2273 additions and 371 deletions

View File

@@ -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 &bdquo;{rawSku}&ldquo; 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 &bdquo;{rawSku}&ldquo; 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>
);
}