This commit is contained in:
seb
2026-10-05 17:57:33 +02:00
parent f627ff5b5a
commit 0a1002227c
9 changed files with 548 additions and 69 deletions

View File

@@ -27,7 +27,8 @@ export default class ArticleSearchDialog extends PureComponent {
}
handleSelect = async (article) => {
const { onAssign, line } = this.props;
const { onAssign, line, kind = 'delivery' } = this.props;
const isDelivery = kind === 'delivery';
if (!onAssign || !article?.sku) return;
if (article.isParent || article.is_parent) {
@@ -41,14 +42,19 @@ export default class ArticleSearchDialog extends PureComponent {
} catch (err) {
this.setState({
assigningSku: null,
error: err?.message || 'Fehler beim Verknüpfen des Lieferanten-Artikels.',
error:
err?.message ||
(isDelivery
? 'Fehler beim Verknüpfen des Lieferanten-Artikels.'
: 'Fehler beim Zuordnen des Artikels.'),
});
}
};
render() {
const { open, onClose, line } = this.props;
const { open, onClose, line, kind = 'delivery' } = this.props;
const { assigningSku, error } = this.state;
const isDelivery = kind === 'delivery';
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
return (
@@ -93,10 +99,10 @@ export default class ArticleSearchDialog extends PureComponent {
>
<Box>
<Typography variant="h6" component="div" fontWeight={600}>
Existierenden Artikel zuordnen
{line ? 'Existierenden Artikel zuordnen' : 'Artikel auswählen'}
</Typography>
<Typography variant="caption" color="text.secondary">
Lieferanten-SKU: <strong>{rawSku || '–'}</strong>
{isDelivery ? 'Lieferanten-SKU' : 'SKU'}: <strong>{rawSku || '–'}</strong>
{line?.name ? ` · ${line.name}` : ''}
</Typography>
</Box>

View File

@@ -13,6 +13,7 @@ import ManifestHeader from './ManifestHeader';
import ManifestLines from './ManifestLines';
import ManifestPdfDialog from './ManifestPdfDialog';
import ArticleSearchDialog from './ArticleSearchDialog';
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
import {
buildManifestBody,
restoreAttachmentsFromApi,
@@ -55,6 +56,7 @@ class ManifestForm extends PureComponent {
this.handleExternalNumberChange =
this.handleExternalNumberChange.bind(this);
this.handleLineChange = this.handleLineChange.bind(this);
this.handleSkuBlur = this.handleSkuBlur.bind(this);
this.handleAddLine = this.handleAddLine.bind(this);
this.handleAddArticleLine = this.handleAddArticleLine.bind(this);
this.handleOpenArticleSearchForLine = this.handleOpenArticleSearchForLine.bind(this);
@@ -208,6 +210,47 @@ class ManifestForm extends PureComponent {
}));
}
async handleSkuBlur(key, sku) {
const trimmed = String(sku ?? '').trim();
if (!trimmed) return;
try {
const art = getArticle(trimmed) || (await artikelAutomat.actions.loadArticle(trimmed));
if (art && !this._cancelled) {
this.setState((prev) => ({
lines: prev.lines.map((l) => {
if (l.key !== key) return l;
const netVal =
art.price != null
? String(art.price)
: art.vknetto != null
? String(art.vknetto)
: art.net != null
? String(art.net)
: '';
const taxVal =
art.taxrate != null
? String(art.taxrate)
: art.tax != null
? String(art.tax)
: '';
return {
...l,
sku: art.sku || l.sku,
name: art.name || l.name || '',
tax: l.tax || taxVal,
taxrate: l.taxrate || taxVal,
net: l.net || netVal,
netprice: l.netprice || netVal,
unit: art.unit || l.unit || 'Stück',
};
}),
}));
}
} catch {
// not in catalog or load error; preserve user entry
}
}
handleAddLine() {
this.setState((prev) => ({ lines: [...prev.lines, emptyLine()] }));
}
@@ -266,7 +309,15 @@ class ManifestForm extends PureComponent {
}
handleAssignArticleForLine(article, targetLine) {
if (!article || !targetLine) return;
if (!article) return;
if (!targetLine) {
this.handleAddArticleLine(article);
this.setState({
articleSearchDialogOpen: false,
articleSearchLine: null,
});
return;
}
const netVal =
article.price != null
? String(article.price)
@@ -513,12 +564,14 @@ class ManifestForm extends PureComponent {
return (
<ManifestLines
kind={cfg.kind}
columns={cfg.columns}
lines={lines}
saving={saving}
wide={4}
linesColor={cfg.linesColor}
onLineChange={this.handleLineChange}
onSkuBlur={this.handleSkuBlur}
onAddLine={this.handleAddLine}
onAddArticleLine={this.handleAddArticleLine}
onSearchArticle={this.handleOpenArticleSearchForLine}
@@ -597,6 +650,7 @@ class ManifestForm extends PureComponent {
<ArticleSearchDialog
open={this.state.articleSearchDialogOpen}
line={this.state.articleSearchLine}
kind={kind}
onClose={this.handleCloseArticleSearch}
onAssign={this.handleAssignArticleForLine}
/>

View File

@@ -31,9 +31,10 @@ import {
class ManifestFormLine extends PureComponent {
constructor(props) {
super(props);
const sku = props.supplierArticle?.article?.sku;
const sku = this.getArticleSku(props);
this.state = {
cachedArticle: sku ? getArticle(sku) : null,
loadingArticle: false,
};
this.handleRemove = this.handleRemove.bind(this);
this.handleBlur = this.handleBlur.bind(this);
@@ -42,13 +43,20 @@ class ManifestFormLine extends PureComponent {
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
}
getArticleSku(props = this.props) {
if (props.kind === 'delivery') {
return props.supplierArticle?.article?.sku || null;
}
return props.line?.sku ? String(props.line.sku).trim() : null;
}
componentDidMount() {
this.subscribeArticle(this.props.supplierArticle?.article?.sku);
this.subscribeArticle(this.getArticleSku());
}
componentDidUpdate(prevProps) {
const prevSku = prevProps.supplierArticle?.article?.sku;
const nextSku = this.props.supplierArticle?.article?.sku;
const prevSku = this.getArticleSku(prevProps);
const nextSku = this.getArticleSku(this.props);
if (prevSku !== nextSku) {
this.subscribeArticle(nextSku);
}
@@ -62,19 +70,35 @@ class ManifestFormLine extends PureComponent {
this.unsubArticle?.();
this.unsubArticle = null;
if (!sku) {
if (this.state.cachedArticle) {
this.setState({ cachedArticle: null });
if (this.state.cachedArticle || this.state.loadingArticle) {
this.setState({ cachedArticle: null, loadingArticle: false });
}
return;
}
const current = getArticle(sku);
this.setState({ cachedArticle: current });
if (!current) {
artikelAutomat.actions.loadArticle(sku).catch(() => { });
if (current) {
this.setState({ cachedArticle: current, loadingArticle: false });
} else {
this.setState({ cachedArticle: null, loadingArticle: true });
artikelAutomat.actions.loadArticle(sku)
.then((art) => {
if (this.getArticleSku() === sku) {
this.setState({ cachedArticle: art, loadingArticle: false });
}
})
.catch(() => {
if (this.getArticleSku() === sku) {
this.setState({ cachedArticle: null, loadingArticle: false });
}
});
}
this.unsubArticle = artikelAutomat.subscribe(this, (state) => ({
cachedArticle: createArticleSelector(sku)(state),
}));
this.unsubArticle = artikelAutomat.subscribe(this, (state) => {
const art = createArticleSelector(sku)(state);
return {
cachedArticle: art ?? null,
...(art ? { loadingArticle: false } : {}),
};
});
}
handleRemove() {
@@ -251,8 +275,255 @@ class ManifestFormLine extends PureComponent {
}
renderSecondLine() {
const { kind = 'delivery' } = this.props;
const rawSku = this.props.line?.sku != null ? String(this.props.line.sku).trim() : '';
if (kind === 'delivery') {
return this.renderDeliverySecondLine(rawSku);
}
return this.renderArticleSecondLine(rawSku);
}
renderArticleSecondLine(rawSku) {
const { loading } = this.props;
const { cachedArticle, loadingArticle } = this.state;
if (loading || loadingArticle) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.25 }}>
<CircularProgress size={14} thickness={5} sx={{ color: 'primary.main' }} />
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic', fontSize: '0.72rem' }}>
Artikeldaten werden geladen…
</Typography>
</Box>
);
}
if (cachedArticle) {
const photo = cachedArticle.photos?.[0] || cachedArticle.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 = cachedArticle.eans ?? cachedArticle.articleean ?? [];
const primaryEan = Array.isArray(eans) ? eans[0] : eans;
const vkPrice = primaryPrice(cachedArticle);
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={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
py: 0.25,
flexWrap: 'wrap',
}}
>
{/* Article Photo Thumbnail */}
<Box
sx={{
width: 28,
height: 28,
minWidth: 28,
borderRadius: 0.75,
overflow: 'hidden',
bgcolor: BORDER,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid',
borderColor: 'divider',
}}
>
{hasPhoto ? (
<ArticlePhoto
photo={{ fullHash, thumbHash, thumbW, thumbH }}
size={28}
/>
) : (
<Inventory2OutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
)}
</Box>
{/* Internal Article SKU */}
{cachedArticle.sku && (
<Chip
component={RouterLink}
to={artikelDetailPath(cachedArticle)}
clickable
size="small"
label={`Art.-Nr.: ${cachedArticle.sku}`}
sx={{
height: 20,
fontSize: '0.7rem',
fontWeight: 600,
bgcolor: 'primary.50',
color: 'primary.dark',
border: '1px solid',
borderColor: 'primary.200',
cursor: 'pointer',
textDecoration: 'none',
'&:hover': {
bgcolor: 'primary.100',
},
}}
/>
)}
{/* Brand */}
{cachedArticle.brand?.name && (
<BrandLabel
brand={cachedArticle.brand}
size={16}
typographyProps={{
variant: 'caption',
color: 'text.secondary',
fontWeight: 500,
}}
/>
)}
{/* Article Name */}
{cachedArticle.name && (
<Typography
variant="caption"
fontWeight={600}
noWrap
sx={{ color: 'text.primary', maxWidth: { xs: 200, sm: 300, md: 400 } }}
title={cachedArticle.name}
>
{cachedArticle.name}
</Typography>
)}
{/* Primary EAN */}
{primaryEan && (
<Typography
variant="caption"
color="text.secondary"
sx={{
fontFamily: 'monospace',
fontSize: '0.68rem',
bgcolor: 'action.hover',
px: 0.5,
py: 0.1,
borderRadius: 0.5,
}}
>
EAN: {primaryEan}
</Typography>
)}
{/* VK Netto & VK Brutto */}
{(vkNetto != null || vkBrutto != null) && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
ml: 'auto',
whiteSpace: 'nowrap',
}}
>
{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>
)}
</Box>
);
}
if (rawSku) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
{this.props.onSearchArticle && (
<Button
size="small"
variant="text"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
onClick={this.handleSearchArticle}
sx={{
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'none',
minWidth: 'auto',
py: 0.25,
px: 1,
color: 'primary.main',
'&:hover': {
bgcolor: 'primary.50',
},
}}
>
Artikel suchen
</Button>
)}
<HelpOutlineIcon sx={{ fontSize: 15, color: 'warning.main' }} />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.72rem' }}>
Kein Artikel für SKU &bdquo;{rawSku}&ldquo; gefunden.
</Typography>
</Box>
);
}
if (this.props.onSearchArticle) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
<Button
size="small"
variant="text"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
onClick={this.handleSearchArticle}
sx={{
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'none',
minWidth: 'auto',
py: 0.25,
px: 1,
color: 'primary.main',
'&:hover': {
bgcolor: 'primary.50',
},
}}
>
Artikel suchen
</Button>
</Box>
);
}
return null;
}
renderDeliverySecondLine(rawSku) {
const { line, supplierArticle, loading } = this.props;
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
if (loading) {
return (

View File

@@ -9,6 +9,7 @@ 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 SearchIcon from '@mui/icons-material/Search';
import DeleteSweepOutlinedIcon from '@mui/icons-material/DeleteSweepOutlined';
import { PanelTile } from 'dashgui/panels';
import { LINE_COLUMNS, hasArticleLines } from './manifestConfig';
@@ -17,6 +18,7 @@ import ManifestFormLine from './ManifestFormLine';
class ManifestLines extends PureComponent {
render() {
const {
kind = 'delivery',
lines,
saving,
linesColor,
@@ -86,17 +88,21 @@ class ManifestLines extends PureComponent {
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 isDelivery = kind === 'delivery';
const matched = isDelivery
? supplierArticlesByLineKey[lineKey] ||
(rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null)
: null;
const isLineLoading = isDelivery
? loadingLineKeys[lineKey] ||
(supplierArticlesLoading && !matched)
: false;
const ledgerLine = line.id != null ? ledgerLinesById[line.id] : null;
return (
<ManifestFormLine
key={lineKey}
kind={kind}
line={line.key != null ? line : { ...line, key: index }}
columns={columns}
saving={saving}
@@ -126,6 +132,16 @@ class ManifestLines extends PureComponent {
>
Zeile
</Button>
{onSearchArticle && (
<Button
size="small"
startIcon={<SearchIcon />}
disabled={saving}
onClick={() => onSearchArticle(null)}
>
Artikel suchen
</Button>
)}
<Button
size="small"
color="error"