From 83dc69832211ee6e843c457d89f524b272a3b419 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 11 Oct 2026 04:21:26 +0200 Subject: [PATCH] u --- src/components/AppShell.jsx | 2 - src/components/PageMinimap.jsx | 19 +- src/components/article/tabs/ArticleSeoTab.jsx | 8 +- .../merkmale/ArtikelMerkmalAuswahl.jsx | 227 +++++ .../merkmale/ArtikelMerkmalText.jsx | 212 ++++ .../merkmale/ArtikelMerkmalWert.jsx | 163 +++ src/components/merkmale/index.js | 3 + src/routes/artikel/merkmale.jsx | 927 ++++++------------ 8 files changed, 890 insertions(+), 671 deletions(-) create mode 100644 src/components/merkmale/ArtikelMerkmalAuswahl.jsx create mode 100644 src/components/merkmale/ArtikelMerkmalText.jsx create mode 100644 src/components/merkmale/ArtikelMerkmalWert.jsx create mode 100644 src/components/merkmale/index.js 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);