diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx
index da778a5..1545d58 100644
--- a/src/components/AppShell.jsx
+++ b/src/components/AppShell.jsx
@@ -20,7 +20,6 @@ import {
NAV_SURFACE,
} from '../colors';
import { ChatFab, ChatDrawer } from './chat';
-import PageMinimap from './PageMinimap';
const SIDEBAR_WIDTH = 144;
@@ -323,7 +322,6 @@ export default function AppShell() {
-
);
}
diff --git a/src/components/PageMinimap.jsx b/src/components/PageMinimap.jsx
index 17d853c..8d186cd 100644
--- a/src/components/PageMinimap.jsx
+++ b/src/components/PageMinimap.jsx
@@ -1,10 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import { matchPath, useLocation } from 'react-router-dom';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import MapRoundedIcon from '@mui/icons-material/MapRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
-import { ARTICLE_TAB, readPreferredArticleTab } from './article/articleRoute';
const MINIMAP_WIDTH = 220;
const MINIMAP_MAX_HEIGHT = 300;
@@ -42,16 +40,6 @@ export default function PageMinimap({ sourceSelector = 'main' }) {
const idleTimerRef = useRef(null);
const metricsRef = useRef({ docH: 1, winH: 1, thumbH: 1 });
- // SEO tab only: /artikel/:sku/seo, or /artikel/:sku when the preferred
- // default tab resolves to seo.
- const { pathname } = useLocation();
- const articleMatch = matchPath('/artikel/:sku/:tab?', pathname);
- const isSeoPage = Boolean(
- articleMatch &&
- (articleMatch.params?.tab === ARTICLE_TAB.seo ||
- (!articleMatch.params?.tab &&
- readPreferredArticleTab() === ARTICLE_TAB.seo)),
- );
/** Move the viewport rectangle without re-rendering (called on scroll). */
const updateViewport = useCallback(() => {
@@ -160,11 +148,6 @@ export default function PageMinimap({ sourceSelector = 'main' }) {
if (enabled && open) rebuild();
}, [enabled, open, rebuild]);
- // Reset scroll visibility on navigation; the new page starts hidden
- // until the user scrolls again.
- useEffect(() => {
- setScrollActive(false);
- }, [pathname]);
useEffect(() => {
rebuild();
@@ -214,7 +197,7 @@ export default function PageMinimap({ sourceSelector = 'main' }) {
// Stays mounted for fade-out; hidden unless scrolling/hovering/dragging.
const visible = scrollActive || hovering || dragging;
- if (!enabled || !isSeoPage) return null;
+ if (!enabled) return null;
// Collapsed: a single centered pill to reopen the overview.
if (!open) {
diff --git a/src/components/article/tabs/ArticleSeoTab.jsx b/src/components/article/tabs/ArticleSeoTab.jsx
index 6e1dfcc..d9e123b 100644
--- a/src/components/article/tabs/ArticleSeoTab.jsx
+++ b/src/components/article/tabs/ArticleSeoTab.jsx
@@ -1,5 +1,6 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
+import PageMinimap from '../../PageMinimap';
function PanelHtmlValue({ html }) {
if (html == null || html === '') return null;
@@ -57,6 +58,11 @@ export default class ArticleSeoTab extends Component {
render() {
const { article: articleData } = this.props;
const desc = articleData.desc ?? articleData.description;
- return ;
+ return (
+ <>
+
+
+ >
+ );
}
}
diff --git a/src/components/merkmale/ArtikelMerkmalAuswahl.jsx b/src/components/merkmale/ArtikelMerkmalAuswahl.jsx
new file mode 100644
index 0000000..2c8178f
--- /dev/null
+++ b/src/components/merkmale/ArtikelMerkmalAuswahl.jsx
@@ -0,0 +1,227 @@
+import { PureComponent } from 'react';
+import Box from '@mui/material/Box';
+import Chip from '@mui/material/Chip';
+import IconButton from '@mui/material/IconButton';
+import ListItem from '@mui/material/ListItem';
+import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
+import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
+import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
+import { enqueueSnackbar } from 'notistack';
+import attributeAutomat from '../../automats/attributeAutomat';
+import { notifyError } from '../../utils/notify';
+import {
+ SELECT_BG,
+ SELECT_BG_HOVER,
+ SELECT_BORDER,
+ TINT_SUBTLE,
+} from '../../colors';
+
+class ArtikelMerkmalAuswahl extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ draft: props.attribute?.name ?? '',
+ busy: false,
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.attribute?.name !== this.props.attribute?.name) {
+ this.setState({ draft: this.props.attribute?.name ?? '' });
+ } else if (prevProps.disabled && !this.props.disabled) {
+ if (this.state.draft !== this.props.attribute?.name) {
+ this.setState({ draft: this.props.attribute?.name ?? '' });
+ }
+ }
+ }
+
+ handleChange = (next) => {
+ this.setState({ draft: next });
+ this.props.onChange?.(next, this.props.attribute);
+ };
+
+ handleBlur = () => {
+ const { attribute } = this.props;
+ if (!attribute) return;
+ const draft = this.state.draft.trim();
+ if (!draft) {
+ this.setState({ draft: attribute.name ?? '' });
+ return;
+ }
+ if (draft !== attribute.name) {
+ void this.renameAttribute(attribute, draft);
+ }
+ };
+
+ async renameAttribute(attribute, name) {
+ this.setState({ busy: true });
+ try {
+ await attributeAutomat.actions.updateAttribute(attribute.id, { name });
+ enqueueSnackbar('Attribut gespeichert.', { variant: 'success' });
+ } catch (err) {
+ this.setState({ draft: attribute.name ?? '' });
+ notifyError(err);
+ } finally {
+ this.setState({ busy: false });
+ }
+ }
+
+ handleKeyDown = (event) => {
+ const { attribute } = this.props;
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ event.target.blur();
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ this.setState({ draft: attribute?.name ?? '' });
+ event.target.blur();
+ }
+ };
+
+ handleToggleExclusive = async (event) => {
+ event.stopPropagation();
+ const { attribute, disabled } = this.props;
+ if (!attribute || disabled || this.state.busy) return;
+
+ const nextExclusive = !attribute.exclusive;
+ this.setState({ busy: true });
+ try {
+ await attributeAutomat.actions.updateAttribute(attribute.id, { exclusive: nextExclusive });
+ enqueueSnackbar(
+ `Attribut „${attribute.name}“ auf ${nextExclusive ? 'Einzelauswahl' : 'Mehrfachauswahl'} gestellt.`,
+ { variant: 'info' }
+ );
+ } catch (err) {
+ notifyError(err);
+ } finally {
+ this.setState({ busy: false });
+ }
+ };
+
+ handleDelete = (event) => {
+ event.stopPropagation();
+ const { attribute, disabled } = this.props;
+ if (!attribute || disabled || this.state.busy) return;
+
+ if (!window.confirm(`Attribut „${attribute.name}“ und alle zugehörigen Werte wirklich löschen?`)) {
+ return;
+ }
+
+ this.setState({ busy: true });
+ void (async () => {
+ try {
+ await attributeAutomat.actions.deleteAttribute(attribute.id);
+ enqueueSnackbar('Attribut gelöscht.', { variant: 'success' });
+ } catch (err) {
+ notifyError(err);
+ this.setState({ busy: false });
+ }
+ })();
+ };
+
+ render() {
+ const { attribute, isSelected, onSelect, disabled, sx } = this.props;
+ const { draft, busy } = this.state;
+ const name = attribute?.name ?? '';
+ const exclusive = attribute ? Boolean(attribute.exclusive) : true;
+ const isDisabled = disabled || busy;
+
+ return (
+
+ e.stopPropagation()}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: 40,
+ height: 40,
+ flexShrink: 0,
+ color: 'text.disabled',
+ cursor: isDisabled ? 'default' : 'grab',
+ userSelect: 'none',
+ touchAction: 'none',
+ '&:hover': isDisabled ? undefined : { color: 'text.secondary' },
+ transition: 'color 0.15s',
+ }}
+ >
+
+
+
+ e.stopPropagation()} sx={{ flex: 1, minWidth: 0 }}>
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default ArtikelMerkmalAuswahl;
diff --git a/src/components/merkmale/ArtikelMerkmalText.jsx b/src/components/merkmale/ArtikelMerkmalText.jsx
new file mode 100644
index 0000000..61428ed
--- /dev/null
+++ b/src/components/merkmale/ArtikelMerkmalText.jsx
@@ -0,0 +1,212 @@
+import { PureComponent } from 'react';
+import Box from '@mui/material/Box';
+import Chip from '@mui/material/Chip';
+import IconButton from '@mui/material/IconButton';
+import ListItem from '@mui/material/ListItem';
+import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
+import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
+import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
+import { enqueueSnackbar } from 'notistack';
+import propertyAutomat from '../../automats/propertyAutomat';
+import { notifyError } from '../../utils/notify';
+
+class ArtikelMerkmalText extends PureComponent {
+ constructor(props) {
+ super(props);
+ const item = props.property ?? props.item;
+ this.state = {
+ draft: item?.name ?? '',
+ busy: false,
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const prevItem = prevProps.property ?? prevProps.item;
+ const item = this.props.property ?? this.props.item;
+ if (prevItem?.name !== item?.name) {
+ this.setState({ draft: item?.name ?? '' });
+ } else if (prevProps.disabled && !this.props.disabled) {
+ if (this.state.draft !== item?.name) {
+ this.setState({ draft: item?.name ?? '' });
+ }
+ }
+ }
+
+ handleChange = (next) => {
+ this.setState({ draft: next });
+ const item = this.props.property ?? this.props.item;
+ this.props.onChange?.(next, item);
+ };
+
+ handleBlur = () => {
+ const item = this.props.property ?? this.props.item;
+ if (!item) return;
+ const draft = this.state.draft.trim();
+ if (!draft) {
+ this.setState({ draft: item.name ?? '' });
+ return;
+ }
+ if (draft !== item.name) {
+ void this.renameProperty(item, draft);
+ }
+ };
+
+ async renameProperty(property, name) {
+ this.setState({ busy: true });
+ try {
+ await propertyAutomat.actions.updateProperty(property.id, { name });
+ enqueueSnackbar('Merkmal gespeichert.', { variant: 'success' });
+ } catch (err) {
+ this.setState({ draft: property.name ?? '' });
+ notifyError(err);
+ } finally {
+ this.setState({ busy: false });
+ }
+ }
+
+ handleKeyDown = (event) => {
+ const item = this.props.property ?? this.props.item;
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ event.target.blur();
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ this.setState({ draft: item?.name ?? '' });
+ event.target.blur();
+ }
+ };
+
+ handleToggleExclusive = async (event) => {
+ event.stopPropagation();
+ const item = this.props.property ?? this.props.item;
+ if (!item || this.props.disabled || this.state.busy) return;
+
+ const nextExclusive = !item.exclusive;
+ this.setState({ busy: true });
+ try {
+ await propertyAutomat.actions.updateProperty(item.id, { exclusive: nextExclusive });
+ enqueueSnackbar(
+ `Merkmal „${item.name}“ auf ${nextExclusive ? 'Einzelauswahl' : 'Mehrfachauswahl'} gestellt.`,
+ { variant: 'info' }
+ );
+ } catch (err) {
+ notifyError(err);
+ } finally {
+ this.setState({ busy: false });
+ }
+ };
+
+ handleDelete = () => {
+ const item = this.props.property ?? this.props.item;
+ if (!item || this.props.disabled || this.state.busy) return;
+
+ if (!window.confirm(`Merkmal „${item.name}“ wirklich löschen?`)) {
+ return;
+ }
+
+ this.setState({ busy: true });
+ void (async () => {
+ try {
+ await propertyAutomat.actions.deleteProperty(item.id);
+ enqueueSnackbar('Merkmal gelöscht.', { variant: 'success' });
+ } catch (err) {
+ notifyError(err);
+ this.setState({ busy: false });
+ }
+ })();
+ };
+
+ render() {
+ const { disabled, sx } = this.props;
+ const item = this.props.property ?? this.props.item;
+ const { draft, busy } = this.state;
+ const name = item?.name ?? '';
+ const exclusive = item ? Boolean(item.exclusive) : true;
+ const isDisabled = disabled || busy;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default ArtikelMerkmalText;
diff --git a/src/components/merkmale/ArtikelMerkmalWert.jsx b/src/components/merkmale/ArtikelMerkmalWert.jsx
new file mode 100644
index 0000000..99215df
--- /dev/null
+++ b/src/components/merkmale/ArtikelMerkmalWert.jsx
@@ -0,0 +1,163 @@
+import { PureComponent } from 'react';
+import Box from '@mui/material/Box';
+import IconButton from '@mui/material/IconButton';
+import ListItem from '@mui/material/ListItem';
+import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
+import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
+import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
+import { enqueueSnackbar } from 'notistack';
+import attributeAutomat from '../../automats/attributeAutomat';
+import { notifyError } from '../../utils/notify';
+
+class ArtikelMerkmalWert extends PureComponent {
+ constructor(props) {
+ super(props);
+ const item = props.item ?? props.value;
+ this.state = {
+ draft: item?.value ?? '',
+ busy: false,
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const prevItem = prevProps.item ?? prevProps.value;
+ const item = this.props.item ?? this.props.value;
+ if (prevItem?.value !== item?.value) {
+ this.setState({ draft: item?.value ?? '' });
+ } else if (prevProps.disabled && !this.props.disabled) {
+ if (this.state.draft !== item?.value) {
+ this.setState({ draft: item?.value ?? '' });
+ }
+ }
+ }
+
+ handleChange = (next) => {
+ this.setState({ draft: next });
+ const item = this.props.item ?? this.props.value;
+ this.props.onChange?.(next, item);
+ };
+
+ handleBlur = () => {
+ const item = this.props.item ?? this.props.value;
+ if (!item) return;
+ const draft = this.state.draft.trim();
+ if (!draft) {
+ this.setState({ draft: item.value ?? '' });
+ return;
+ }
+ if (draft !== item.value) {
+ void this.renameValue(item, draft);
+ }
+ };
+
+ async renameValue(item, val) {
+ this.setState({ busy: true });
+ try {
+ await attributeAutomat.actions.updateValue(item.id, { value: val });
+ enqueueSnackbar('Wert gespeichert.', { variant: 'success' });
+ } catch (err) {
+ this.setState({ draft: item.value ?? '' });
+ notifyError(err);
+ } finally {
+ this.setState({ busy: false });
+ }
+ }
+
+ handleKeyDown = (event) => {
+ const item = this.props.item ?? this.props.value;
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ event.target.blur();
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ this.setState({ draft: item?.value ?? '' });
+ event.target.blur();
+ }
+ };
+
+ handleDelete = () => {
+ const item = this.props.item ?? this.props.value;
+ if (!item || this.props.disabled || this.state.busy) return;
+
+ if (!window.confirm(`Wert „${item.value}“ wirklich löschen?`)) {
+ return;
+ }
+
+ this.setState({ busy: true });
+ void (async () => {
+ try {
+ await attributeAutomat.actions.deleteValue(item.id);
+ enqueueSnackbar('Wert gelöscht.', { variant: 'success' });
+ } catch (err) {
+ notifyError(err);
+ this.setState({ busy: false });
+ }
+ })();
+ };
+
+ render() {
+ const { disabled, sx } = this.props;
+ const item = this.props.item ?? this.props.value;
+ const { draft, busy } = this.state;
+ const valText = item?.value ?? '';
+ const isDisabled = disabled || busy;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default ArtikelMerkmalWert;
diff --git a/src/components/merkmale/index.js b/src/components/merkmale/index.js
new file mode 100644
index 0000000..5db9409
--- /dev/null
+++ b/src/components/merkmale/index.js
@@ -0,0 +1,3 @@
+export { default as ArtikelMerkmalAuswahl } from './ArtikelMerkmalAuswahl';
+export { default as ArtikelMerkmalWert } from './ArtikelMerkmalWert';
+export { default as ArtikelMerkmalText } from './ArtikelMerkmalText';
diff --git a/src/routes/artikel/merkmale.jsx b/src/routes/artikel/merkmale.jsx
index 8f532a2..563705f 100644
--- a/src/routes/artikel/merkmale.jsx
+++ b/src/routes/artikel/merkmale.jsx
@@ -2,14 +2,9 @@ import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
-import IconButton from '@mui/material/IconButton';
import List from '@mui/material/List';
-import ListItem from '@mui/material/ListItem';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
-import Chip from '@mui/material/Chip';
-import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
-import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import TuneIcon from '@mui/icons-material/Tune';
import StyleIcon from '@mui/icons-material/Style';
import LabelImportantIcon from '@mui/icons-material/LabelImportant';
@@ -24,20 +19,17 @@ 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,
- SELECT_BG,
- SELECT_BG_HOVER,
- SELECT_BORDER,
- TINT_SUBTLE,
} from '../../colors';
-function draftsFrom(items, key = 'name') {
- return Object.fromEntries((items || []).map((row) => [row.id, row[key]]));
-}
-
class ArtikelMerkmalePage extends PureComponent {
constructor(props) {
super(props);
@@ -50,22 +42,17 @@ class ArtikelMerkmalePage extends PureComponent {
propStatus: propertyAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
error: attributeAutomat.state.error || propertyAutomat.state.error || null,
- properties: initialProperties,
- propDrafts: draftsFrom(initialProperties, 'name'),
- newPropName: '',
-
attributes: initialAttributes,
- attrDrafts: draftsFrom(initialAttributes, 'name'),
- newAttrName: '',
+ attrFilter: '',
+ attrSubmitting: false,
selectedAttributeId: initialSelectedAttrId,
- valDrafts: draftsFrom(
- initialAttributes.find((a) => a.id === initialSelectedAttrId)?.values || [],
- 'value'
- ),
- newValText: '',
+ valFilter: '',
+ valSubmitting: false,
- busyKey: null,
+ properties: initialProperties,
+ propFilter: '',
+ propSubmitting: false,
};
this._cancelled = false;
@@ -76,6 +63,11 @@ class ArtikelMerkmalePage extends PureComponent {
this._propSortable = null;
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.handleCreateProperty = this.handleCreateProperty.bind(this);
}
componentDidMount() {
@@ -85,7 +77,11 @@ class ArtikelMerkmalePage extends PureComponent {
this.unsubProp = propertyAutomat.subscribe((state) => {
if (this._cancelled) return;
if (state.status === 'succeeded') {
- this.applyProperties(state.properties);
+ this.setState({
+ propStatus: 'ready',
+ error: null,
+ properties: state.properties,
+ });
} else if (state.status === 'failed') {
this.setState({ propStatus: 'failed', error: backendErrorFrom(state.error) });
}
@@ -94,7 +90,16 @@ class ArtikelMerkmalePage extends PureComponent {
this.unsubAttr = attributeAutomat.subscribe((state) => {
if (this._cancelled) return;
if (state.status === 'succeeded') {
- this.applyAttributes(state.attributes);
+ 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) });
}
@@ -104,18 +109,38 @@ class ArtikelMerkmalePage extends PureComponent {
}
componentDidUpdate(prevProps, prevState) {
- if (prevState.properties !== this.state.properties && this.propListRef.current) {
- this._initPropSortable();
+ if (
+ (prevState.properties !== this.state.properties ||
+ prevState.propFilter !== this.state.propFilter ||
+ prevState.propStatus !== this.state.propStatus) &&
+ this.propListRef.current
+ ) {
+ if (this.state.propStatus === 'ready') {
+ this._initPropSortable();
+ }
}
- if (prevState.attributes !== this.state.attributes && this.attrListRef.current) {
- this._initAttrSortable();
+
+ if (
+ (prevState.attributes !== this.state.attributes ||
+ prevState.attrFilter !== this.state.attrFilter ||
+ 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.attributes !== this.state.attributes ||
+ prevState.valFilter !== this.state.valFilter ||
+ prevState.attrStatus !== this.state.attrStatus) &&
this.valListRef.current
) {
- this._initValSortable();
+ if (this.state.attrStatus === 'ready') {
+ this._initValSortable();
+ }
}
}
@@ -133,11 +158,14 @@ class ArtikelMerkmalePage extends PureComponent {
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;
@@ -163,11 +191,14 @@ class ArtikelMerkmalePage extends PureComponent {
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;
@@ -193,11 +224,14 @@ class ArtikelMerkmalePage extends PureComponent {
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;
@@ -263,55 +297,6 @@ class ArtikelMerkmalePage extends PureComponent {
}
}
- // --- Data Loading & Sync ---
-
- applyProperties(properties) {
- this.setState((prev) => {
- const currentDrafts = prev.propDrafts || {};
- const nextDrafts = { ...draftsFrom(properties, 'name') };
- for (const p of properties) {
- if (prev.busyKey !== `prop-${p.id}` && currentDrafts[p.id] !== undefined) {
- nextDrafts[p.id] = currentDrafts[p.id];
- }
- }
- return {
- propStatus: 'ready',
- error: null,
- properties,
- propDrafts: nextDrafts,
- };
- });
- }
-
- applyAttributes(attributes) {
- this.setState((prev) => {
- const currentDrafts = prev.attrDrafts || {};
- const nextDrafts = { ...draftsFrom(attributes, 'name') };
- for (const a of attributes) {
- if (prev.busyKey !== `attr-${a.id}` && currentDrafts[a.id] !== undefined) {
- nextDrafts[a.id] = currentDrafts[a.id];
- }
- }
-
- let selectedId = prev.selectedAttributeId;
- if (!selectedId || !attributes.some((a) => a.id === selectedId)) {
- selectedId = attributes[0]?.id ?? null;
- }
-
- const selectedAttr = attributes.find((a) => a.id === selectedId);
- const nextValDrafts = { ...draftsFrom(selectedAttr?.values || [], 'value') };
-
- return {
- attrStatus: 'ready',
- error: null,
- attributes,
- attrDrafts: nextDrafts,
- selectedAttributeId: selectedId,
- valDrafts: nextValDrafts,
- };
- });
- }
-
async loadAll() {
this.setState({
propStatus: propertyAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
@@ -329,103 +314,19 @@ class ArtikelMerkmalePage extends PureComponent {
}
}
- handleRetry = () => {
+ handleRetry() {
void this.loadAll();
- };
-
- // --- Property Actions ---
-
- async handleCreateProperty() {
- const name = this.state.newPropName.trim();
- if (!name) return;
-
- this.setState({ busyKey: 'new-prop' });
- try {
- const row = await propertyAutomat.actions.createProperty({
- name,
- exclusive: true,
- });
- if (this._cancelled) return;
- this.setState({ newPropName: '', busyKey: null });
- enqueueSnackbar(`Merkmal „${row?.name || name}" angelegt.`, { variant: 'success' });
- } catch (err) {
- if (this._cancelled) return;
- notifyError(err);
- this.setState({ busyKey: null });
- }
}
- async handleTogglePropertyExclusive(prop, e) {
- e?.stopPropagation();
-
- const nextExclusive = !prop.exclusive;
- this.setState({ busyKey: `prop-ex-${prop.id}` });
- try {
- await propertyAutomat.actions.updateProperty(prop.id, { exclusive: nextExclusive });
- if (this._cancelled) return;
- this.setState({ busyKey: null });
- enqueueSnackbar(
- `Merkmal „${prop.name}“ auf ${nextExclusive ? 'Einzelauswahl' : 'Mehrfachauswahl'} gestellt.`,
- { variant: 'info' }
- );
- } catch (err) {
- if (this._cancelled) return;
- notifyError(err);
- this.setState({ busyKey: null });
- }
+ handleSelectAttribute(attrId) {
+ this.setState({ selectedAttributeId: attrId, valFilter: '' });
}
- async handleRenameProperty(prop) {
- const draft = String(this.state.propDrafts[prop.id] ?? '').trim();
- if (!draft) {
- this.setState((prev) => ({
- propDrafts: { ...prev.propDrafts, [prop.id]: prop.name },
- }));
- return;
- }
- if (draft === prop.name) return;
-
- this.setState({ busyKey: `prop-${prop.id}` });
- try {
- await propertyAutomat.actions.updateProperty(prop.id, { name: draft });
- if (this._cancelled) return;
- this.setState({ busyKey: null });
- enqueueSnackbar('Merkmal gespeichert.', { variant: 'success' });
- } catch (err) {
- if (this._cancelled) return;
- notifyError(err);
- this.setState((prev) => ({
- busyKey: null,
- propDrafts: { ...prev.propDrafts, [prop.id]: prop.name },
- }));
- }
- }
-
- async handleDeleteProperty(prop) {
- if (!window.confirm(`Merkmal „${prop.name}" wirklich löschen?`)) {
- return;
- }
-
- this.setState({ busyKey: `prop-${prop.id}` });
- try {
- await propertyAutomat.actions.deleteProperty(prop.id);
- if (this._cancelled) return;
- this.setState({ busyKey: null });
- enqueueSnackbar('Merkmal gelöscht.', { variant: 'success' });
- } catch (err) {
- if (this._cancelled) return;
- notifyError(err);
- this.setState({ busyKey: null });
- }
- }
-
- // --- Attribute Actions ---
-
async handleCreateAttribute() {
- const name = this.state.newAttrName.trim();
- if (!name) return;
+ const name = this.state.attrFilter.trim();
+ if (!name || this.state.attrSubmitting) return;
- this.setState({ busyKey: 'new-attr' });
+ this.setState({ attrSubmitting: true });
try {
const row = await attributeAutomat.actions.createAttribute({
name,
@@ -433,152 +334,53 @@ class ArtikelMerkmalePage extends PureComponent {
});
if (this._cancelled) return;
this.setState({
- newAttrName: '',
- busyKey: null,
+ attrFilter: '',
+ attrSubmitting: false,
selectedAttributeId: row?.id ?? this.state.selectedAttributeId,
});
- enqueueSnackbar(`Attribut „${row?.name || name}" angelegt.`, { variant: 'success' });
+ enqueueSnackbar(`Attribut „${row?.name || name}“ angelegt.`, { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
+ this.setState({ attrSubmitting: false });
notifyError(err);
- this.setState({ busyKey: null });
}
}
- async handleRenameAttribute(attr) {
- const draft = String(this.state.attrDrafts[attr.id] ?? '').trim();
- if (!draft) {
- this.setState((prev) => ({
- attrDrafts: { ...prev.attrDrafts, [attr.id]: attr.name },
- }));
- return;
- }
- if (draft === attr.name) return;
+ async handleCreateValue() {
+ const { selectedAttributeId } = this.state;
+ const value = this.state.valFilter.trim();
+ if (!selectedAttributeId || !value || this.state.valSubmitting) return;
- this.setState({ busyKey: `attr-${attr.id}` });
+ this.setState({ valSubmitting: true });
try {
- await attributeAutomat.actions.updateAttribute(attr.id, { name: draft });
+ await attributeAutomat.actions.createValue(selectedAttributeId, { value });
if (this._cancelled) return;
- this.setState({ busyKey: null });
- enqueueSnackbar('Attribut gespeichert.', { variant: 'success' });
+ this.setState({ valFilter: '', valSubmitting: false });
+ enqueueSnackbar(`Wert „${value}“ angelegt.`, { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
+ this.setState({ valSubmitting: false });
notifyError(err);
- this.setState((prev) => ({
- busyKey: null,
- attrDrafts: { ...prev.attrDrafts, [attr.id]: attr.name },
- }));
}
}
- async handleToggleAttributeExclusive(attr, e) {
- e?.stopPropagation();
+ async handleCreateProperty() {
+ const name = this.state.propFilter.trim();
+ if (!name || this.state.propSubmitting) return;
- const nextExclusive = !attr.exclusive;
- this.setState({ busyKey: `attr-ex-${attr.id}` });
+ this.setState({ propSubmitting: true });
try {
- await attributeAutomat.actions.updateAttribute(attr.id, { exclusive: nextExclusive });
+ const row = await propertyAutomat.actions.createProperty({
+ name,
+ exclusive: true,
+ });
if (this._cancelled) return;
- this.setState({ busyKey: null });
- enqueueSnackbar(
- `Attribut „${attr.name}“ auf ${nextExclusive ? 'Einzelauswahl' : 'Mehrfachauswahl'} gestellt.`,
- { variant: 'info' }
- );
+ 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);
- this.setState({ busyKey: null });
- }
- }
-
- async handleDeleteAttribute(attr, e) {
- e?.stopPropagation();
- if (!window.confirm(`Attribut „${attr.name}" und alle zugehörigen Werte wirklich löschen?`)) {
- return;
- }
-
- this.setState({ busyKey: `attr-${attr.id}` });
- try {
- await attributeAutomat.actions.deleteAttribute(attr.id);
- if (this._cancelled) return;
- this.setState({ busyKey: null });
- enqueueSnackbar('Attribut gelöscht.', { variant: 'success' });
- } catch (err) {
- if (this._cancelled) return;
- notifyError(err);
- this.setState({ busyKey: null });
- }
- }
-
- handleSelectAttribute(attrId) {
- const selectedAttr = this.state.attributes.find((a) => a.id === attrId);
- this.setState({
- selectedAttributeId: attrId,
- valDrafts: draftsFrom(selectedAttr?.values || [], 'value'),
- newValText: '',
- });
- }
-
- // --- Value Actions ---
-
- async handleCreateValue(attrId) {
- const text = this.state.newValText.trim();
- if (!text || !attrId) return;
-
- this.setState({ busyKey: 'new-val' });
- try {
- await attributeAutomat.actions.createValue(attrId, { value: text });
- if (this._cancelled) return;
- this.setState({ newValText: '', busyKey: null });
- enqueueSnackbar(`Wert „${text}" angelegt.`, { variant: 'success' });
- } catch (err) {
- if (this._cancelled) return;
- notifyError(err);
- this.setState({ busyKey: null });
- }
- }
-
- async handleRenameValue(val) {
- const draft = String(this.state.valDrafts[val.id] ?? '').trim();
- if (!draft) {
- this.setState((prev) => ({
- valDrafts: { ...prev.valDrafts, [val.id]: val.value },
- }));
- return;
- }
- if (draft === val.value) return;
-
- this.setState({ busyKey: `val-${val.id}` });
- try {
- await attributeAutomat.actions.updateValue(val.id, { value: draft });
- if (this._cancelled) return;
- this.setState({ busyKey: null });
- enqueueSnackbar('Wert gespeichert.', { variant: 'success' });
- } catch (err) {
- if (this._cancelled) return;
- notifyError(err);
- this.setState((prev) => ({
- busyKey: null,
- valDrafts: { ...prev.valDrafts, [val.id]: val.value },
- }));
- }
- }
-
- async handleDeleteValue(val) {
- if (!window.confirm(`Wert „${val.value}" wirklich löschen?`)) {
- return;
- }
-
- this.setState({ busyKey: `val-${val.id}` });
- try {
- await attributeAutomat.actions.deleteValue(val.id);
- if (this._cancelled) return;
- this.setState({ busyKey: null });
- enqueueSnackbar('Wert gelöscht.', { variant: 'success' });
- } catch (err) {
- if (this._cancelled) return;
- notifyError(err);
- this.setState({ busyKey: null });
}
}
@@ -588,18 +390,16 @@ class ArtikelMerkmalePage extends PureComponent {
propStatus,
error,
properties,
- propDrafts,
- newPropName,
attributes,
- attrDrafts,
- newAttrName,
selectedAttributeId,
- valDrafts,
- newValText,
- busyKey,
+ attrFilter,
+ attrSubmitting,
+ valFilter,
+ valSubmitting,
+ propFilter,
+ propSubmitting,
} = this.state;
-
if (attrStatus === 'loading' && propStatus === 'loading' && !properties.length && !attributes.length) {
return (
@@ -615,378 +415,205 @@ class ArtikelMerkmalePage extends PureComponent {
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 (
- <>
- {/* SortableJS ghost / chosen styling from /artikel/lager */}
+
+ {/* Tile 1: Auswahl-Merkmale */}
+
+
+
+
+
+ Auswahl-Merkmale
+
+
-
- {/* Tile 1: Attribute (Auswahllisten) */}
-
-
-
-
-
- Auswahl-Merkmale
-
-
+
+ this.setState({ attrFilter: next })}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ void this.handleCreateAttribute();
+ }
+ }}
+ />
+
+
-
- {attributes.map((a) => {
- const isSelected = selectedAttr?.id === a.id;
- return (
- this.handleSelectAttribute(a.id)}
- sx={{
- gap: 1,
- py: 0.75,
- px: 0.5,
- borderRadius: 1.5,
- alignItems: 'center',
- cursor: 'pointer',
- bgcolor: isSelected ? SELECT_BG : 'transparent',
- border: isSelected ? `1px solid ${SELECT_BORDER}` : '1px solid transparent',
- transition: 'all 0.15s ease',
- '&:hover': {
- bgcolor: isSelected ? SELECT_BG_HOVER : TINT_SUBTLE,
- },
- }}
- data-id={a.id}
- >
- e.stopPropagation()}
- sx={{
- display: 'flex',
- alignItems: 'center',
- color: 'text.disabled',
- flexShrink: 0,
- '&:hover': { color: 'text.secondary' },
- transition: 'color 0.15s',
- }}
- >
-
-
-
-
-
- e.stopPropagation()} sx={{ flex: 1, minWidth: 0 }}>
-
- this.setState((prev) => ({
- attrDrafts: { ...prev.attrDrafts, [a.id]: next },
- }))
- }
- onBlur={() => void this.handleRenameAttribute(a)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- event.target.blur();
- }
- if (event.key === 'Escape') {
- event.preventDefault();
- this.setState((prev) => ({
- attrDrafts: { ...prev.attrDrafts, [a.id]: a.name },
- }));
- event.target.blur();
- }
- }}
- sx={{ width: '100%' }}
- />
-
-
- void this.handleToggleAttributeExclusive(a, e)}
- aria-pressed={!a.exclusive}
- sx={{
- height: 22,
- fontSize: '0.72rem',
- fontWeight: 600,
- cursor: 'pointer',
- flexShrink: 0,
- ...(a.exclusive
- ? { bgcolor: 'grey.300', color: 'text.secondary' }
- : null),
- }}
- />
-
- void this.handleDeleteAttribute(a, e)}
- sx={{ flexShrink: 0 }}
- >
-
-
-
- );
- })}
-
-
-
-
- this.setState({ newAttrName: next })}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- void this.handleCreateAttribute();
- }
- }}
- sx={{ flex: 1 }}
- />
-
-
-
-
-
-
- {/* Tile 2: Werte für das gewählte Attribut */}
-
-
-
-
-
- {selectedAttr ? `Werte für Auswahl-Merkmal „${selectedAttr.name}“` : 'Werte'}
-
-
-
- {!selectedAttr ? (
+
+ {filteredAttributes.length === 0 ? (
- Kein Attribut ausgewählt.
+ {attrFilterTrimmed
+ ? `Keine Treffer für „${attrFilterTrimmed}“.`
+ : 'Noch keine Auswahl-Merkmale angelegt.'}
) : (
- <>
-
- {selectedValues.map((v) => (
-
-
-
-
-
-
- this.setState((prev) => ({
- valDrafts: { ...prev.valDrafts, [v.id]: next },
- }))
- }
- onBlur={() => void this.handleRenameValue(v)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- event.target.blur();
- }
- if (event.key === 'Escape') {
- event.preventDefault();
- this.setState((prev) => ({
- valDrafts: { ...prev.valDrafts, [v.id]: v.value },
- }));
- event.target.blur();
- }
- }}
- sx={{ flex: 1 }}
- />
- void this.handleDeleteValue(v)}
- >
-
-
-
- ))}
-
-
-
- this.setState({ newValText: next })}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- void this.handleCreateValue(selectedAttr.id);
- }
- }}
- sx={{ flex: 1 }}
- />
-
-
- >
+ filteredAttributes.map((a) => (
+ this.handleSelectAttribute(a.id)}
+ />
+ ))
)}
-
-
+
+
+
- {/* Tile 3: Merkmale (Freitext) */}
-
-
-
-
-
- Text-Merkmale
-
-
+ {/* Tile 2: Werte für das gewählte Auswahl-Merkmal */}
+
+
+
+
+
+ {selectedAttr ? `Werte für Auswahl-Merkmal „${selectedAttr.name}“` : 'Werte'}
+
+
-
- {properties.map((p) => (
-
-
-
-
-
-
- this.setState((prev) => ({
- propDrafts: { ...prev.propDrafts, [p.id]: next },
- }))
- }
- onBlur={() => void this.handleRenameProperty(p)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- event.target.blur();
- }
- if (event.key === 'Escape') {
- event.preventDefault();
- this.setState((prev) => ({
- propDrafts: { ...prev.propDrafts, [p.id]: p.name },
- }));
- event.target.blur();
- }
- }}
- sx={{ flex: 1 }}
- />
- void this.handleTogglePropertyExclusive(p, e)}
- aria-pressed={!p.exclusive}
- sx={{
- height: 22,
- fontSize: '0.72rem',
- fontWeight: 600,
- cursor: 'pointer',
- flexShrink: 0,
- ...(p.exclusive
- ? { bgcolor: 'grey.300', color: 'text.secondary' }
- : null),
- }}
- />
- void this.handleDeleteProperty(p)}
- >
-
-
-
- ))}
-
-
-
+ {!selectedAttr ? (
+
+ Kein Attribut ausgewählt.
+
+ ) : (
+ <>
this.setState({ newPropName: next })}
+ search
+ clear
+ value={valFilter}
+ disabled={valSubmitting}
+ placeholder="Suchen oder anlegen…"
+ onChange={(next) => this.setState({ valFilter: next })}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
- void this.handleCreateProperty();
+ void this.handleCreateValue();
}
}}
- sx={{ flex: 1 }}
/>
-
-
-
-
- >
+
+
+ {filteredValues.length === 0 ? (
+
+ {valFilterTrimmed
+ ? `Keine Treffer für „${valFilterTrimmed}“.`
+ : 'Noch keine Werte angelegt.'}
+
+ ) : (
+ filteredValues.map((v) => (
+
+ ))
+ )}
+
+ >
+ )}
+
+
+
+ {/* 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.'}
+
+ ) : (
+ filteredProperties.map((p) => (
+
+ ))
+ )}
+
+
+
+
);
}
}
+export {
+ ArtikelMerkmalAuswahl,
+ ArtikelMerkmalWert,
+ ArtikelMerkmalText,
+};
export default withNavigate(ArtikelMerkmalePage);