diff --git a/src/components/TextInput.jsx b/src/components/TextInput.jsx index 6ab9fb7..3dd1152 100644 --- a/src/components/TextInput.jsx +++ b/src/components/TextInput.jsx @@ -71,8 +71,9 @@ export default class TextInput extends Component { } = this.props; const isSelect = Boolean(rest.select); - const effectiveReadOnly = isSelect - ? (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly) + const isAutocomplete = Boolean(inputProps?.['aria-autocomplete'] || rest['aria-autocomplete']); + const effectiveReadOnly = (isSelect || isAutocomplete) + ? (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? false) : (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? this.state.readOnly); const htmlInput = isSelect diff --git a/src/components/article/boxes/ArticleViewLists.jsx b/src/components/article/boxes/ArticleViewLists.jsx index 0f1cfe3..ef996d3 100644 --- a/src/components/article/boxes/ArticleViewLists.jsx +++ b/src/components/article/boxes/ArticleViewLists.jsx @@ -15,6 +15,8 @@ import FormControlLabel from '@mui/material/FormControlLabel'; import IconButton from '@mui/material/IconButton'; import Link from '@mui/material/Link'; import MenuItem from '@mui/material/MenuItem'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; import TextInput from '../../TextInput'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; @@ -25,6 +27,7 @@ import LayersIcon from '@mui/icons-material/Layers'; import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck'; import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; import LinkOffIcon from '@mui/icons-material/LinkOff'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { PanelTile } from 'dashgui/panels'; import { enqueueSnackbar } from 'notistack'; import { artikelDetailPath } from '../articleRoute'; @@ -103,6 +106,11 @@ export default class ArticleViewLists extends PureComponent { }); }); + this.unsubArtikel = artikelAutomat.subscribe(() => { + if (this._cancelled) return; + this.forceUpdate(); + }); + if (attributeAutomat.state.status === 'idle') { void attributeAutomat.actions.load(); } @@ -111,6 +119,10 @@ export default class ArticleViewLists extends PureComponent { void this.loadAssignedAttributes(this.props.article.sku); } + if (this.props.article?.fatherSku) { + void artikelAutomat.actions.loadArticle(this.props.article.fatherSku); + } + void this.loadAllArticles(); } @@ -119,6 +131,9 @@ export default class ArticleViewLists extends PureComponent { if (this.props.article?.sku) { void this.loadAssignedAttributes(this.props.article.sku); } + if (this.props.article?.fatherSku) { + void artikelAutomat.actions.loadArticle(this.props.article.fatherSku); + } const nextArticle = this.props.article; const hasStueckliste = Boolean( nextArticle?.articlelistId != null || nextArticle?.articlelist?.length > 0 @@ -130,6 +145,9 @@ export default class ArticleViewLists extends PureComponent { if (!hasStueckliste && hasOptions) { activeListTab = 'options'; } + if (this.state.allArticles.length === 0) { + void this.loadAllArticles(); + } this.setState({ activeListTab, activeCreatorMode: null, @@ -145,6 +163,7 @@ export default class ArticleViewLists extends PureComponent { componentWillUnmount() { this._cancelled = true; this.unsubAttr?.(); + this.unsubArtikel?.(); } getApi() { @@ -153,13 +172,13 @@ export default class ArticleViewLists extends PureComponent { async loadAllArticles() { try { - const data = await (this.context?.articleIndexPromise || this.getApi().index()); + const data = await this.context?.articleIndexPromise; const articles = Array.isArray(data) ? data : (data?.articles || []); if (!this._cancelled) { this.setState({ allArticles: articles }); } - } catch { - // Ignore + } catch (err) { + console.error('Failed to load article index:', err); } } @@ -590,7 +609,7 @@ export default class ArticleViewLists extends PureComponent { } }; - renderStuecklisteContent(stuecklisteItems, selectableOtherArticles) { + renderStuecklisteContent(stuecklisteItems, selectableOtherArticles, isStueckliste = true) { const { busy, stuecklisteTargetArticle, stuecklisteCount } = this.state; return ( @@ -600,26 +619,45 @@ export default class ArticleViewLists extends PureComponent { Stückliste - + {isStueckliste && ( + + )} - - - this.setState({ confirmClearType: 'stueckliste' })} - disabled={busy} - sx={{ p: 0.4, color: 'text.secondary', '&:hover': { color: 'error.main' } }} - > - - - - + {isStueckliste ? ( + + + this.setState({ confirmClearType: 'stueckliste' })} + disabled={busy} + sx={{ p: 0.4, color: 'text.secondary', '&:hover': { color: 'error.main' } }} + > + + + + + ) : ( + + )} {/* Add Component Row */} @@ -631,9 +669,9 @@ export default class ArticleViewLists extends PureComponent { disabled={busy} onChange={(_e, val) => this.setState({ stuecklisteTargetArticle: val })} getOptionLabel={(opt) => - typeof opt === 'string' ? opt : `${opt.sku} - ${opt.name || ''}` + typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '') } - isOptionEqualToValue={(opt, val) => opt.sku === val?.sku} + isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku} renderInput={(params) => ( - {stuecklisteItems.length === 0 ? ( - - Noch keine Komponenten in Stückliste. - - ) : ( - stuecklisteItems.map((item) => ( + {stuecklisteItems.map((item) => ( )) - )} - - - ); - } - - renderStuecklisteCreator(selectableOtherArticles, hasOptions) { - const { busy, stuecklisteTargetArticle, stuecklisteCount } = this.state; - return ( - - - Erste Komponente hinzufügen: - - - this.setState({ stuecklisteTargetArticle: val })} - getOptionLabel={(opt) => - typeof opt === 'string' ? opt : `${opt.sku} - ${opt.name || ''}` } - isOptionEqualToValue={(opt, val) => opt.sku === val?.sku} - renderInput={(params) => ( - - )} - sx={{ width: '100%' }} - /> - - - - this.setState({ - stuecklisteCount: Math.max(1, parseInt(e.target.value, 10) || 1), - }) - } - slotProps={{ - htmlInput: { - min: 1, - style: { textAlign: 'center' }, - }, - }} - sx={{ width: 72 }} - /> - - - - - ); } - renderOptionsContent(optionsItems, selectableOtherArticles) { + renderOptionsContent(optionsItems, selectableOtherArticles, hasOptions = true) { const { busy, optionTargetArticle, optionCount, optionMandated } = this.state; return ( @@ -876,26 +815,46 @@ export default class ArticleViewLists extends PureComponent { Optionen - + {hasOptions && ( + + )} - - - this.setState({ confirmClearType: 'options' })} - disabled={busy} - sx={{ p: 0.4, color: 'text.secondary', '&:hover': { color: 'error.main' } }} - > - - - - + {hasOptions ? ( + + + this.setState({ confirmClearType: 'options' })} + disabled={busy} + sx={{ p: 0.4, color: 'text.secondary', '&:hover': { color: 'error.main' } }} + > + + + + + ) : ( + + )} {/* Add Option Row */} @@ -907,9 +866,9 @@ export default class ArticleViewLists extends PureComponent { disabled={busy} onChange={(_e, val) => this.setState({ optionTargetArticle: val })} getOptionLabel={(opt) => - typeof opt === 'string' ? opt : `${opt.sku} - ${opt.name || ''}` + typeof opt === 'string' ? opt : (opt ? `${opt.sku} - ${opt.name || ''}` : '') } - isOptionEqualToValue={(opt, val) => opt.sku === val?.sku} + isOptionEqualToValue={(opt, val) => opt?.sku === val?.sku} renderInput={(params) => ( - Pflicht-Zubehör (mandated) + Fest } sx={{ ml: 0.5 }} @@ -991,17 +950,9 @@ export default class ArticleViewLists extends PureComponent { flexDirection: 'column', gap: 0.5, mt: 0.25, - maxHeight: 150, - overflowY: 'auto', - pr: 0.25, }} > - {optionsItems.length === 0 ? ( - - Noch keine Optionen hinterlegt. - - ) : ( - optionsItems.map((opt) => ( + {optionsItems.map((opt) => ( {opt.mandated ? ( )) - )} + } ); } - renderOptionsCreator(selectableOtherArticles, isStueckliste) { - const { busy, optionTargetArticle, optionCount, optionMandated } = this.state; + + renderCompoundManager( + isStueckliste, + hasOptions, + stuecklisteItems, + optionsItems, + selectableOtherArticles + ) { + const { activeListTab } = this.state; + return ( - - - Erste Option hinzufügen: - - - this.setState({ optionTargetArticle: val })} - getOptionLabel={(opt) => - typeof opt === 'string' ? opt : `${opt.sku} - ${opt.name || ''}` - } - isOptionEqualToValue={(opt, val) => opt.sku === val?.sku} - renderInput={(params) => ( - - )} - sx={{ width: '100%' }} - /> - - - - this.setState({ - optionCount: Math.max(1, parseInt(e.target.value, 10) || 1), - }) - } - slotProps={{ - htmlInput: { - min: 1, - style: { textAlign: 'center' }, - }, - }} - sx={{ width: 72 }} + + this.setState({ activeListTab: val })} + sx={{ + minHeight: 32, + mb: 0.5, + borderBottom: '1px solid', + borderColor: 'divider', + '& .MuiTab-root': { + minHeight: 32, + py: 0.25, + px: 1.5, + textTransform: 'none', + fontSize: '0.8rem', + fontWeight: 600, + }, + }} + > + } + iconPosition="start" /> + } + iconPosition="start" + /> + - this.setState({ optionMandated: e.target.checked })} - sx={{ p: 0.5 }} - /> - } - label={ - - Pflicht - - } - sx={{ ml: 0 }} + {activeListTab === 'stueckliste' + ? this.renderStuecklisteContent(stuecklisteItems, selectableOtherArticles, isStueckliste) + : this.renderOptionsContent(optionsItems, selectableOtherArticles, hasOptions)} + + ); + } + + renderChildView(fatherSku, fatherArticle, attributes, assignedAttributes) { + const { article: articleData } = this.props; + const fatherAttrName = + fatherArticle?.fatherAttributeName || + (fatherArticle?.fatherAttributeNameId + ? attributes.find((a) => a.id === fatherArticle.fatherAttributeNameId)?.name + : null) || + 'Varianten'; + + const selfInFather = (fatherArticle?.children || []).find( + (c) => c.sku === articleData.sku || c.id === articleData.id + ); + const assignedMatch = (assignedAttributes || []).find( + (a) => + (fatherArticle?.fatherAttributeNameId != null && + Number(a.attributeId) === Number(fatherArticle.fatherAttributeNameId)) || + (a.attributeName && + fatherAttrName && + String(a.attributeName).toLowerCase() === String(fatherAttrName).toLowerCase()) + ); + + const variationValue = + selfInFather?.attributeValue || + assignedMatch?.attributeValue || + null; + + const siblings = (fatherArticle?.children || []).filter( + (c) => c.sku !== articleData.sku && c.id !== articleData.id + ); + + return ( + + {/* Header: Variation [Grün] für [Farbe] */} + + + Variation + + {variationValue ? ( + + ) : null} + + für + + - - - + + + {fatherArticle?.name || fatherSku} + + + + {fatherSku} + + + + + + + + {/* Link(s) Geschwister */} + + + Geschwister: + + {siblings.length === 0 ? ( + + Keine weiteren Varianten vorhanden. + + ) : ( + + {siblings.map((sibling) => ( + + + + {sibling.attributeValue || sibling.name || sibling.sku} + + + + {sibling.sku} + + + + + + ))} + + )} ); @@ -1195,136 +1319,92 @@ export default class ArticleViewLists extends PureComponent { const { busy, activeCreatorMode, selectedAttributeId, attributes } = this.state; return ( - - - Typ: - - - Standard-Artikel - - - {activeCreatorMode === 'father' ? ( - + + + + + + Vaterartikel aktivieren + + + + + {selectableAttributes.length === 0 ? ( Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen. ) : ( - this.setState({ selectedAttributeId: e.target.value })} - > - {attributes.map((a) => { - const isAssigned = assignedAttributeIds.has(a.id); - return ( - + + this.setState({ selectedAttributeId: e.target.value })} + sx={{ flex: 1, minWidth: 0 }} + > + + Merkmal wählen... + + {selectableAttributes.map((a) => ( + {a.name} - {isAssigned ? ' (bereits zugewiesen)' : ''} - ); - })} - - )} + ))} + - - - - + + + )} ) : ( @@ -1385,52 +1465,42 @@ export default class ArticleViewLists extends PureComponent { ); const availableFatherValues = allFatherValues.filter((v) => !assignedValueIds.has(v.id)); + const fatherSku = articleData.fatherSku; + const fatherArticle = fatherSku ? getArticle(fatherSku) : null; + return ( - {/* If article is a Child / Variant of another article, show link to its father */} - {isChild && articleData.fatherSku ? ( - - - - - Variante von: - - - - {articleData.fatherSku} - - - ) : null} - {/* ── Mode 1: Father Article ──────────────────────────────────────── */} {isFather ? ( + {isChild && fatherSku ? ( + + + + Übergeordnete Variante von: + + + {fatherSku} + + + ) : null} + - Vaterartikel + Variantsvater für {children.length === 0 ? ( @@ -1569,121 +1636,133 @@ export default class ArticleViewLists extends PureComponent { alignItems: 'center', justifyContent: 'space-between', px: 1, - py: 0.4, + py: 0.6, borderRadius: 1, bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider', + transition: 'all 0.15s ease-in-out', + '&:hover': { + borderColor: 'primary.main', + boxShadow: '0 1px 4px rgba(0,0,0,0.06)', + }, }} > - + {child.attributeValue || child.name || child.sku} - - {child.sku} - - + + + {child.sku} + + + + this.handleOpenRemoveDialog(child)} + title="Variante entfernen" sx={{ - p: 0.25, + p: 0.35, color: 'text.secondary', '&:hover': { color: 'error.main' }, }} > - + )) )} - ) : isStueckliste || hasOptions || activeCreatorMode === 'stueckliste' || activeCreatorMode === 'options' ? ( - /* ── Combined Compound Manager (Stückliste & Optionen) ─────────── */ - - {/* Sub-tabs: Stückliste & Optionen */} - - - + ) : isChild && fatherSku ? ( + /* ── Mode 2: Child Variant Article ───────────────────────────────── */ + + {this.renderChildView( + fatherSku, + fatherArticle, + attributes, + assignedAttributes + )} + + {isStueckliste || + hasOptions || + activeCreatorMode === 'stueckliste' || + activeCreatorMode === 'options' + ? this.renderCompoundManager( + isStueckliste, + hasOptions, + stuecklisteItems, + optionsItems, + selectableOtherArticles + ) + : this.renderStandardView(selectableAttributes, assignedAttributeIds)} - - {activeListTab === 'stueckliste' - ? isStueckliste - ? this.renderStuecklisteContent(stuecklisteItems, selectableOtherArticles) - : this.renderStuecklisteCreator(selectableOtherArticles, hasOptions) - : hasOptions - ? this.renderOptionsContent(optionsItems, selectableOtherArticles) - : this.renderOptionsCreator(selectableOtherArticles, isStueckliste)} + ) : isStueckliste || + hasOptions || + activeCreatorMode === 'stueckliste' || + activeCreatorMode === 'options' ? ( + /* ── Mode 3: Combined Compound Manager (Stückliste & Optionen) ───── */ + this.renderCompoundManager( + isStueckliste, + hasOptions, + stuecklisteItems, + optionsItems, + selectableOtherArticles + ) ) : ( + /* ── Mode 4: Standard Article ────────────────────────────────────── */ this.renderStandardView(selectableAttributes, assignedAttributeIds) )} diff --git a/src/components/article/tabs/ArticleAttributesTab.jsx b/src/components/article/tabs/ArticleAttributesTab.jsx index 229bc97..303d81b 100644 --- a/src/components/article/tabs/ArticleAttributesTab.jsx +++ b/src/components/article/tabs/ArticleAttributesTab.jsx @@ -13,17 +13,54 @@ import { enqueueSnackbar } from 'notistack'; import { StorageManagerContext } from '../../../api/storageManagerContext'; import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat'; import propertyAutomat, { selectProperties } from '../../../automats/propertyAutomat'; +import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; import { article as articleApi } from '../../../api'; export default class ArticleAttributesTab extends PureComponent { static contextType = StorageManagerContext; + getArticle() { + return this.props.article || getArticle(this.props.sku); + } + + isFatherVariantAttribute(attr) { + const article = this.getArticle(); + const isFather = Boolean(article?.isFather ?? article?.is_father); + if (!isFather || !attr) return false; + const fatherAttrId = article?.fatherAttributeNameId ?? article?.attributeNameId ?? null; + if (fatherAttrId != null && Number(attr.id) === Number(fatherAttrId)) return true; + if ( + article?.fatherAttributeName && + String(attr.name).trim().toLowerCase() === String(article.fatherAttributeName).trim().toLowerCase() + ) { + return true; + } + return false; + } + constructor(props) { super(props); const initialAttributes = selectAttributes() || []; const initialProperties = selectProperties() || []; - const firstAttr = initialAttributes[0]; + const article = props.article || getArticle(props.sku); + const isFather = Boolean(article?.isFather ?? article?.is_father); + const fatherAttrId = article?.fatherAttributeNameId ?? article?.attributeNameId ?? null; + const fatherAttrName = article?.fatherAttributeName; + + const selectableAttrs = initialAttributes.filter((a) => { + if (!isFather) return true; + if (fatherAttrId != null && Number(a.id) === Number(fatherAttrId)) return false; + if ( + fatherAttrName && + String(a.name).trim().toLowerCase() === String(fatherAttrName).trim().toLowerCase() + ) { + return false; + } + return true; + }); + + const firstAttr = selectableAttrs[0]; const initialKey = firstAttr ? `attr-${firstAttr.id}` : initialProperties[0] @@ -53,11 +90,24 @@ export default class ArticleAttributesTab extends PureComponent { let nextKey = prev.selectedMerkmalKey; let nextItemId = prev.selectedItemId; - if (!nextKey) { - const first = state.attributes[0]; + const selectableAttrs = state.attributes; + + const isCurrentKeyValid = + nextKey && + (selectableAttrs.some((a) => `attr-${a.id}` === nextKey) || + prev.globalProperties.some((p) => `prop-${p.id}` === nextKey)); + + if (!isCurrentKeyValid) { + const first = selectableAttrs[0]; if (first) { nextKey = `attr-${first.id}`; nextItemId = first.values?.[0]?.id ?? ''; + } else if (prev.globalProperties[0]) { + nextKey = `prop-${prev.globalProperties[0].id}`; + nextItemId = ''; + } else { + nextKey = ''; + nextItemId = ''; } } return { @@ -71,8 +121,9 @@ export default class ArticleAttributesTab extends PureComponent { this.unsubProp = propertyAutomat.subscribe((state) => { if (this._cancelled) return; this.setState((prev) => { + const selectableAttrs = prev.globalAttributes; let nextKey = prev.selectedMerkmalKey; - if (!nextKey && !prev.globalAttributes.length && state.properties[0]) { + if (!nextKey && !selectableAttrs.length && state.properties[0]) { nextKey = `prop-${state.properties[0].id}`; } return { @@ -82,6 +133,11 @@ export default class ArticleAttributesTab extends PureComponent { }); }); + this.unsubArtikel = artikelAutomat.subscribe(() => { + if (this._cancelled) return; + this.forceUpdate(); + }); + if (attributeAutomat.state.status === 'idle') { void attributeAutomat.actions.load(); } @@ -102,6 +158,7 @@ export default class ArticleAttributesTab extends PureComponent { this._cancelled = true; this.unsubAttr?.(); this.unsubProp?.(); + this.unsubArtikel?.(); } async loadArticleData(sku) { @@ -164,16 +221,30 @@ export default class ArticleAttributesTab extends PureComponent { saving, } = this.state; - if (saving || !sku || !selectedMerkmalKey) return; + const selectableAttributes = globalAttributes; + const merkmalOptions = [ + ...selectableAttributes.map((a) => ({ key: `attr-${a.id}`, id: a.id, type: 'attr' })), + ...globalProperties.map((p) => ({ key: `prop-${p.id}`, id: p.id, type: 'prop' })), + ]; + const selectedOption = + merkmalOptions.find((m) => m.key === selectedMerkmalKey) ?? merkmalOptions[0] ?? null; + const effectiveKey = selectedOption?.key || selectedMerkmalKey; + + if (saving || !sku || !effectiveKey) return; const api = this.context?.article || articleApi; - if (selectedMerkmalKey.startsWith('attr-')) { - const attrId = Number(selectedMerkmalKey.replace('attr-', '')); - const targetAttr = globalAttributes.find((a) => a.id === attrId); - if (!targetAttr || !selectedItemId) return; + if (effectiveKey.startsWith('attr-')) { + const attrId = Number(effectiveKey.replace('attr-', '')); + const targetAttr = selectableAttributes.find((a) => a.id === attrId); + if (!targetAttr || this.isFatherVariantAttribute(targetAttr)) return; + + const effectiveItemId = targetAttr.values?.some((v) => v.id === selectedItemId) + ? selectedItemId + : (targetAttr.values?.[0]?.id ?? ''); + if (!effectiveItemId) return; const isExclusive = Boolean(targetAttr.exclusive); - const targetValId = Number(selectedItemId); + const targetValId = Number(effectiveItemId); const isAlreadyAssigned = assignedAttributes.some((a) => a.attributeValueId === targetValId); if (isAlreadyAssigned) { @@ -320,8 +391,9 @@ export default class ArticleAttributesTab extends PureComponent { } // Unified list of selectable Merkmale + const selectableAttributes = globalAttributes; const merkmalOptions = [ - ...globalAttributes.map((a) => ({ + ...selectableAttributes.map((a) => ({ key: `attr-${a.id}`, id: a.id, type: 'attr', @@ -339,9 +411,17 @@ export default class ArticleAttributesTab extends PureComponent { })), ]; - const selectedOption = merkmalOptions.find((m) => m.key === selectedMerkmalKey) ?? null; + const selectedOption = + merkmalOptions.find((m) => m.key === selectedMerkmalKey) ?? merkmalOptions[0] ?? null; + const currentMerkmalKey = selectedOption?.key ?? ''; const isAttr = selectedOption?.type === 'attr'; + const isVariantBlocked = isAttr && this.isFatherVariantAttribute(selectedOption); const availableItems = selectedOption?.values || []; + const currentItemId = isAttr + ? (availableItems.some((v) => v.id === selectedItemId) + ? selectedItemId + : (availableItems[0]?.id ?? '')) + : ''; // Unified list of assigned groups sorted by definition order const assignedGroups = []; @@ -397,7 +477,7 @@ export default class ArticleAttributesTab extends PureComponent { - {isAttr ? ( + {isVariantBlocked ? ( + + Ein Vaterartikel kann keine Werte für sein Varianten-Merkmal haben. + + ) : isAttr ? ( 0 && ( {assignedGroups.map((group) => ( - + {group.name}: - + {group.items.map((item) => (