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