Compare commits

...

3 Commits

Author SHA1 Message Date
seb
6ca7597c59 u 2026-10-30 05:02:47 +01:00
seb
05f1311892 u 2026-10-30 04:48:35 +01:00
seb
dba9ae5cf0 u 2026-10-30 04:16:56 +01:00
13 changed files with 676 additions and 285 deletions

View File

@@ -12,4 +12,10 @@ export const thumb = {
return `/api/thumb/${encodeURIComponent(fullHash)}`;
},
// #endregion
// #region GET /api/smallthumb/:fullHash — smallUrl
smallUrl(fullHash) {
return `/api/smallthumb/${encodeURIComponent(fullHash)}`;
},
// #endregion
};

View File

@@ -31,7 +31,7 @@ class ArticleNameDialog extends Component {
super(props);
this.state = {
mode: SINGLE_MODE,
name: '',
name: props.initialName ?? '',
bulkText: '',
busy: false,
error: null,
@@ -46,7 +46,7 @@ class ArticleNameDialog extends Component {
if (this.props.open && !prevProps.open) {
this.setState({
mode: SINGLE_MODE,
name: '',
name: this.props.initialName ?? '',
bulkText: '',
busy: false,
error: null,

View File

@@ -9,14 +9,17 @@ import {
class ArticlePhoto extends Component {
constructor(props) {
super(props);
this.state = { thumbLoaded: false };
this.state = { thumbLoaded: false, smallThumbError: false };
this.handleThumbLoad = this.handleThumbLoad.bind(this);
this.handleThumbError = this.handleThumbError.bind(this);
}
componentDidUpdate(prevProps) {
if (prevProps.photo?.fullHash !== this.props.photo?.fullHash) {
this.setState({ thumbLoaded: false });
if (
prevProps.photo?.fullHash !== this.props.photo?.fullHash ||
prevProps.useSmallThumb !== this.props.useSmallThumb
) {
this.setState({ thumbLoaded: false, smallThumbError: false });
}
}
@@ -25,7 +28,11 @@ class ArticlePhoto extends Component {
}
handleThumbError() {
this.setState({ thumbLoaded: false });
if (this.props.useSmallThumb && !this.state.smallThumbError) {
this.setState({ smallThumbError: true, thumbLoaded: false });
} else {
this.setState({ thumbLoaded: false });
}
}
render() {
@@ -34,9 +41,11 @@ class ArticlePhoto extends Component {
size = 200,
fill = false,
objectFit = fill ? 'cover' : 'contain',
useSmallThumb = false,
sx,
...rest
} = this.props;
const { thumbLoaded } = this.state;
const { thumbLoaded, smallThumbError } = this.state;
const fullHash = photo?.fullHash;
const hasPreview = photo?.thumbHash != null;
const aspectRatio =
@@ -44,8 +53,15 @@ class ArticlePhoto extends Component {
? `${photo.thumbW} / ${photo.thumbH}`
: '1 / 1';
const imgSrc = fullHash
? useSmallThumb && !smallThumbError
? thumb.smallUrl(fullHash)
: thumb.url(fullHash)
: null;
return (
<Box
{...rest}
sx={{
position: 'relative',
...(fill
@@ -78,10 +94,10 @@ class ArticlePhoto extends Component {
<ThumbHashImage thumbHash={photo.thumbHash} />
</Box>
) : null}
{fullHash ? (
{imgSrc ? (
<Box
component="img"
src={thumb.url(fullHash)}
src={imgSrc}
alt=""
onLoad={this.handleThumbLoad}
onError={this.handleThumbError}

View File

@@ -1,16 +1,27 @@
import { PureComponent } from 'react';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import Box from '@mui/material/Box';
import { PanelTile } from 'dashgui/panels';
import { primaryPhoto } from '../../utils/articleDetail';
import { sortedPhotos, primaryPhoto } from '../../utils/articleDetail';
import ArticlePhoto from './ArticlePhoto';
import ArticlePhotosDialog from './ArticlePhotosDialog';
class ArticlePhotoTile extends PureComponent {
constructor(props) {
super(props);
this.state = { editOpen: false };
this.state = {
editOpen: false,
selectedHash: null,
};
this.handleEditOpen = this.handleEditOpen.bind(this);
this.handleEditClose = this.handleEditClose.bind(this);
this.handleSelectPhoto = this.handleSelectPhoto.bind(this);
}
componentDidUpdate(prevProps) {
if (prevProps.article?.sku !== this.props.article?.sku) {
this.setState({ selectedHash: null });
}
}
handleEditOpen() {
@@ -21,6 +32,10 @@ class ArticlePhotoTile extends PureComponent {
this.setState({ editOpen: false });
}
handleSelectPhoto(fullHash) {
this.setState({ selectedHash: fullHash });
}
render() {
const {
article: articleData,
@@ -30,8 +45,14 @@ class ArticlePhotoTile extends PureComponent {
sx,
color,
} = this.props;
const { editOpen } = this.state;
const photo = primaryPhoto(articleData);
const { editOpen, selectedHash } = this.state;
const photos = sortedPhotos(articleData);
const primary = primaryPhoto(articleData);
const activePhoto =
(selectedHash && photos.find((p) => p.fullHash === selectedHash)) ||
primary;
const otherPhotos = photos.filter((p) => p.fullHash !== activePhoto?.fullHash);
return (
<>
@@ -47,7 +68,86 @@ class ArticlePhotoTile extends PureComponent {
busy: false,
}}
>
<ArticlePhoto photo={photo} />
{otherPhotos.length === 0 ? (
<ArticlePhoto photo={activePhoto} />
) : (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'flex-start',
gap: 1.5,
}}
>
<ArticlePhoto photo={activePhoto} />
{/* Smallthumbs of all other images beside the main image */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.75,
maxHeight: 200,
overflowY: 'auto',
overflowX: 'hidden',
mt: '32px',
py: 0.25,
px: 0.25,
flexShrink: 0,
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-thumb': {
bgcolor: 'action.hover',
borderRadius: 2,
},
}}
>
{otherPhotos.map((p) => (
<Box
key={p.fullHash}
component="button"
type="button"
onClick={(e) => {
e.stopPropagation();
this.handleSelectPhoto(p.fullHash);
}}
sx={{
p: 0,
m: 0,
width: 40,
height: 40,
flexShrink: 0,
cursor: 'pointer',
borderRadius: 1,
overflow: 'hidden',
border: '2px solid transparent',
bgcolor: 'transparent',
outline: 'none',
display: 'block',
transition: 'all 0.15s ease',
'&:hover': {
transform: 'scale(1.08)',
borderColor: 'primary.main',
boxShadow: 2,
},
'&:focus-visible': {
borderColor: 'primary.main',
},
}}
>
<ArticlePhoto
photo={p}
size={40}
useSmallThumb
objectFit="cover"
sx={{ width: '100%', height: '100%', borderRadius: 1 }}
/>
</Box>
))}
</Box>
</Box>
)}
</PanelTile>
<ArticlePhotosDialog
open={editOpen}

View File

@@ -43,14 +43,12 @@ export default class CategoryPickerDialog extends PureComponent {
}
componentDidMount() {
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
this._unsub = categoryAutomat.subscribe((s) => {
this.setState(() => ({
categories: this.props.categories ?? s.categories,
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
}));
this._unsub = categoryAutomat.subscribe((s) => {
this.setState({
categories: s.categories,
rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId,
});
}
});
}
componentDidUpdate(prevProps) {
@@ -64,10 +62,12 @@ export default class CategoryPickerDialog extends PureComponent {
} else if (this.state.rootCategoryId != null) {
nextSelectedId = this.state.rootCategoryId;
}
this.setState({ selectedId: nextSelectedId });
}
if (
this.setState({
selectedId: nextSelectedId,
categories: categoryAutomat.state.categories ?? this.props.categories,
rootCategoryId: categoryAutomat.state.rootCategoryId ?? this.props.rootCategoryId,
});
} else if (
this.props.categories !== prevProps.categories ||
this.props.rootCategoryId !== prevProps.rootCategoryId
) {

View File

@@ -45,7 +45,7 @@ import {
alpha,
} from '../../../colors';
function getVariantDetails(child, allArticles, defaultTaxRate) {
function getVariantDetails(child, allArticles, defaultTaxRate, parentArticle) {
const cached = child?.sku ? getArticle(child.sku) : null;
const fromIndex = !cached && child?.sku
? (allArticles || []).find((a) => a.sku === child.sku)
@@ -56,14 +56,30 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
const gross = getArticleGross(merged, defaultTaxRate);
const priceDisplay = gross != null && Number.isFinite(gross) ? formatPrice(gross) : null;
// 2. Stock & Unit
// 2. Stock tracking
const rawTrack =
merged.track_stock ??
merged.trackStock ??
child?.trackStock ??
child?.track_stock ??
parentArticle?.track_stock ??
parentArticle?.trackStock;
const trackStock =
rawTrack !== undefined
? rawTrack !== false && rawTrack !== 0 && rawTrack !== '0'
: true;
// 3. 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);
if (trackStock) {
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 =
@@ -76,9 +92,9 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
rawUnit && !['stk', 'stück', 'pc', 'stk.'].includes(rawUnit.trim().toLowerCase())
? ` ${rawUnit}`
: '';
const stockDisplay = stockVal != null ? `${formatStock(stockVal)}${unit}` : null;
const stockDisplay = trackStock && stockVal != null ? `${formatStock(stockVal)}${unit}` : null;
// 3. EAN(s)
// 4. EAN(s)
const eanCandidates = [];
const rawEans = merged.eans ?? child?.eans;
if (Array.isArray(rawEans)) {
@@ -103,6 +119,7 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
stockDisplay,
primaryEan,
allEans: eanCandidates,
trackStock,
};
}
@@ -123,6 +140,7 @@ export default class ArticleViewLists extends PureComponent {
this.state = {
attributes: initialAttributes,
attributesStatus: companyAutomat.state.attributesStatus || 'idle',
assignedAttributes: [],
allArticles: [],
busy: false,
@@ -170,10 +188,11 @@ export default class ArticleViewLists extends PureComponent {
return {
company: state.current,
attributes: state.attributes,
attributesStatus: state.attributesStatus,
selectedAttributeId: nextSelectedId,
};
});
}, ['current', 'attributes']);
}, ['current', 'attributes', 'attributesStatus']);
this.unsubArtikel = artikelAutomat.subscribe(() => {
if (this._cancelled) return;
@@ -197,7 +216,7 @@ export default class ArticleViewLists extends PureComponent {
if (!this._cancelled && parent?.children) {
this.ensureChildren(parent.children);
}
}).catch(() => {});
}).catch(() => { });
}
void this.loadAllArticles();
@@ -218,7 +237,7 @@ export default class ArticleViewLists extends PureComponent {
if (!this._cancelled && parent?.children) {
this.ensureChildren(parent.children);
}
}).catch(() => {});
}).catch(() => { });
} else {
const parent = getArticle(this.props.article.parentSku);
if (parent?.children) {
@@ -266,7 +285,7 @@ export default class ArticleViewLists extends PureComponent {
if (!Array.isArray(children)) return;
for (const child of children) {
if (child?.sku && !getArticle(child.sku)) {
void artikelAutomat.actions.loadArticle(child.sku).catch(() => {});
void artikelAutomat.actions.loadArticle(child.sku).catch(() => { });
}
}
}
@@ -391,30 +410,41 @@ export default class ArticleViewLists extends PureComponent {
const { selectedChildValueId, newChildText, isCustomChildValue, busy } = this.state;
if (busy || !article?.sku) return;
const val = newChildText.trim();
const payload = {};
if (isCustomChildValue) {
const val = newChildText.trim();
if (!val) return;
if (val && (isCustomChildValue || !selectedChildValueId)) {
payload.value = val;
} else if (selectedChildValueId) {
payload.attributeValueId = Number(selectedChildValueId);
} else if (val) {
payload.value = val;
} else {
if (!selectedChildValueId) return;
payload.attributeValueId = Number(selectedChildValueId);
return;
}
this.setState({ busy: true });
try {
const api = this.getApi();
await api.addChild(article.sku, payload);
const res = await api.addChild(article.sku, payload);
if (this._cancelled) return;
if (res?.sku) {
void artikelAutomat.actions.loadArticle(res.sku).catch(() => { });
}
await artikelAutomat.actions.reloadArticle(article.sku);
if (this._cancelled) return;
void articleIndexAutomat.actions.reload().catch(() => { });
void companyAutomat.actions.loadAttributes?.().catch?.(() => { });
enqueueSnackbar('Variante erfolgreich angelegt.', { variant: 'success' });
this.setState({
busy: false,
selectedChildValueId: '',
newChildText: '',
isCustomChildValue: false,
});
} catch (err) {
if (this._cancelled) return;
@@ -489,17 +519,17 @@ export default class ArticleViewLists extends PureComponent {
const nextItems =
existingIndex >= 0
? currentItems.map((item, idx) =>
idx === existingIndex ? { ...item, count: item.count + count } : item
)
idx === existingIndex ? { ...item, count: item.count + count } : item
)
: [
...currentItems,
{
articleId: stuecklisteTargetArticle.id,
sku: stuecklisteTargetArticle.sku,
count,
sort: currentItems.length,
},
];
...currentItems,
{
articleId: stuecklisteTargetArticle.id,
sku: stuecklisteTargetArticle.sku,
count,
sort: currentItems.length,
},
];
this.setState({ busy: true });
try {
@@ -613,20 +643,20 @@ export default class ArticleViewLists extends PureComponent {
const nextOptions =
existingIndex >= 0
? currentOptions.map((opt, idx) =>
idx === existingIndex
? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) }
: opt
)
idx === existingIndex
? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) }
: opt
)
: [
...currentOptions,
{
articleId: optionTargetArticle.id,
sku: optionTargetArticle.sku,
count,
sort: currentOptions.length,
mandated: Boolean(optionMandated),
},
];
...currentOptions,
{
articleId: optionTargetArticle.id,
sku: optionTargetArticle.sku,
count,
sort: currentOptions.length,
mandated: Boolean(optionMandated),
},
];
this.setState({ busy: true });
try {
@@ -832,70 +862,70 @@ export default class ArticleViewLists extends PureComponent {
}}
>
{stuecklisteItems.map((item) => (
<Box
key={item.id || item.sku}
<Box
key={item.id || item.sku}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.4,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip
label={`${item.count}x`}
size="small"
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
/>
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.82rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.name || item.sku}
</Typography>
<Link
component={RouterLink}
to={artikelDetailPath(item.sku)}
sx={{
fontSize: '0.72rem',
fontFamily: 'monospace',
color: 'text.secondary',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{item.sku}
</Link>
</Box>
</Box>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveStuecklisteItem(item.sku)}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.4,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip
label={`${item.count}x`}
size="small"
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
/>
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.82rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.name || item.sku}
</Typography>
<Link
component={RouterLink}
to={artikelDetailPath(item.sku)}
sx={{
fontSize: '0.72rem',
fontFamily: 'monospace',
color: 'text.secondary',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{item.sku}
</Link>
</Box>
</Box>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveStuecklisteItem(item.sku)}
sx={{
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))
}
</Box>
</Box>
@@ -1042,81 +1072,81 @@ export default class ArticleViewLists extends PureComponent {
}}
>
{optionsItems.map((opt) => (
<Box
key={opt.id || opt.sku}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.4,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip
label={`${opt.count}x`}
size="small"
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
/>
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography
variant="body2"
<Box
key={opt.id || opt.sku}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.4,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip
label={`${opt.count}x`}
size="small"
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
/>
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.82rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{opt.name || opt.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Link
component={RouterLink}
to={artikelDetailPath(opt.sku)}
sx={{
fontWeight: 600,
fontSize: '0.82rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '0.72rem',
fontFamily: 'monospace',
color: 'text.secondary',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{opt.name || opt.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Link
component={RouterLink}
to={artikelDetailPath(opt.sku)}
sx={{
fontSize: '0.72rem',
fontFamily: 'monospace',
color: 'text.secondary',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{opt.sku}
</Link>
{opt.mandated ? (
<Chip
label="Fest"
size="small"
color="warning"
variant="outlined"
sx={{ height: 16, fontSize: '0.65rem', fontWeight: 600 }}
/>
) : null}
</Box>
{opt.sku}
</Link>
{opt.mandated ? (
<Chip
label="Fest"
size="small"
color="warning"
variant="outlined"
sx={{ height: 16, fontSize: '0.65rem', fontWeight: 600 }}
/>
) : null}
</Box>
</Box>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveOption(opt.sku)}
sx={{
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveOption(opt.sku)}
sx={{
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))
}
</Box>
</Box>
@@ -1209,14 +1239,14 @@ export default class ArticleViewLists extends PureComponent {
const siblings = hasSelf
? childrenList
: [
...childrenList,
{
id: articleData.id,
sku: articleData.sku,
name: articleData.name,
attributeValue: variationValue,
},
];
...childrenList,
{
id: articleData.id,
sku: articleData.sku,
name: articleData.name,
attributeValue: variationValue,
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}>
@@ -1346,7 +1376,8 @@ export default class ArticleViewLists extends PureComponent {
stockDisplay,
primaryEan,
allEans,
} = getVariantDetails(sibling, allArticles, defaultTaxRate);
trackStock,
} = getVariantDetails(sibling, allArticles, defaultTaxRate, parentArticle);
return (
<Box
@@ -1463,7 +1494,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{
fontSize: '0.74rem',
fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap',
}}
>
@@ -1582,7 +1613,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{
fontSize: '0.74rem',
fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap',
}}
>
@@ -1602,7 +1633,7 @@ export default class ArticleViewLists extends PureComponent {
}
renderStandardView(selectableAttributes, assignedAttributeIds) {
const { busy, activeCreatorMode, selectedAttributeId } = this.state;
const { busy, activeCreatorMode, selectedAttributeId, attributes, attributesStatus } = this.state;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{activeCreatorMode === 'parent' ? (
@@ -1624,7 +1655,36 @@ export default class ArticleViewLists extends PureComponent {
</Button>
</Box>
{selectableAttributes.length === 0 ? (
{attributesStatus === 'loading' ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<CircularProgress size={14} />
<Typography variant="caption" color="text.secondary">
Merkmale werden geladen…
</Typography>
</Box>
) : !attributes || attributes.length === 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, py: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Im System sind noch keine Merkmale angelegt.
</Typography>
<Link
component={RouterLink}
to="/artikel/merkmale"
variant="caption"
sx={{
color: 'primary.main',
fontWeight: 600,
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
'&:hover': { textDecoration: 'underline' },
}}
>
Zu den Merkmalen →
</Link>
</Box>
) : selectableAttributes.length === 0 ? (
<Typography variant="caption" color="text.secondary">
Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen.
</Typography>
@@ -1877,11 +1937,7 @@ export default class ArticleViewLists extends PureComponent {
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
Varianten:
</Typography>
{children.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: '0.8rem' }}>
Noch keine Varianten angelegt.
</Typography>
) : (
{children.length === 0 ? null : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{children.map((child) => {
const defaultTaxRate = this.state.company?.taxrate ?? companyAutomat.state.current?.taxrate ?? 19;
@@ -1891,7 +1947,8 @@ export default class ArticleViewLists extends PureComponent {
stockDisplay,
primaryEan,
allEans,
} = getVariantDetails(child, allArticles, defaultTaxRate);
trackStock,
} = getVariantDetails(child, allArticles, defaultTaxRate, articleData);
return (
<Box
@@ -2033,7 +2090,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{
fontSize: '0.74rem',
fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap',
}}
>
@@ -2046,7 +2103,6 @@ export default class ArticleViewLists extends PureComponent {
size="small"
disabled={busy}
onClick={() => this.handleOpenRemoveDialog(child)}
title="Variante entfernen"
sx={{
p: 0.35,
color: 'text.secondary',
@@ -2067,7 +2123,6 @@ export default class ArticleViewLists extends PureComponent {
{isCustomChildValue || availableParentValues.length === 0 ? (
<TextInput
label="Neuer Wert"
placeholder="Neuer Wert..."
value={newChildText}
disabled={busy}
onChange={(e) => this.setState({ newChildText: e.target.value })}
@@ -2112,34 +2167,31 @@ export default class ArticleViewLists extends PureComponent {
</TextInput>
)}
<Tooltip title="Variante hinzufügen">
<span>
<IconButton
size="small"
color="primary"
disabled={
busy ||
(isCustomChildValue || availableParentValues.length === 0
? !newChildText.trim()
: !selectedChildValueId)
}
onClick={this.handleAddChild}
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
'&.Mui-disabled': {
bgcolor: 'action.disabledBackground',
color: 'action.disabled',
},
p: 0.6,
flexShrink: 0,
}}
>
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<IconButton
size="small"
color="primary"
aria-label="Variante hinzufügen"
disabled={
busy ||
(isCustomChildValue || availableParentValues.length === 0
? !newChildText.trim()
: !selectedChildValueId)
}
onClick={this.handleAddChild}
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
'&.Mui-disabled': {
bgcolor: 'action.disabledBackground',
color: 'action.disabled',
},
p: 0.6,
flexShrink: 0,
}}
>
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
</IconButton>
</Box>
{/* Toggle custom value / predefined value if values exist */}
@@ -2174,16 +2226,16 @@ export default class ArticleViewLists extends PureComponent {
)}
<Box sx={{ mt: 0.5, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
{isStueckliste ||
hasOptions ||
activeCreatorMode === 'stueckliste' ||
activeCreatorMode === 'options'
hasOptions ||
activeCreatorMode === 'stueckliste' ||
activeCreatorMode === 'options'
? this.renderCompoundManager(
isStueckliste,
hasOptions,
stuecklisteItems,
optionsItems,
selectableOtherArticles
)
isStueckliste,
hasOptions,
stuecklisteItems,
optionsItems,
selectableOtherArticles
)
: this.renderStandardView(selectableAttributes, assignedAttributeIds)}
</Box>
</Box>
@@ -2262,11 +2314,11 @@ export default class ArticleViewLists extends PureComponent {
<DialogContentText sx={{ fontSize: '0.88rem' }}>
{confirmClearType === 'stueckliste'
? (hasOptions
? 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen? Die Optionen bleiben dabei erhalten.'
: 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen und den Artikel wieder als Standard-Artikel führen?')
? 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen? Die Optionen bleiben dabei erhalten.'
: 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen und den Artikel wieder als Standard-Artikel führen?')
: (isStueckliste
? 'Möchten Sie alle Optionen von diesem Artikel entfernen? Die Stückliste bleibt dabei erhalten.'
: 'Möchten Sie alle Optionen von diesem Artikel entfernen und ihn wieder als Standard-Artikel führen?')}
? 'Möchten Sie alle Optionen von diesem Artikel entfernen? Die Stückliste bleibt dabei erhalten.'
: 'Möchten Sie alle Optionen von diesem Artikel entfernen und ihn wieder als Standard-Artikel führen?')}
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 2, pb: 2 }}>

View File

@@ -1,7 +1,7 @@
import { Component, PureComponent } from 'react';
import { EditablePanelTile } from 'dashgui/panels';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { getCategoryState } from '../../../automats/categoryAutomat';
import categoryAutomat, { getCategoryState } from '../../../automats/categoryAutomat';
import artikelAutomat from '../../../automats/artikelAutomat';
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
import { Box, Chip, IconButton, Typography } from '@mui/material';
@@ -92,10 +92,29 @@ function categoryLabels(categoryIds, categories, rootCategoryId) {
.filter((c) => c.label);
}
class CategoryPills extends Component {
class CategoryPills extends PureComponent {
constructor(props) {
super(props);
this.state = { pickerOpen: false, targetPillId: null };
const catState = getCategoryState();
this.state = {
pickerOpen: false,
targetPillId: null,
categories: catState?.categories ?? categoryAutomat.state.categories,
rootCategoryId: catState?.rootCategoryId ?? categoryAutomat.state.rootCategoryId,
};
}
componentDidMount() {
this._unsubCat = categoryAutomat.subscribe((s) => {
this.setState({
categories: s.categories,
rootCategoryId: s.rootCategoryId,
});
});
}
componentWillUnmount() {
this._unsubCat?.();
}
handleRemoveCategory = (removeId) => {
@@ -135,8 +154,7 @@ class CategoryPills extends Component {
render() {
const { categoryIds, editMode } = this.props;
const { pickerOpen, targetPillId } = this.state;
const { categories, rootCategoryId } = getCategoryState() ?? {};
const { pickerOpen, targetPillId, categories, rootCategoryId } = this.state;
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;

View File

@@ -1,4 +1,6 @@
import { PureComponent } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import Link from '@mui/material/Link';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
@@ -562,6 +564,27 @@ export default class ArticleAttributesTab extends PureComponent {
</Tooltip>
</Box>
{merkmalOptions.length === 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Im System sind noch keine Merkmale angelegt.
</Typography>
<Link
component={RouterLink}
to="/artikel/merkmale"
variant="caption"
sx={{
color: 'primary.main',
fontWeight: 600,
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
Zu den Merkmalen →
</Link>
</Box>
)}
{/* Unified Assigned Merkmale List */}
{assignedGroups.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>

View File

@@ -416,14 +416,12 @@ export default class CategoryTree extends PureComponent {
}
componentDidMount() {
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
this._unsub = categoryAutomat.subscribe((s) => {
this.setState({
categories: this.props.categories ?? s.categories,
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
});
this._unsub = categoryAutomat.subscribe((s) => {
this.setState({
categories: s.categories,
rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId,
});
}
});
}
componentDidUpdate(prevProps) {
@@ -463,7 +461,14 @@ export default class CategoryTree extends PureComponent {
name,
});
enqueueSnackbar(`Kategorie „${name}“ angelegt.`, { variant: 'success' });
this.setState({ addDialogOpen: false });
const nextCategories = categoryAutomat.state.categories ?? {
...(this.state.categories || {}),
[String(created.id)]: created,
};
this.setState({
addDialogOpen: false,
categories: nextCategories,
});
this.props.onSelect?.(created.id, created);
} catch (err) {
notifyError(err);
@@ -489,12 +494,19 @@ export default class CategoryTree extends PureComponent {
if (!name || !renameNode) return;
this.setState({ isSubmitting: true });
try {
await categoryAutomat.actions.renameCategory({
const updated = await categoryAutomat.actions.renameCategory({
categoryId: renameNode.id,
name,
});
enqueueSnackbar(`Kategorie in „${name}“ umbenannt.`, { variant: 'success' });
this.setState({ renameDialogOpen: false });
const nextCategories = categoryAutomat.state.categories ?? {
...(this.state.categories || {}),
[String(updated.id)]: updated,
};
this.setState({
renameDialogOpen: false,
categories: nextCategories,
});
} catch (err) {
notifyError(err);
} finally {
@@ -524,7 +536,10 @@ export default class CategoryTree extends PureComponent {
if (this.props.selectedId != null && String(this.props.selectedId) === String(deleteNode.id)) {
this.props.onSelect?.(deleteNode.parentId ?? rootCategoryId);
}
this.setState({ deleteDialogOpen: false });
this.setState({
deleteDialogOpen: false,
categories: categoryAutomat.state.categories,
});
} catch (err) {
notifyError(err);
} finally {

View File

@@ -19,17 +19,18 @@ export default class ArticleSearchDialog extends PureComponent {
this.state = {
assigningSku: null,
error: null,
showAllArticles: false,
};
}
componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) {
this.setState({ assigningSku: null, error: null });
this.setState({ assigningSku: null, error: null, showAllArticles: false });
}
}
handleClose = () => {
this.setState({ assigningSku: null, error: null });
this.setState({ assigningSku: null, error: null, showAllArticles: false });
this.props.onClose?.();
};
@@ -61,9 +62,9 @@ export default class ArticleSearchDialog extends PureComponent {
render() {
const { open, onClose, line, kind = 'delivery', supplierArticles: propSupplierArticles } = this.props;
const { assigningSku, error } = this.state;
const { assigningSku, error, showAllArticles } = this.state;
const isDelivery = kind === 'delivery';
const onlySupplierArticles = isDelivery && !line;
const onlySupplierArticles = isDelivery && !line && !showAllArticles;
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
const supplierArticlesList = onlySupplierArticles
@@ -122,7 +123,7 @@ export default class ArticleSearchDialog extends PureComponent {
<Box>
<Typography variant="h6" component="div" fontWeight={600}>
{line
? 'Existierenden Artikel zuordnen'
? (isDelivery ? 'Artikel neu zuordnen' : 'Existierenden Artikel zuordnen')
: isDelivery
? 'Lieferanten-Artikel auswählen'
: 'Artikel auswählen'}
@@ -134,7 +135,21 @@ export default class ArticleSearchDialog extends PureComponent {
{line?.name ? ` · ${line.name}` : ''}
</>
) : isDelivery ? (
'Nur mit dem ausgewählten Lieferanten verknüpfte Artikel'
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
<span>
{showAllArticles
? 'Alle Artikel im Katalog durchsuchen'
: 'Nur mit dem ausgewählten Lieferanten verknüpfte Artikel'}
</span>
<Button
size="small"
variant="text"
onClick={() => this.setState({ showAllArticles: !showAllArticles })}
sx={{ fontSize: '0.72rem', py: 0, px: 0.75, textTransform: 'none', minWidth: 'auto', fontWeight: 600 }}
>
{showAllArticles ? 'Nur Lieferanten-Artikel' : 'Alle Artikel durchsuchen'}
</Button>
</Box>
) : (
'Artikel für Beleg auswählen'
)}

View File

@@ -766,6 +766,30 @@ class ManifestFormLine extends PureComponent {
/>
)}
{this.props.onSearchArticle && (
<Button
size="small"
variant="text"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
disabled={this.props.saving || this.props.supplierArticleBusy || this.isFullyLedgered()}
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 neu zuordnen
</Button>
)}
{/* Brand */}
{cachedArticle.brand?.name && (
<BrandLabel
@@ -1124,6 +1148,30 @@ class ManifestFormLine extends PureComponent {
/>
)}
{this.props.onSearchArticle && (
<Button
size="small"
variant="text"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
disabled={this.props.saving || this.props.supplierArticleBusy || this.isFullyLedgered()}
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 neu zuordnen
</Button>
)}
{/* Brand */}
{(fullArticle.brand?.name || article.brand?.name) && (
<BrandLabel
@@ -1307,6 +1355,34 @@ class ManifestFormLine extends PureComponent {
);
}
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 }} />}
disabled={this.isFullyLedgered()}
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;
}

View File

@@ -20,6 +20,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import articleSearchAutomat from '../automats/articleSearchAutomat';
import articleIndexAutomat from '../automats/articleIndexAutomat';
import BackendError from '../components/BackendError';
import { formatPrice, getArticleGross } from '../utils/price';
import { thumb } from '../api';
@@ -50,6 +51,7 @@ import navigationAutomat, {
import categoryAutomat from '../automats/categoryAutomat';
import companyAutomat from '../automats/companyAutomat';
import { BrandLabel, BrandThumb } from '../components/brand';
import { ArticleNameDialog } from '../components/article';
import { SCRIM_LABEL } from '../colors';
// #endregion
@@ -282,6 +284,7 @@ function initialSearchState(searchParams) {
categoryFilterOpen: false,
brandFilterOpen: false,
supplierFilterOpen: false,
newArticleOpen: false,
error: automatResult.error,
categoryFacets: automatResult.categoryFacets,
brandFacets: automatResult.brandFacets,
@@ -354,6 +357,42 @@ class ArtikelSearchPage extends Component {
}));
};
handleOpenNewArticle = () => {
this.setState({ newArticleOpen: true });
};
handleCloseNewArticle = () => {
this.setState({ newArticleOpen: false });
};
handleCreateArticle = async ({ names }) => {
const filterParam = this.props.searchParams.get('filter') ?? '';
const categories = parseFilter(filterParam);
const parentId =
categories[0] ||
categoryAutomat.state.rootCategoryId ||
companyAutomat.state.current?.rootCategoryId ||
companyAutomat.state.current?.rootcategory_id ||
1;
const list = (names ?? []).filter(Boolean);
if (list.length <= 1) {
const created = await categoryAutomat.actions.createArticle({
parentId,
name: list[0] ?? '',
});
void articleIndexAutomat.actions.reload().catch(() => {});
if (created) {
this.props.navigate(artikelDetailPath(created));
}
return;
}
const createdList = await categoryAutomat.actions.createArticles({ parentId, names: list });
void articleIndexAutomat.actions.reload().catch(() => {});
if (createdList?.[0]) {
this.props.navigate(artikelDetailPath(createdList[0]));
}
};
handleOpenArticle = (row) => {
this.persistQuery();
this.props.navigate(artikelDetailPath(row));
@@ -548,6 +587,7 @@ class ArtikelSearchPage extends Component {
categoryFilterOpen,
brandFilterOpen,
supplierFilterOpen,
newArticleOpen,
error,
totalCount,
categoryFacets,
@@ -696,6 +736,13 @@ class ArtikelSearchPage extends Component {
>
Lieferanten
</Button>
<Button
variant="contained"
onClick={this.handleOpenNewArticle}
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Neuer Artikel
</Button>
{categories.map((catId) => {
const label = getCategoryBreadcrumbLabel(
@@ -1047,6 +1094,12 @@ class ArtikelSearchPage extends Component {
</PanelTile>
</Box>
</Box>
<ArticleNameDialog
open={newArticleOpen}
initialName={input}
onClose={this.handleCloseNewArticle}
onSubmit={this.handleCreateArticle}
/>
</>
);
}

View File

@@ -576,6 +576,20 @@ class LieferungNeuPage extends PureComponent {
}
try {
// If this line was previously mapped to a different article, unlink the old supplier article
const existingSupplierArticle =
(line?.key != null ? this.state.supplierArticlesByLineKey?.[line.key] : null) ||
(supplierSku ? (this.state.supplierArticlesMap?.[supplierSku.toLowerCase()] || this.state.supplierArticlesMap?.[supplierSku]) : null);
const oldInternalSku = existingSupplierArticle?.article?.sku;
const oldAssignmentId = existingSupplierArticle?.id;
if (oldInternalSku && oldAssignmentId != null && String(oldInternalSku).toLowerCase() !== String(internalSku).toLowerCase()) {
try {
await this.automat.actions.unlinkSupplierArticle(oldInternalSku, oldAssignmentId, line?.key);
} catch (unlinkErr) {
console.warn('Vorherige Lieferanten-Zuordnung konnte nicht gelöst werden:', unlinkErr);
}
}
await articleApi.addSupplier(internalSku, {
entityId: partyId,
sku: supplierSku || undefined,
@@ -587,13 +601,16 @@ class LieferungNeuPage extends PureComponent {
if (supplierSku) {
await this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
} else {
await this.automat.actions.fetchSupplierArticleForSku(internalSku, line.key);
}
await this.automat.actions.fetchSupplierArticles(partyId);
if (!line.name && article.name) {
if (!line.name || (existingSupplierArticle?.article?.name && line.name === existingSupplierArticle.article.name)) {
this.automat.actions.updateLineitem({
...line,
name: article.name,
name: article.name || line.name,
...(!line.sku ? { sku: internalSku } : {}),
});
}