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;