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'; 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 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 { ArtikelMerkmalAuswahl, ArtikelMerkmalWert, ArtikelMerkmalText, } from '../../components/merkmale'; import { BLUE_PANEL, ORANGE_PANEL, PURPLE_PANEL, } from '../../colors'; 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, attrPage: 1, selectedAttributeId: initialSelectedAttrId, valFilter: '', valSubmitting: false, valPage: 1, properties: initialProperties, propFilter: '', propSubmitting: 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); } 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); } } render() { const { attrStatus, propStatus, error, properties, attributes, selectedAttributeId, attrFilter, attrSubmitting, valFilter, valSubmitting, propFilter, propSubmitting, } = 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; 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(); } }} /> {filteredProperties.length === 0 ? ( {propFilterTrimmed ? `Keine Treffer für „${propFilterTrimmed}“.` : 'Noch keine Text-Merkmale angelegt.'} ) : ( this.setState({ propPage: page })} renderItem={(p) => ( )} /> )} ); } } export { ArtikelMerkmalAuswahl, ArtikelMerkmalWert, ArtikelMerkmalText, }; export default withNavigate(ArtikelMerkmalePage);