diff --git a/src/components/merkmale/AuswahlMerkmaleTab.jsx b/src/components/merkmale/AuswahlMerkmaleTab.jsx new file mode 100644 index 0000000..0878b8a --- /dev/null +++ b/src/components/merkmale/AuswahlMerkmaleTab.jsx @@ -0,0 +1,559 @@ +import { PureComponent, createRef } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import StyleIcon from '@mui/icons-material/Style'; +import LabelImportantIcon from '@mui/icons-material/LabelImportant'; +import Sortable from 'sortablejs'; +import { enqueueSnackbar } from 'notistack'; +import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import FillSpace from 'dashgui/FillSpace'; +import { PanelTile, PanelTileGrid } from 'dashgui/panels'; +import BackendError from '../BackendError'; +import { backendErrorFrom } from '../../utils/backendError'; +import { notifyError } from '../../utils/notify'; +import attributeAutomat, { selectAttributes } from '../../automats/attributeAutomat'; +import ArtikelMerkmalAuswahl from './ArtikelMerkmalAuswahl'; +import ArtikelMerkmalWert from './ArtikelMerkmalWert'; +import { BLUE_PANEL, PURPLE_PANEL } from '../../colors'; + +/** + * Tab 1: Auswahl-Merkmale & Werte + */ +class AuswahlMerkmaleTab extends PureComponent { + // #region Constructor & State + constructor(props) { + super(props); + const initialAttributes = selectAttributes() || []; + const initialSelectedAttrId = initialAttributes[0]?.id ?? null; + + this.state = { + attrStatus: attributeAutomat.state.status === 'succeeded' ? 'ready' : 'loading', + error: attributeAutomat.state.error || null, + attributes: initialAttributes, + selectedAttributeId: initialSelectedAttrId, + + attrFilter: '', + attrSubmitting: false, + attrDeletingUnused: false, + attrPage: 1, + + valFilter: '', + valSubmitting: false, + valDeletingUnused: false, + valPage: 1, + }; + + this._cancelled = false; + this.attrListRef = createRef(); + this.valListRef = createRef(); + this._attrSortable = null; + this._valSortable = null; + + this.handleRetry = this.handleRetry.bind(this); + this.handleCreateAttribute = this.handleCreateAttribute.bind(this); + this.handleCreateValue = this.handleCreateValue.bind(this); + this.handleDeleteUnusedAttributes = this.handleDeleteUnusedAttributes.bind(this); + this.handleDeleteUnusedValues = this.handleDeleteUnusedValues.bind(this); + } + // #endregion + + // #region Lifecycle + componentDidMount() { + this._cancelled = false; + + this.unsubAttr = attributeAutomat.subscribe((state) => { + if (this._cancelled) return; + if (state.status === 'succeeded') { + let selectedId = this.state.selectedAttributeId; + if (!selectedId || !state.attributes.some((a) => a.id === selectedId)) { + selectedId = state.attributes[0]?.id ?? null; + } + this.setState({ + attrStatus: 'ready', + error: null, + attributes: state.attributes, + selectedAttributeId: selectedId, + }); + } else if (state.status === 'failed') { + this.setState({ attrStatus: 'failed', error: backendErrorFrom(state.error) }); + } + }); + + if (attributeAutomat.state.status !== 'succeeded') { + void this.load(); + } else { + this._initAttrSortable(); + this._initValSortable(); + } + } + + componentDidUpdate(prevProps, prevState) { + if ( + (prevState.attributes !== this.state.attributes || + prevState.attrFilter !== this.state.attrFilter || + prevState.attrPage !== this.state.attrPage || + prevState.attrStatus !== this.state.attrStatus) && + this.attrListRef.current + ) { + if (this.state.attrStatus === 'ready') { + this._initAttrSortable(); + } + } + + if ( + (prevState.selectedAttributeId !== this.state.selectedAttributeId || + prevState.attributes !== this.state.attributes || + prevState.valFilter !== this.state.valFilter || + prevState.valPage !== this.state.valPage || + prevState.attrStatus !== this.state.attrStatus) && + this.valListRef.current + ) { + if (this.state.attrStatus === 'ready') { + this._initValSortable(); + } + } + } + + componentWillUnmount() { + this._cancelled = true; + this.unsubAttr?.(); + this._destroySortables(); + } + // #endregion + + // #region Sortable Reorder + _initAttrSortable() { + this._attrSortable?.destroy(); + const el = this.attrListRef.current; + if (!el) return; + + const isFiltered = Boolean(this.state.attrFilter?.trim()); + + this._attrSortable = Sortable.create(el, { + handle: '.drag-handle', + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + disabled: isFiltered, + onEnd: (evt) => { + const { oldIndex, newIndex } = evt; + if (oldIndex === newIndex) return; + + const offset = ((this.state.attrPage || 1) - 1) * 8; + const realOld = offset + oldIndex; + const realNew = offset + newIndex; + if (realOld === realNew) return; + + this.setState( + (prev) => { + const next = [...prev.attributes]; + const [moved] = next.splice(realOld, 1); + next.splice(realNew, 0, moved); + return { attributes: next }; + }, + () => { + const ids = this.state.attributes.map((a) => a.id); + void this._persistAttrOrder(ids); + } + ); + }, + }); + } + + _initValSortable() { + this._valSortable?.destroy(); + const el = this.valListRef.current; + if (!el) return; + + const isFiltered = Boolean(this.state.valFilter?.trim()); + + this._valSortable = Sortable.create(el, { + handle: '.drag-handle', + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + disabled: isFiltered, + onEnd: (evt) => { + const { oldIndex, newIndex } = evt; + if (oldIndex === newIndex) return; + + const offset = ((this.state.valPage || 1) - 1) * 8; + const realOld = offset + oldIndex; + const realNew = offset + newIndex; + if (realOld === realNew) return; + + const currentAttr = this.state.attributes.find( + (a) => a.id === this.state.selectedAttributeId + ); + if (!currentAttr) return; + + const values = currentAttr.values || []; + const next = [...values]; + const [moved] = next.splice(realOld, 1); + next.splice(realNew, 0, moved); + + const updatedAttributes = this.state.attributes.map((a) => + a.id === currentAttr.id ? { ...a, values: next } : a + ); + + this.setState({ attributes: updatedAttributes }, () => { + const ids = next.map((v) => v.id); + void this._persistValOrder(currentAttr.id, ids); + }); + }, + }); + } + + _destroySortables() { + this._attrSortable?.destroy(); + this._attrSortable = null; + this._valSortable?.destroy(); + this._valSortable = null; + } + + async _persistAttrOrder(ids) { + try { + await attributeAutomat.actions.reorderAttributes(ids); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + void attributeAutomat.actions.load(); + } + } + + async _persistValOrder(attributeId, ids) { + try { + await attributeAutomat.actions.reorderValues(attributeId, ids); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + void attributeAutomat.actions.load(); + } + } + // #endregion + + // #region Actions & Handlers + async load() { + this.setState({ + attrStatus: attributeAutomat.state.status === 'succeeded' ? 'ready' : 'loading', + error: null, + }); + try { + await attributeAutomat.actions.load(); + } catch (err) { + if (this._cancelled) return; + this.setState({ error: backendErrorFrom(err) }); + } + } + + handleRetry() { + void this.load(); + } + + handleSelectAttribute(attrId) { + this.setState({ selectedAttributeId: attrId, valFilter: '', valPage: 1 }); + } + + async handleCreateAttribute() { + const name = this.state.attrFilter.trim(); + if (!name || this.state.attrSubmitting) return; + + this.setState({ attrSubmitting: true }); + try { + const row = await attributeAutomat.actions.createAttribute({ + name, + exclusive: true, + }); + if (this._cancelled) return; + this.setState({ + attrFilter: '', + attrSubmitting: false, + selectedAttributeId: row?.id ?? this.state.selectedAttributeId, + }); + enqueueSnackbar(`Attribut „${row?.name || name}“ angelegt.`, { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + this.setState({ attrSubmitting: false }); + notifyError(err); + } + } + + async handleCreateValue() { + const { selectedAttributeId } = this.state; + const value = this.state.valFilter.trim(); + if (!selectedAttributeId || !value || this.state.valSubmitting) return; + + this.setState({ valSubmitting: true }); + try { + await attributeAutomat.actions.createValue(selectedAttributeId, { value }); + if (this._cancelled) return; + this.setState({ valFilter: '', valSubmitting: false }); + enqueueSnackbar(`Wert „${value}“ angelegt.`, { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + this.setState({ valFilter: false }); + notifyError(err); + } + } + + async handleDeleteUnusedAttributes() { + const { attributes, attrDeletingUnused } = this.state; + if (attrDeletingUnused) return; + const unused = attributes.filter((a) => (a.articleCount ?? 0) === 0); + if (!unused.length) return; + + if (!window.confirm(`Alle ${unused.length} ungenutzten Auswahl-Merkmale wirklich löschen?`)) { + return; + } + + this.setState({ attrDeletingUnused: true }); + try { + await attributeAutomat.actions.deleteAttributes(unused.map((a) => a.id)); + enqueueSnackbar(`${unused.length} ungenutzte Auswahl-Merkmale gelöscht.`, { variant: 'success' }); + } catch (err) { + notifyError(err); + } finally { + if (!this._cancelled) { + this.setState({ attrDeletingUnused: false }); + } + } + } + + async handleDeleteUnusedValues() { + const { attributes, selectedAttributeId, valDeletingUnused } = this.state; + if (valDeletingUnused) return; + const selectedAttr = attributes.find((a) => a.id === selectedAttributeId) ?? attributes[0] ?? null; + if (!selectedAttr) return; + const unused = (selectedAttr.values || []).filter((v) => (v.articleCount ?? 0) === 0); + if (!unused.length) return; + + if (!window.confirm(`Alle ${unused.length} ungenutzten Werte wirklich löschen?`)) { + return; + } + + this.setState({ valDeletingUnused: true }); + try { + await attributeAutomat.actions.deleteValues(unused.map((v) => v.id)); + enqueueSnackbar(`${unused.length} ungenutzte Werte gelöscht.`, { variant: 'success' }); + } catch (err) { + notifyError(err); + } finally { + if (!this._cancelled) { + this.setState({ valDeletingUnused: false }); + } + } + } + // #endregion + + // #region Render + render() { + const { + attrStatus, + error, + attributes, + selectedAttributeId, + attrFilter, + attrSubmitting, + attrDeletingUnused, + valFilter, + valSubmitting, + valDeletingUnused, + attrPage, + valPage, + } = this.state; + + if (attrStatus === 'loading' && !attributes.length) { + return ( + + + + ); + } + + if (error && !attributes.length) { + return ; + } + + const selectedAttr = attributes.find((a) => a.id === selectedAttributeId) ?? attributes[0] ?? null; + const selectedValues = selectedAttr?.values || []; + + const attrFilterTrimmed = attrFilter.trim(); + const filteredAttributes = attrFilterTrimmed + ? attributes.filter((a) => a.name.toLowerCase().includes(attrFilterTrimmed.toLowerCase())) + : attributes; + + const valFilterTrimmed = valFilter.trim(); + const filteredValues = valFilterTrimmed + ? selectedValues.filter((v) => v.value.toLowerCase().includes(valFilterTrimmed.toLowerCase())) + : selectedValues; + + const unusedAttrCount = attributes.filter((a) => (a.articleCount ?? 0) === 0).length; + const unusedValCount = (selectedValues || []).filter((v) => (v.articleCount ?? 0) === 0).length; + + return ( + + {/* Tile 1: Auswahl-Merkmale */} + + + + + + + Auswahl-Merkmale + + + + + + + this.setState({ attrFilter: next })} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void this.handleCreateAttribute(); + } + }} + /> + + + + {filteredAttributes.length === 0 ? ( + + {attrFilterTrimmed + ? `Keine Treffer für „${attrFilterTrimmed}“.` + : 'Noch keine Auswahl-Merkmale angelegt.'} + + ) : ( + this.setState({ attrPage: page })} + renderItem={(a) => ( + this.handleSelectAttribute(a.id)} + /> + )} + /> + )} + + + + {/* Tile 2: Werte für das gewählte Auswahl-Merkmal */} + + + + + + + {selectedAttr ? `Werte für Auswahl-Merkmal „${selectedAttr.name}“` : 'Werte'} + + + + + + {!selectedAttr ? ( + + Kein Attribut ausgewählt. + + ) : ( + <> + + this.setState({ valFilter: next })} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void this.handleCreateValue(); + } + }} + /> + + + + {filteredValues.length === 0 ? ( + + {valFilterTrimmed + ? `Keine Treffer für „${valFilterTrimmed}“.` + : 'Noch keine Werte angelegt.'} + + ) : ( + this.setState({ valPage: page })} + renderItem={(v) => ( + + )} + /> + )} + + )} + + + + ); + } + // #endregion +} + +export default AuswahlMerkmaleTab; diff --git a/src/components/merkmale/TextMerkmaleTab.jsx b/src/components/merkmale/TextMerkmaleTab.jsx new file mode 100644 index 0000000..8e96d6d --- /dev/null +++ b/src/components/merkmale/TextMerkmaleTab.jsx @@ -0,0 +1,316 @@ +import { PureComponent, createRef } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import TuneIcon from '@mui/icons-material/Tune'; +import Sortable from 'sortablejs'; +import { enqueueSnackbar } from 'notistack'; +import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import FillSpace from 'dashgui/FillSpace'; +import { PanelTile, PanelTileGrid } from 'dashgui/panels'; +import BackendError from '../BackendError'; +import { backendErrorFrom } from '../../utils/backendError'; +import { notifyError } from '../../utils/notify'; +import propertyAutomat, { selectProperties } from '../../automats/propertyAutomat'; +import ArtikelMerkmalText from './ArtikelMerkmalText'; +import { ORANGE_PANEL } from '../../colors'; + +/** + * Tab 2: Text-Merkmale + */ +class TextMerkmaleTab extends PureComponent { + // #region Constructor & State + constructor(props) { + super(props); + const initialProperties = selectProperties() || []; + + this.state = { + propStatus: propertyAutomat.state.status === 'succeeded' ? 'ready' : 'loading', + error: propertyAutomat.state.error || null, + properties: initialProperties, + + propFilter: '', + propSubmitting: false, + propDeletingUnused: false, + propPage: 1, + }; + + this._cancelled = false; + this.propListRef = createRef(); + this._propSortable = null; + + this.handleRetry = this.handleRetry.bind(this); + this.handleCreateProperty = this.handleCreateProperty.bind(this); + this.handleDeleteUnusedProperties = this.handleDeleteUnusedProperties.bind(this); + } + // #endregion + + // #region Lifecycle + componentDidMount() { + this._cancelled = false; + console.log('TextMerkmaleTab mounted', this.props); + + this.unsubProp = propertyAutomat.subscribe((state) => { + if (this._cancelled) return; + if (state.status === 'succeeded') { + this.setState({ + propStatus: 'ready', + error: null, + properties: state.properties, + }); + } else if (state.status === 'failed') { + this.setState({ propStatus: 'failed', error: backendErrorFrom(state.error) }); + } + }); + + if (propertyAutomat.state.status !== 'succeeded') { + void this.load(); + } else { + this._initPropSortable(); + } + } + + componentDidUpdate(prevProps, prevState) { + if ( + (prevState.properties !== this.state.properties || + prevState.propFilter !== this.state.propFilter || + prevState.propPage !== this.state.propPage || + prevState.propStatus !== this.state.propStatus) && + this.propListRef.current + ) { + if (this.state.propStatus === 'ready') { + this._initPropSortable(); + } + } + } + + componentWillUnmount() { + this._cancelled = true; + this.unsubProp?.(); + this._destroySortables(); + } + // #endregion + + // #region Sortable Reorder + _initPropSortable() { + this._propSortable?.destroy(); + const el = this.propListRef.current; + if (!el) return; + + const isFiltered = Boolean(this.state.propFilter?.trim()); + + this._propSortable = Sortable.create(el, { + handle: '.drag-handle', + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + disabled: isFiltered, + onEnd: (evt) => { + const { oldIndex, newIndex } = evt; + if (oldIndex === newIndex) return; + + const offset = ((this.state.propPage || 1) - 1) * 8; + const realOld = offset + oldIndex; + const realNew = offset + newIndex; + if (realOld === realNew) return; + + this.setState( + (prev) => { + const next = [...prev.properties]; + const [moved] = next.splice(realOld, 1); + next.splice(realNew, 0, moved); + return { properties: next }; + }, + () => { + const ids = this.state.properties.map((p) => p.id); + void this._persistPropOrder(ids); + } + ); + }, + }); + } + + _destroySortables() { + this._propSortable?.destroy(); + this._propSortable = null; + } + + async _persistPropOrder(ids) { + try { + await propertyAutomat.actions.reorderProperties(ids); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + void propertyAutomat.actions.load(); + } + } + // #endregion + + // #region Actions & Handlers + async load() { + this.setState({ + propStatus: propertyAutomat.state.status === 'succeeded' ? 'ready' : 'loading', + error: null, + }); + try { + await propertyAutomat.actions.load(); + } catch (err) { + if (this._cancelled) return; + this.setState({ error: backendErrorFrom(err) }); + } + } + + handleRetry() { + void this.load(); + } + + async handleCreateProperty() { + const name = this.state.propFilter.trim(); + if (!name || this.state.propSubmitting) return; + + this.setState({ propSubmitting: true }); + try { + const row = await propertyAutomat.actions.createProperty({ + name, + exclusive: true, + }); + if (this._cancelled) return; + this.setState({ propFilter: '', propSubmitting: false }); + enqueueSnackbar(`Merkmal „${row?.name || name}“ angelegt.`, { variant: 'success' }); + } catch (err) { + if (this._cancelled) return; + this.setState({ propSubmitting: false }); + notifyError(err); + } + } + + async handleDeleteUnusedProperties() { + const { properties, propDeletingUnused } = this.state; + if (propDeletingUnused) return; + const unused = properties.filter((p) => (p.articleCount ?? 0) === 0); + if (!unused.length) return; + + if (!window.confirm(`Alle ${unused.length} ungenutzten Text-Merkmale wirklich löschen?`)) { + return; + } + + this.setState({ propDeletingUnused: true }); + try { + await propertyAutomat.actions.deleteProperties(unused.map((p) => p.id)); + enqueueSnackbar(`${unused.length} ungenutzte Text-Merkmale gelöscht.`, { variant: 'success' }); + } catch (err) { + notifyError(err); + } finally { + if (!this._cancelled) { + this.setState({ propDeletingUnused: false }); + } + } + } + // #endregion + + // #region Render + render() { + const { + propStatus, + error, + properties, + propFilter, + propSubmitting, + propDeletingUnused, + propPage, + } = this.state; + + if (propStatus === 'loading' && !properties.length) { + return ( + + + + ); + } + + if (error && !properties.length) { + return ; + } + + const propFilterTrimmed = propFilter.trim(); + const filteredProperties = propFilterTrimmed + ? properties.filter((p) => p.name.toLowerCase().includes(propFilterTrimmed.toLowerCase())) + : properties; + + const unusedPropCount = properties.filter((p) => (p.articleCount ?? 0) === 0).length; + + return ( + + {/* Tile 3: Text-Merkmale */} + + + + + + + Text-Merkmale + + + + + + + this.setState({ propFilter: next })} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void this.handleCreateProperty(); + } + }} + /> + + + + this.setState({ propPage: page })} + renderItem={(p) => ( + + )} + /> + + + + ); + } + // #endregion +} + +export default TextMerkmaleTab; diff --git a/src/components/merkmale/index.js b/src/components/merkmale/index.js index 5db9409..fda1524 100644 --- a/src/components/merkmale/index.js +++ b/src/components/merkmale/index.js @@ -1,3 +1,5 @@ export { default as ArtikelMerkmalAuswahl } from './ArtikelMerkmalAuswahl'; export { default as ArtikelMerkmalWert } from './ArtikelMerkmalWert'; export { default as ArtikelMerkmalText } from './ArtikelMerkmalText'; +export { default as AuswahlMerkmaleTab } from './AuswahlMerkmaleTab'; +export { default as TextMerkmaleTab } from './TextMerkmaleTab'; diff --git a/src/routes/artikel/merkmale.jsx b/src/routes/artikel/merkmale.jsx index e6b9df2..f0c5a78 100644 --- a/src/routes/artikel/merkmale.jsx +++ b/src/routes/artikel/merkmale.jsx @@ -1,787 +1,46 @@ -import { PureComponent, createRef } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; -import List from '@mui/material/List'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; +// #region Imports +import { PureComponent } from 'react'; import TuneIcon from '@mui/icons-material/Tune'; import StyleIcon from '@mui/icons-material/Style'; -import LabelImportantIcon from '@mui/icons-material/LabelImportant'; -import Sortable from 'sortablejs'; -import { enqueueSnackbar } from 'notistack'; -import BackendError from '../../components/BackendError'; -import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; -import FillSpace from 'dashgui/FillSpace'; -import { PanelTile, PanelTileGrid, TabbedPanelTileGrid } from 'dashgui/panels'; +import { TabbedPanelTileGrid } from 'dashgui/panels'; import withNavigate from '../../components/withNavigate'; -import { backendErrorFrom } from '../../utils/backendError'; -import { notifyError } from '../../utils/notify'; import { setLastArticleSku } from '../../automats'; -import attributeAutomat, { selectAttributes } from '../../automats/attributeAutomat'; -import propertyAutomat, { selectProperties } from '../../automats/propertyAutomat'; import { + AuswahlMerkmaleTab, + TextMerkmaleTab, ArtikelMerkmalAuswahl, ArtikelMerkmalWert, ArtikelMerkmalText, } from '../../components/merkmale'; -import { - BLUE_PANEL, - ORANGE_PANEL, - PURPLE_PANEL, -} from '../../colors'; +// #endregion +// #region Page +/** + * Top-level page: stateless frame holding TabbedPanelTileGrid with independent tab components. + */ class ArtikelMerkmalePage extends PureComponent { - constructor(props) { - super(props); - const initialAttributes = selectAttributes() || []; - const initialProperties = selectProperties() || []; - const initialSelectedAttrId = initialAttributes[0]?.id ?? null; - - this.state = { - attrStatus: attributeAutomat.state.status === 'succeeded' ? 'ready' : 'loading', - propStatus: propertyAutomat.state.status === 'succeeded' ? 'ready' : 'loading', - error: attributeAutomat.state.error || propertyAutomat.state.error || null, - - attributes: initialAttributes, - attrFilter: '', - attrSubmitting: false, - attrDeletingUnused: false, - attrPage: 1, - selectedAttributeId: initialSelectedAttrId, - - valFilter: '', - valSubmitting: false, - valDeletingUnused: false, - valPage: 1, - - properties: initialProperties, - propFilter: '', - propSubmitting: false, - propDeletingUnused: false, - propPage: 1, - }; - - this._cancelled = false; - this.propListRef = createRef(); - this.attrListRef = createRef(); - this.valListRef = createRef(); - - this._propSortable = null; - this._attrSortable = null; - this._valSortable = null; - - this.handleRetry = this.handleRetry.bind(this); - this.handleTabChange = this.handleTabChange.bind(this); - this.handleCreateAttribute = this.handleCreateAttribute.bind(this); - this.handleCreateValue = this.handleCreateValue.bind(this); - this.handleCreateProperty = this.handleCreateProperty.bind(this); - this.handleDeleteUnusedAttributes = this.handleDeleteUnusedAttributes.bind(this); - this.handleDeleteUnusedValues = this.handleDeleteUnusedValues.bind(this); - this.handleDeleteUnusedProperties = this.handleDeleteUnusedProperties.bind(this); - } - componentDidMount() { setLastArticleSku(null); - this._cancelled = false; - - this.unsubProp = propertyAutomat.subscribe((state) => { - if (this._cancelled) return; - if (state.status === 'succeeded') { - this.setState({ - propStatus: 'ready', - error: null, - properties: state.properties, - }); - } else if (state.status === 'failed') { - this.setState({ propStatus: 'failed', error: backendErrorFrom(state.error) }); - } - }); - - this.unsubAttr = attributeAutomat.subscribe((state) => { - if (this._cancelled) return; - if (state.status === 'succeeded') { - let selectedId = this.state.selectedAttributeId; - if (!selectedId || !state.attributes.some((a) => a.id === selectedId)) { - selectedId = state.attributes[0]?.id ?? null; - } - this.setState({ - attrStatus: 'ready', - error: null, - attributes: state.attributes, - selectedAttributeId: selectedId, - }); - } else if (state.status === 'failed') { - this.setState({ attrStatus: 'failed', error: backendErrorFrom(state.error) }); - } - }); - - void this.loadAll(); - } - - componentDidUpdate(prevProps, prevState) { - if ( - (prevState.properties !== this.state.properties || - prevState.propFilter !== this.state.propFilter || - prevState.propPage !== this.state.propPage || - prevState.propStatus !== this.state.propStatus) && - this.propListRef.current - ) { - if (this.state.propStatus === 'ready') { - this._initPropSortable(); - } - } - - if ( - (prevState.attributes !== this.state.attributes || - prevState.attrFilter !== this.state.attrFilter || - prevState.attrPage !== this.state.attrPage || - prevState.attrStatus !== this.state.attrStatus) && - this.attrListRef.current - ) { - if (this.state.attrStatus === 'ready') { - this._initAttrSortable(); - } - } - - if ( - (prevState.selectedAttributeId !== this.state.selectedAttributeId || - prevState.attributes !== this.state.attributes || - prevState.valFilter !== this.state.valFilter || - prevState.valPage !== this.state.valPage || - prevState.attrStatus !== this.state.attrStatus) && - this.valListRef.current - ) { - if (this.state.attrStatus === 'ready') { - this._initValSortable(); - } - } - } - - componentWillUnmount() { - this._cancelled = true; - this.unsubProp?.(); - this.unsubAttr?.(); - this._destroySortables(); - } - - // --- SortableJS Initialization --- - - _initPropSortable() { - this._propSortable?.destroy(); - const el = this.propListRef.current; - if (!el) return; - - const isFiltered = Boolean(this.state.propFilter?.trim()); - - this._propSortable = Sortable.create(el, { - handle: '.drag-handle', - animation: 150, - ghostClass: 'sortable-ghost', - chosenClass: 'sortable-chosen', - disabled: isFiltered, - onEnd: (evt) => { - const { oldIndex, newIndex } = evt; - if (oldIndex === newIndex) return; - - const offset = ((this.state.propPage || 1) - 1) * 8; - const realOld = offset + oldIndex; - const realNew = offset + newIndex; - if (realOld === realNew) return; - - this.setState( - (prev) => { - const next = [...prev.properties]; - const [moved] = next.splice(realOld, 1); - next.splice(realNew, 0, moved); - return { properties: next }; - }, - () => { - const ids = this.state.properties.map((p) => p.id); - void this._persistPropOrder(ids); - } - ); - }, - }); - } - - _initAttrSortable() { - this._attrSortable?.destroy(); - const el = this.attrListRef.current; - if (!el) return; - - const isFiltered = Boolean(this.state.attrFilter?.trim()); - - this._attrSortable = Sortable.create(el, { - handle: '.drag-handle', - animation: 150, - ghostClass: 'sortable-ghost', - chosenClass: 'sortable-chosen', - disabled: isFiltered, - onEnd: (evt) => { - const { oldIndex, newIndex } = evt; - if (oldIndex === newIndex) return; - - const offset = ((this.state.attrPage || 1) - 1) * 8; - const realOld = offset + oldIndex; - const realNew = offset + newIndex; - if (realOld === realNew) return; - - this.setState( - (prev) => { - const next = [...prev.attributes]; - const [moved] = next.splice(realOld, 1); - next.splice(realNew, 0, moved); - return { attributes: next }; - }, - () => { - const ids = this.state.attributes.map((a) => a.id); - void this._persistAttrOrder(ids); - } - ); - }, - }); - } - - _initValSortable() { - this._valSortable?.destroy(); - const el = this.valListRef.current; - if (!el) return; - - const isFiltered = Boolean(this.state.valFilter?.trim()); - - this._valSortable = Sortable.create(el, { - handle: '.drag-handle', - animation: 150, - ghostClass: 'sortable-ghost', - chosenClass: 'sortable-chosen', - disabled: isFiltered, - onEnd: (evt) => { - const { oldIndex, newIndex } = evt; - if (oldIndex === newIndex) return; - - const offset = ((this.state.valPage || 1) - 1) * 8; - const realOld = offset + oldIndex; - const realNew = offset + newIndex; - if (realOld === realNew) return; - - const currentAttr = this.state.attributes.find( - (a) => a.id === this.state.selectedAttributeId - ); - if (!currentAttr) return; - - const values = currentAttr.values || []; - const next = [...values]; - const [moved] = next.splice(realOld, 1); - next.splice(realNew, 0, moved); - - const updatedAttributes = this.state.attributes.map((a) => - a.id === currentAttr.id ? { ...a, values: next } : a - ); - - this.setState({ attributes: updatedAttributes }, () => { - const ids = next.map((v) => v.id); - void this._persistValOrder(currentAttr.id, ids); - }); - }, - }); - } - - _destroySortables() { - this._propSortable?.destroy(); - this._propSortable = null; - this._attrSortable?.destroy(); - this._attrSortable = null; - this._valSortable?.destroy(); - this._valSortable = null; - } - - async _persistPropOrder(ids) { - try { - await propertyAutomat.actions.reorderProperties(ids); - } catch (err) { - if (this._cancelled) return; - notifyError(err); - void propertyAutomat.actions.load(); - } - } - - async _persistAttrOrder(ids) { - try { - await attributeAutomat.actions.reorderAttributes(ids); - } catch (err) { - if (this._cancelled) return; - notifyError(err); - void attributeAutomat.actions.load(); - } - } - - async _persistValOrder(attributeId, ids) { - try { - await attributeAutomat.actions.reorderValues(attributeId, ids); - } catch (err) { - if (this._cancelled) return; - notifyError(err); - void attributeAutomat.actions.load(); - } - } - - async loadAll() { - this.setState({ - propStatus: propertyAutomat.state.status === 'succeeded' ? 'ready' : 'loading', - attrStatus: attributeAutomat.state.status === 'succeeded' ? 'ready' : 'loading', - error: null, - }); - try { - await Promise.all([ - propertyAutomat.actions.load(), - attributeAutomat.actions.load(), - ]); - } catch (err) { - if (this._cancelled) return; - this.setState({ error: backendErrorFrom(err) }); - } - } - - handleRetry() { - void this.loadAll(); - } - - handleTabChange(nextTab) { - if (nextTab === 0) { - this._initAttrSortable(); - this._initValSortable(); - } else if (nextTab === 1) { - this._initPropSortable(); - } - } - - handleSelectAttribute(attrId) { - this.setState({ selectedAttributeId: attrId, valFilter: '', valPage: 1 }); - } - - async handleCreateAttribute() { - const name = this.state.attrFilter.trim(); - if (!name || this.state.attrSubmitting) return; - - this.setState({ attrSubmitting: true }); - try { - const row = await attributeAutomat.actions.createAttribute({ - name, - exclusive: true, - }); - if (this._cancelled) return; - this.setState({ - attrFilter: '', - attrSubmitting: false, - selectedAttributeId: row?.id ?? this.state.selectedAttributeId, - }); - enqueueSnackbar(`Attribut „${row?.name || name}“ angelegt.`, { variant: 'success' }); - } catch (err) { - if (this._cancelled) return; - this.setState({ attrSubmitting: false }); - notifyError(err); - } - } - - async handleCreateValue() { - const { selectedAttributeId } = this.state; - const value = this.state.valFilter.trim(); - if (!selectedAttributeId || !value || this.state.valSubmitting) return; - - this.setState({ valSubmitting: true }); - try { - await attributeAutomat.actions.createValue(selectedAttributeId, { value }); - if (this._cancelled) return; - this.setState({ valFilter: '', valSubmitting: false }); - enqueueSnackbar(`Wert „${value}“ angelegt.`, { variant: 'success' }); - } catch (err) { - if (this._cancelled) return; - this.setState({ valSubmitting: false }); - notifyError(err); - } - } - - async handleCreateProperty() { - const name = this.state.propFilter.trim(); - if (!name || this.state.propSubmitting) return; - - this.setState({ propSubmitting: true }); - try { - const row = await propertyAutomat.actions.createProperty({ - name, - exclusive: true, - }); - if (this._cancelled) return; - this.setState({ propFilter: '', propSubmitting: false }); - enqueueSnackbar(`Merkmal „${row?.name || name}“ angelegt.`, { variant: 'success' }); - } catch (err) { - if (this._cancelled) return; - this.setState({ propSubmitting: false }); - notifyError(err); - } - } - - async handleDeleteUnusedAttributes() { - const { attributes, attrDeletingUnused } = this.state; - if (attrDeletingUnused) return; - const unused = attributes.filter((a) => (a.articleCount ?? 0) === 0); - if (!unused.length) return; - - if (!window.confirm(`Alle ${unused.length} ungenutzten Auswahl-Merkmale wirklich löschen?`)) { - return; - } - - this.setState({ attrDeletingUnused: true }); - try { - await attributeAutomat.actions.deleteAttributes(unused.map((a) => a.id)); - enqueueSnackbar(`${unused.length} ungenutzte Auswahl-Merkmale gelöscht.`, { variant: 'success' }); - } catch (err) { - notifyError(err); - } finally { - if (!this._cancelled) { - this.setState({ attrDeletingUnused: false }); - } - } - } - - async handleDeleteUnusedValues() { - const { attributes, selectedAttributeId, valDeletingUnused } = this.state; - if (valDeletingUnused) return; - const selectedAttr = attributes.find((a) => a.id === selectedAttributeId) ?? attributes[0] ?? null; - if (!selectedAttr) return; - const unused = (selectedAttr.values || []).filter((v) => (v.articleCount ?? 0) === 0); - if (!unused.length) return; - - if (!window.confirm(`Alle ${unused.length} ungenutzten Werte wirklich löschen?`)) { - return; - } - - this.setState({ valDeletingUnused: true }); - try { - await attributeAutomat.actions.deleteValues(unused.map((v) => v.id)); - enqueueSnackbar(`${unused.length} ungenutzte Werte gelöscht.`, { variant: 'success' }); - } catch (err) { - notifyError(err); - } finally { - if (!this._cancelled) { - this.setState({ valDeletingUnused: false }); - } - } - } - - async handleDeleteUnusedProperties() { - const { properties, propDeletingUnused } = this.state; - if (propDeletingUnused) return; - const unused = properties.filter((p) => (p.articleCount ?? 0) === 0); - if (!unused.length) return; - - if (!window.confirm(`Alle ${unused.length} ungenutzten Text-Merkmale wirklich löschen?`)) { - return; - } - - this.setState({ propDeletingUnused: true }); - try { - await propertyAutomat.actions.deleteProperties(unused.map((p) => p.id)); - enqueueSnackbar(`${unused.length} ungenutzte Text-Merkmale gelöscht.`, { variant: 'success' }); - } catch (err) { - notifyError(err); - } finally { - if (!this._cancelled) { - this.setState({ propDeletingUnused: false }); - } - } } render() { - const { - attrStatus, - propStatus, - error, - properties, - attributes, - selectedAttributeId, - attrFilter, - attrSubmitting, - attrDeletingUnused, - valFilter, - valSubmitting, - valDeletingUnused, - propFilter, - propSubmitting, - propDeletingUnused, - } = this.state; - - if (attrStatus === 'loading' && propStatus === 'loading' && !properties.length && !attributes.length) { - return ( - - - - ); - } - - if (error && !properties.length && !attributes.length) { - return ; - } - - const selectedAttr = attributes.find((a) => a.id === selectedAttributeId) ?? attributes[0] ?? null; - const selectedValues = selectedAttr?.values || []; - - const attrFilterTrimmed = attrFilter.trim(); - const filteredAttributes = attrFilterTrimmed - ? attributes.filter((a) => a.name.toLowerCase().includes(attrFilterTrimmed.toLowerCase())) - : attributes; - - const valFilterTrimmed = valFilter.trim(); - const filteredValues = valFilterTrimmed - ? selectedValues.filter((v) => v.value.toLowerCase().includes(valFilterTrimmed.toLowerCase())) - : selectedValues; - - const propFilterTrimmed = propFilter.trim(); - const filteredProperties = propFilterTrimmed - ? properties.filter((p) => p.name.toLowerCase().includes(propFilterTrimmed.toLowerCase())) - : properties; - - const unusedAttrCount = attributes.filter((a) => (a.articleCount ?? 0) === 0).length; - const unusedValCount = (selectedValues || []).filter((v) => (v.articleCount ?? 0) === 0).length; - const unusedPropCount = properties.filter((p) => (p.articleCount ?? 0) === 0).length; - return ( - - {/* Tab 1: Auswahl-Merkmale & Werte */} - } text="Auswahl-Merkmale"> - {/* Tile 1: Auswahl-Merkmale */} - - - - - - - Auswahl-Merkmale - - - - - - - this.setState({ attrFilter: next })} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - void this.handleCreateAttribute(); - } - }} - /> - - - - {filteredAttributes.length === 0 ? ( - - {attrFilterTrimmed - ? `Keine Treffer für „${attrFilterTrimmed}“.` - : 'Noch keine Auswahl-Merkmale angelegt.'} - - ) : ( - this.setState({ attrPage: page })} - renderItem={(a) => ( - this.handleSelectAttribute(a.id)} - /> - )} - /> - )} - - - - {/* Tile 2: Werte für das gewählte Auswahl-Merkmal */} - - - - - - - {selectedAttr ? `Werte für Auswahl-Merkmal „${selectedAttr.name}“` : 'Werte'} - - - - - - {!selectedAttr ? ( - - Kein Attribut ausgewählt. - - ) : ( - <> - - this.setState({ valFilter: next })} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - void this.handleCreateValue(); - } - }} - /> - - - - {filteredValues.length === 0 ? ( - - {valFilterTrimmed - ? `Keine Treffer für „${valFilterTrimmed}“.` - : 'Noch keine Werte angelegt.'} - - ) : ( - this.setState({ valPage: page })} - renderItem={(v) => ( - - )} - /> - )} - - )} - - - - - {/* Tab 2: Text-Merkmale */} - } text="Text-Merkmale"> - {/* Tile 3: Text-Merkmale */} - - - - - - - Text-Merkmale - - - - - - - this.setState({ propFilter: next })} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - void this.handleCreateProperty(); - } - }} - /> - - - - this.setState({ propPage: page })} - renderItem={(p) => ( - - )} - /> - - - + + } text="Auswahl-Merkmale" /> + } text="Text-Merkmale" /> ); } } +// #endregion +// #region Exports export { + AuswahlMerkmaleTab, + TextMerkmaleTab, ArtikelMerkmalAuswahl, ArtikelMerkmalWert, ArtikelMerkmalText, }; export default withNavigate(ArtikelMerkmalePage); +// #endregion