This commit is contained in:
seb
2026-10-08 21:32:52 +02:00
parent 056e4f63b6
commit 22063e6944

View File

@@ -34,6 +34,9 @@ import { artikelDetailPath } from '../articleRoute';
import { article as articleApi } from '../../../api'; import { article as articleApi } from '../../../api';
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat'; import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
import companyAutomat from '../../../automats/companyAutomat';
import { formatStock } from '../searchFormat';
import { formatPrice, getArticleGross } from '../../../utils/price';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
import articleIndexAutomat from '../../../automats/articleIndexAutomat'; import articleIndexAutomat from '../../../automats/articleIndexAutomat';
import { import {
@@ -43,6 +46,67 @@ import {
alpha, alpha,
} from '../../../colors'; } from '../../../colors';
function getVariantDetails(child, allArticles, defaultTaxRate) {
const cached = child?.sku ? getArticle(child.sku) : null;
const fromIndex = !cached && child?.sku
? (allArticles || []).find((a) => a.sku === child.sku)
: null;
const merged = cached || fromIndex || child || {};
// 1. Gross price
const gross = getArticleGross(merged, defaultTaxRate);
const priceDisplay = gross != null && Number.isFinite(gross) ? formatPrice(gross) : null;
// 2. Stock & Unit
let stockVal = null;
if (Array.isArray(merged.warehouses) && merged.warehouses.length > 0) {
stockVal = merged.warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0);
} else if (merged.stock != null && !Number.isNaN(Number(merged.stock))) {
stockVal = Number(merged.stock);
} else if (child?.stock != null && !Number.isNaN(Number(child.stock))) {
stockVal = Number(child.stock);
}
const rawUnit =
merged.unit?.symbol ||
merged.unit?.name ||
child?.unit?.symbol ||
child?.unit?.name ||
'';
const unit =
rawUnit && !['stk', 'stück', 'pc', 'stk.'].includes(rawUnit.trim().toLowerCase())
? ` ${rawUnit}`
: '';
const stockDisplay = stockVal != null ? `${formatStock(stockVal)}${unit}` : null;
// 3. EAN(s)
const eanCandidates = [];
const rawEans = merged.eans ?? child?.eans;
if (Array.isArray(rawEans)) {
for (const item of rawEans) {
const val = typeof item === 'object' && item !== null ? item.ean : item;
if (val && String(val).trim()) eanCandidates.push(String(val).trim());
}
} else if (rawEans && typeof rawEans === 'string') {
eanCandidates.push(rawEans.trim());
}
const singleEan = merged.ean ?? child?.ean;
if (singleEan && typeof singleEan === 'string' && !eanCandidates.includes(singleEan.trim())) {
eanCandidates.push(singleEan.trim());
}
const primaryEan = eanCandidates[0] ?? null;
return {
priceDisplay,
stockVal,
stockDisplay,
primaryEan,
allEans: eanCandidates,
};
}
export default class ArticleViewLists extends PureComponent { export default class ArticleViewLists extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -88,12 +152,16 @@ export default class ArticleViewLists extends PureComponent {
// Confirmation dialogs // Confirmation dialogs
removeTargetChild: null, // { sku, attributeValue } removeTargetChild: null, // { sku, attributeValue }
confirmClearType: null, // 'stueckliste' | 'options' | null confirmClearType: null, // 'stueckliste' | 'options' | null
company: companyAutomat.state.current,
}; };
this._cancelled = false; this._cancelled = false;
} }
componentDidMount() { componentDidMount() {
this._cancelled = false; this._cancelled = false;
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
company: state.current,
}));
this.unsubAttr = attributeAutomat.subscribe((state) => { this.unsubAttr = attributeAutomat.subscribe((state) => {
if (this._cancelled) return; if (this._cancelled) return;
this.setState((prev) => { this.setState((prev) => {
@@ -123,21 +191,48 @@ export default class ArticleViewLists extends PureComponent {
void this.loadAssignedAttributes(this.props.article.sku); void this.loadAssignedAttributes(this.props.article.sku);
} }
if (this.props.article?.isParent) {
this.ensureChildren(this.props.article.children);
}
if (this.props.article?.parentSku) { if (this.props.article?.parentSku) {
void artikelAutomat.actions.loadArticle(this.props.article.parentSku); void artikelAutomat.actions.loadArticle(this.props.article.parentSku).then((parent) => {
if (!this._cancelled && parent?.children) {
this.ensureChildren(parent.children);
}
}).catch(() => {});
} }
void this.loadAllArticles(); void this.loadAllArticles();
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.article?.isParent) {
if (
prevProps.article?.sku !== this.props.article?.sku ||
prevProps.article?.children !== this.props.article?.children
) {
this.ensureChildren(this.props.article.children);
}
}
if (this.props.article?.parentSku) {
if (prevProps.article?.parentSku !== this.props.article?.parentSku) {
void artikelAutomat.actions.loadArticle(this.props.article.parentSku).then((parent) => {
if (!this._cancelled && parent?.children) {
this.ensureChildren(parent.children);
}
}).catch(() => {});
} else {
const parent = getArticle(this.props.article.parentSku);
if (parent?.children) {
this.ensureChildren(parent.children);
}
}
}
if (prevProps.article?.sku !== this.props.article?.sku) { if (prevProps.article?.sku !== this.props.article?.sku) {
if (this.props.article?.sku) { if (this.props.article?.sku) {
void this.loadAssignedAttributes(this.props.article.sku); void this.loadAssignedAttributes(this.props.article.sku);
} }
if (this.props.article?.parentSku) {
void artikelAutomat.actions.loadArticle(this.props.article.parentSku);
}
const nextArticle = this.props.article; const nextArticle = this.props.article;
const hasStueckliste = Boolean( const hasStueckliste = Boolean(
nextArticle?.articlelistId != null || nextArticle?.articlelist?.length > 0 nextArticle?.articlelistId != null || nextArticle?.articlelist?.length > 0
@@ -166,10 +261,20 @@ export default class ArticleViewLists extends PureComponent {
componentWillUnmount() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
this.unsubCompany?.();
this.unsubAttr?.(); this.unsubAttr?.();
this.unsubArtikel?.(); this.unsubArtikel?.();
} }
ensureChildren(children) {
if (!Array.isArray(children)) return;
for (const child of children) {
if (child?.sku && !getArticle(child.sku)) {
void artikelAutomat.actions.loadArticle(child.sku).catch(() => {});
}
}
}
getApi() { getApi() {
return articleApi; return articleApi;
} }
@@ -1073,6 +1178,8 @@ export default class ArticleViewLists extends PureComponent {
renderChildView(parentSku, parentArticle, attributes, assignedAttributes) { renderChildView(parentSku, parentArticle, attributes, assignedAttributes) {
const { article: articleData } = this.props; const { article: articleData } = this.props;
const { allArticles } = this.state;
const defaultTaxRate = this.state.company?.taxrate ?? companyAutomat.state.current?.taxrate ?? 19;
const parentAttrName = const parentAttrName =
parentArticle?.parentAttributeName || parentArticle?.parentAttributeName ||
(parentArticle?.parentAttributeNameId (parentArticle?.parentAttributeNameId
@@ -1235,6 +1342,13 @@ export default class ArticleViewLists extends PureComponent {
const isSelf = const isSelf =
sibling.sku === articleData.sku || sibling.sku === articleData.sku ||
(articleData.id != null && sibling.id === articleData.id); (articleData.id != null && sibling.id === articleData.id);
const {
priceDisplay,
stockVal,
stockDisplay,
primaryEan,
allEans,
} = getVariantDetails(sibling, allArticles, defaultTaxRate);
return ( return (
<Box <Box
@@ -1268,25 +1382,49 @@ export default class ArticleViewLists extends PureComponent {
py: 0.25, py: 0.25,
}} }}
> >
<Typography <Box
variant="body2"
sx={{ sx={{
fontWeight: 600, display: 'flex',
fontSize: '0.86rem', alignItems: 'baseline',
lineHeight: 1.3, justifyContent: 'space-between',
overflow: 'hidden', gap: 1,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'text.primary',
}} }}
> >
{sibling.attributeValue || variationValue || sibling.name || sibling.sku} <Typography
</Typography> variant="body2"
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}> sx={{
fontWeight: 600,
fontSize: '0.86rem',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'text.primary',
flex: 1,
}}
>
{sibling.attributeValue || variationValue || sibling.name || sibling.sku}
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: '0.86rem',
lineHeight: 1.3,
whiteSpace: 'nowrap',
color: priceDisplay ? 'text.primary' : 'text.disabled',
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
}}
>
{priceDisplay || '—'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.6, mt: 0.3 }}>
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
fontSize: '0.78rem', fontSize: '0.76rem',
fontWeight: 600, fontWeight: 600,
color: 'text.secondary', color: 'text.secondary',
fontFamily: 'monospace', fontFamily: 'monospace',
@@ -1295,6 +1433,44 @@ export default class ArticleViewLists extends PureComponent {
> >
{sibling.sku} {sibling.sku}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.72rem' }}>
·
</Typography>
<Tooltip
title={allEans.length > 1 ? `EANs: ${allEans.join(', ')}` : ''}
disableHoverListener={allEans.length <= 1}
>
<Typography
variant="caption"
sx={{
fontSize: '0.74rem',
color: primaryEan ? 'text.secondary' : 'text.disabled',
fontFamily: primaryEan ? 'monospace' : 'inherit',
letterSpacing: primaryEan ? '0.01em' : 'normal',
whiteSpace: 'nowrap',
}}
>
{primaryEan ? `EAN ${primaryEan}${allEans.length > 1 ? ` (+${allEans.length - 1})` : ''}` : 'EAN —'}
</Typography>
</Tooltip>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.72rem' }}>
·
</Typography>
<Typography
variant="caption"
sx={{
fontSize: '0.74rem',
fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap',
}}
>
Bestand {stockDisplay ?? '—'}
</Typography>
</Box> </Box>
</Box> </Box>
) : ( ) : (
@@ -1319,37 +1495,101 @@ export default class ArticleViewLists extends PureComponent {
}, },
}} }}
> >
<Typography <Box
className="child-name"
variant="body2"
sx={{ sx={{
fontWeight: 600, display: 'flex',
fontSize: '0.86rem', alignItems: 'baseline',
lineHeight: 1.3, justifyContent: 'space-between',
overflow: 'hidden', gap: 1,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'text.primary',
transition: 'color 0.15s ease',
}} }}
> >
{sibling.attributeValue || sibling.name || sibling.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
<Typography <Typography
className="child-sku" className="child-name"
variant="caption" variant="body2"
sx={{ sx={{
fontSize: '0.78rem',
fontWeight: 600, fontWeight: 600,
color: 'primary.main', fontSize: '0.86rem',
fontFamily: 'monospace', lineHeight: 1.3,
letterSpacing: '0.02em', overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'text.primary',
transition: 'color 0.15s ease',
flex: 1,
}} }}
> >
{sibling.sku} {sibling.attributeValue || sibling.name || sibling.sku}
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: '0.86rem',
lineHeight: 1.3,
whiteSpace: 'nowrap',
color: priceDisplay ? 'text.primary' : 'text.disabled',
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
}}
>
{priceDisplay || '—'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.6, mt: 0.3 }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35 }}>
<Typography
className="child-sku"
variant="caption"
sx={{
fontSize: '0.76rem',
fontWeight: 600,
color: 'primary.main',
fontFamily: 'monospace',
letterSpacing: '0.02em',
}}
>
{sibling.sku}
</Typography>
<OpenInNewIcon sx={{ fontSize: 12, color: 'primary.main', opacity: 0.8 }} />
</Box>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.72rem' }}>
·
</Typography>
<Tooltip
title={allEans.length > 1 ? `EANs: ${allEans.join(', ')}` : ''}
disableHoverListener={allEans.length <= 1}
>
<Typography
variant="caption"
sx={{
fontSize: '0.74rem',
color: primaryEan ? 'text.secondary' : 'text.disabled',
fontFamily: primaryEan ? 'monospace' : 'inherit',
letterSpacing: primaryEan ? '0.01em' : 'normal',
whiteSpace: 'nowrap',
}}
>
{primaryEan ? `EAN ${primaryEan}${allEans.length > 1 ? ` (+${allEans.length - 1})` : ''}` : 'EAN —'}
</Typography>
</Tooltip>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.72rem' }}>
·
</Typography>
<Typography
variant="caption"
sx={{
fontSize: '0.74rem',
fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap',
}}
>
Bestand {stockDisplay ?? '—'}
</Typography> </Typography>
<OpenInNewIcon sx={{ fontSize: 13, color: 'primary.main', opacity: 0.8 }} />
</Box> </Box>
</Link> </Link>
)} )}
@@ -1645,100 +1885,181 @@ export default class ArticleViewLists extends PureComponent {
</Typography> </Typography>
) : ( ) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{children.map((child) => ( {children.map((child) => {
<Box const defaultTaxRate = this.state.company?.taxrate ?? companyAutomat.state.current?.taxrate ?? 19;
key={child.id || child.sku} const {
sx={{ priceDisplay,
display: 'flex', stockVal,
alignItems: 'center', stockDisplay,
justifyContent: 'space-between', primaryEan,
px: 1, allEans,
py: 0.6, } = getVariantDetails(child, allArticles, defaultTaxRate);
borderRadius: 1,
bgcolor: 'background.paper', return (
border: '1px solid', <Box
borderColor: 'divider', key={child.id || child.sku}
transition: 'all 0.15s ease-in-out',
'&:hover': {
borderColor: 'primary.main',
boxShadow: SHADOW_CARD,
},
}}
>
<Link
component={RouterLink}
to={artikelDetailPath(child.sku)}
sx={{ sx={{
minWidth: 0,
flex: 1,
display: 'flex', display: 'flex',
flexDirection: 'column', alignItems: 'center',
textDecoration: 'none', justifyContent: 'space-between',
color: 'inherit', px: 1,
py: 0.25, py: 0.6,
mr: 1, borderRadius: 1,
cursor: 'pointer', bgcolor: 'background.paper',
'&:hover .child-name': { border: '1px solid',
color: 'primary.main', borderColor: 'divider',
}, transition: 'all 0.15s ease-in-out',
'&:hover .child-sku': { '&:hover': {
color: 'primary.dark', borderColor: 'primary.main',
textDecoration: 'underline', boxShadow: SHADOW_CARD,
}, },
}} }}
> >
<Typography <Link
className="child-name" component={RouterLink}
variant="body2" to={artikelDetailPath(child.sku)}
sx={{ sx={{
fontWeight: 600, minWidth: 0,
fontSize: '0.86rem', flex: 1,
lineHeight: 1.3, display: 'flex',
overflow: 'hidden', flexDirection: 'column',
textOverflow: 'ellipsis', textDecoration: 'none',
whiteSpace: 'nowrap', color: 'inherit',
color: 'text.primary', py: 0.25,
transition: 'color 0.15s ease', mr: 1,
cursor: 'pointer',
'&:hover .child-name': {
color: 'primary.main',
},
'&:hover .child-sku': {
color: 'primary.dark',
textDecoration: 'underline',
},
}} }}
> >
{child.attributeValue || child.name || child.sku} <Box
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
<Typography
className="child-sku"
variant="caption"
sx={{ sx={{
fontSize: '0.78rem', display: 'flex',
fontWeight: 600, alignItems: 'baseline',
color: 'primary.main', justifyContent: 'space-between',
fontFamily: 'monospace', gap: 1,
letterSpacing: '0.02em',
display: 'inline-flex',
alignItems: 'center',
gap: 0.35,
}} }}
> >
{child.sku} <Typography
</Typography> className="child-name"
<OpenInNewIcon sx={{ fontSize: 13, color: 'primary.main', opacity: 0.8 }} /> variant="body2"
</Box> sx={{
</Link> fontWeight: 600,
fontSize: '0.86rem',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'text.primary',
transition: 'color 0.15s ease',
flex: 1,
}}
>
{child.attributeValue || child.name || child.sku}
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: '0.86rem',
lineHeight: 1.3,
whiteSpace: 'nowrap',
color: priceDisplay ? 'text.primary' : 'text.disabled',
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
}}
>
{priceDisplay || '—'}
</Typography>
</Box>
<IconButton <Box
size="small" sx={{
disabled={busy} display: 'flex',
onClick={() => this.handleOpenRemoveDialog(child)} alignItems: 'center',
title="Variante entfernen" flexWrap: 'wrap',
sx={{ gap: 0.6,
p: 0.35, mt: 0.3,
color: 'text.secondary', }}
'&:hover': { color: 'error.main' }, >
}} <Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.35 }}>
> <Typography
<DeleteOutlinedIcon sx={{ fontSize: 17 }} /> className="child-sku"
</IconButton> variant="caption"
</Box> sx={{
))} fontSize: '0.76rem',
fontWeight: 600,
color: 'primary.main',
fontFamily: 'monospace',
letterSpacing: '0.02em',
}}
>
{child.sku}
</Typography>
<OpenInNewIcon sx={{ fontSize: 12, color: 'primary.main', opacity: 0.8 }} />
</Box>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.72rem' }}>
·
</Typography>
<Tooltip
title={allEans.length > 1 ? `EANs: ${allEans.join(', ')}` : ''}
disableHoverListener={allEans.length <= 1}
>
<Typography
variant="caption"
sx={{
fontSize: '0.74rem',
color: primaryEan ? 'text.secondary' : 'text.disabled',
fontFamily: primaryEan ? 'monospace' : 'inherit',
letterSpacing: primaryEan ? '0.01em' : 'normal',
whiteSpace: 'nowrap',
}}
>
{primaryEan ? `EAN ${primaryEan}${allEans.length > 1 ? ` (+${allEans.length - 1})` : ''}` : 'EAN —'}
</Typography>
</Tooltip>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.72rem' }}>
·
</Typography>
<Typography
variant="caption"
sx={{
fontSize: '0.74rem',
fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap',
}}
>
Bestand {stockDisplay ?? '—'}
</Typography>
</Box>
</Link>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleOpenRemoveDialog(child)}
title="Variante entfernen"
sx={{
p: 0.35,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 17 }} />
</IconButton>
</Box>
);
})}
</Box> </Box>
)} )}
</Box> </Box>