Compare commits

..

3 Commits

Author SHA1 Message Date
seb
59c3b901ae u 2026-10-12 01:52:45 +02:00
seb
afff1a948c u 2026-10-12 01:51:06 +02:00
seb
0a073f7cf4 u 2026-10-12 01:31:41 +02:00
8 changed files with 973 additions and 648 deletions

View File

@@ -117,6 +117,17 @@ const actions = {
await actions.load();
},
/**
* Delete multiple attribute names.
* @param {number[]} ids
*/
async deleteAttributes(ids) {
for (const id of ids) {
await attributenameApi.delete(id);
}
await actions.load();
},
/**
* Reorder attribute names.
* @param {number[]} attributenameIds
@@ -157,6 +168,17 @@ const actions = {
await actions.load();
},
/**
* Delete multiple attribute values.
* @param {number[]} ids
*/
async deleteValues(ids) {
for (const id of ids) {
await attributevalueApi.delete(id);
}
await actions.load();
},
/**
* Reorder values of an attribute.
* @param {number} attributeId

View File

@@ -113,6 +113,14 @@ const actions = {
await brandApi.delete(id);
updateStateWithBrands(brandAutomat.state.brands.filter((b) => b.id !== id));
},
async removeBrands(ids) {
const idSet = new Set(ids);
for (const id of ids) {
await brandApi.delete(id);
}
updateStateWithBrands(brandAutomat.state.brands.filter((b) => !idSet.has(b.id)));
await actions.load(true);
},
// #region addPic
async addImage(id, file) {
const res = await brandApi.addImage(id, file);

View File

@@ -97,6 +97,17 @@ const actions = {
await actions.load();
},
/**
* Delete multiple property names.
* @param {number[]} ids
*/
async deleteProperties(ids) {
for (const id of ids) {
await propertynameApi.delete(id);
}
await actions.load();
},
/**
* Reorder property names.
* @param {number[]} propertynameIds

View File

@@ -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 (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (error && !attributes.length) {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
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 (
<PanelTileGrid>
{/* Tile 1: Auswahl-Merkmale */}
<PanelTile wide={2} minHeight={0} color={BLUE_PANEL}>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<StyleIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, noWrap: true }}>
Auswahl-Merkmale
</Typography>
</Box>
<Button
size="small"
variant="outlined"
color="error"
disabled={unusedAttrCount === 0 || attrDeletingUnused || attrStatus !== 'ready'}
onClick={this.handleDeleteUnusedAttributes}
sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}
>
Alle ungenutzen löschen
</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={attrFilter}
disabled={attrSubmitting}
placeholder="Suchen oder anlegen…"
onChange={(next) => this.setState({ attrFilter: next })}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void this.handleCreateAttribute();
}
}}
/>
<Button
variant="contained"
disabled={!attrFilterTrimmed || attrSubmitting}
onClick={() => void this.handleCreateAttribute()}
>
Hinzufügen
</Button>
</Box>
{filteredAttributes.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{attrFilterTrimmed
? `Keine Treffer für „${attrFilterTrimmed}“.`
: 'Noch keine Auswahl-Merkmale angelegt.'}
</Typography>
) : (
<FillSpace
items={filteredAttributes}
totalCount={attributes.length}
minPageSize={8}
maxPageSize={8}
listRef={this.attrListRef}
onPageChange={(page) => this.setState({ attrPage: page })}
renderItem={(a) => (
<ArtikelMerkmalAuswahl
key={a.id}
attribute={a}
isSelected={selectedAttr?.id === a.id}
onSelect={() => this.handleSelectAttribute(a.id)}
/>
)}
/>
)}
</Stack>
</PanelTile>
{/* Tile 2: Werte für das gewählte Auswahl-Merkmal */}
<PanelTile wide={2} minHeight={0} color={PURPLE_PANEL}>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<LabelImportantIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, noWrap: true }}>
{selectedAttr ? `Werte für Auswahl-Merkmal „${selectedAttr.name}“` : 'Werte'}
</Typography>
</Box>
<Button
size="small"
variant="outlined"
color="error"
disabled={!selectedAttr || unusedValCount === 0 || valDeletingUnused || attrStatus !== 'ready'}
onClick={this.handleDeleteUnusedValues}
sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}
>
Alle ungenutzen löschen
</Button>
</Box>
{!selectedAttr ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
Kein Attribut ausgewählt.
</Typography>
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
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.handleCreateValue();
}
}}
/>
<Button
variant="contained"
disabled={!valFilterTrimmed || valSubmitting}
onClick={() => void this.handleCreateValue()}
>
Hinzufügen
</Button>
</Box>
{filteredValues.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{valFilterTrimmed
? `Keine Treffer für „${valFilterTrimmed}“.`
: 'Noch keine Werte angelegt.'}
</Typography>
) : (
<FillSpace
items={filteredValues}
totalCount={selectedValues.length}
minPageSize={8}
maxPageSize={8}
listRef={this.valListRef}
onPageChange={(page) => this.setState({ valPage: page })}
renderItem={(v) => (
<ArtikelMerkmalWert
key={v.id}
value={v}
/>
)}
/>
)}
</>
)}
</Stack>
</PanelTile>
</PanelTileGrid>
);
}
// #endregion
}
export default AuswahlMerkmaleTab;

View File

@@ -0,0 +1,311 @@
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 (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (error && !properties.length) {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
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 (
<PanelTileGrid>
{/* Tile 3: Text-Merkmale */}
<PanelTile wide={2} minHeight={0} color={ORANGE_PANEL}>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<TuneIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600, noWrap: true }}>
Text-Merkmale
</Typography>
</Box>
<Button
size="small"
variant="outlined"
color="error"
disabled={unusedPropCount === 0 || propDeletingUnused || propStatus !== 'ready'}
onClick={this.handleDeleteUnusedProperties}
sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}
>
Alle ungenutzen löschen
</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={propFilter}
disabled={propSubmitting}
placeholder="Suchen oder anlegen…"
onChange={(next) => this.setState({ propFilter: next })}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void this.handleCreateProperty();
}
}}
/>
<Button
variant="contained"
disabled={!propFilterTrimmed || propSubmitting}
onClick={() => void this.handleCreateProperty()}
>
Hinzufügen
</Button>
</Box>
<FillSpace
items={filteredProperties}
totalCount={properties.length}
minPageSize={8}
maxPageSize={8}
listRef={this.propListRef}
onPageChange={(page) => this.setState({ propPage: page })}
renderItem={ArtikelMerkmalText}
/>
</Stack>
</PanelTile>
</PanelTileGrid>
);
}
// #endregion
}
export default TextMerkmaleTab;

View File

@@ -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';

View File

@@ -22,16 +22,21 @@ import {
class ArtikelMarkenSearchField extends PureComponent {
constructor(props) {
super(props);
const brands = brandAutomat.state.brands || [];
this.state = {
input: brandAutomat.state.filter ?? '',
submitting: false,
deletingUnused: false,
unusedCount: brands.filter((b) => (b.articleCount ?? 0) === 0).length,
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleDeleteUnused = this.handleDeleteUnused.bind(this);
}
componentDidMount() {
this.unsub = brandAutomat.subscribe(this, (state) => ({
input: state.filter,
unusedCount: (state.brands || []).filter((b) => (b.articleCount ?? 0) === 0).length,
}));
}
@@ -60,9 +65,30 @@ class ArtikelMarkenSearchField extends PureComponent {
}
}
async handleDeleteUnused() {
if (this.state.deletingUnused) return;
const allBrands = brandAutomat.state.brands || [];
const unused = allBrands.filter((b) => (b.articleCount ?? 0) === 0);
if (!unused.length) return;
if (!window.confirm(`Alle ${unused.length} ungenutzten Marken wirklich löschen?`)) {
return;
}
this.setState({ deletingUnused: true });
try {
await brandAutomat.actions.removeBrands(unused.map((b) => b.id));
enqueueSnackbar(`${unused.length} ungenutzte Marken gelöscht.`, { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
this.setState({ deletingUnused: false });
}
}
render() {
const { inputRef } = this.props;
const { input, submitting } = this.state;
const { input, submitting, deletingUnused, unusedCount } = this.state;
const canSubmit = Boolean(input.trim()) && !submitting;
return (
@@ -89,6 +115,12 @@ class ArtikelMarkenSearchField extends PureComponent {
value={input}
disabled={submitting}
onChange={(next) => this.onInputChange(next)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void this.handleSubmit();
}
}}
/>
<Button
variant="contained"
@@ -97,6 +129,15 @@ class ArtikelMarkenSearchField extends PureComponent {
>
Hinzufügen
</Button>
<Button
variant="outlined"
color="error"
disabled={unusedCount === 0 || deletingUnused}
onClick={() => void this.handleDeleteUnused()}
sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}
>
Alle ungenutzen löschen
</Button>
</Box>
</PanelTileGrid>
);

View File

@@ -1,675 +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,
attrPage: 1,
selectedAttributeId: initialSelectedAttrId,
valFilter: '',
valSubmitting: false,
valPage: 1,
properties: initialProperties,
propFilter: '',
propSubmitting: false,
propPage: 1,
};
this._cancelled = false;
this.propListRef = createRef();
this.attrListRef = createRef();
this.valListRef = createRef();
this._propSortable = null;
this._attrSortable = null;
this._valSortable = null;
this.handleRetry = this.handleRetry.bind(this);
this.handleTabChange = this.handleTabChange.bind(this);
this.handleCreateAttribute = this.handleCreateAttribute.bind(this);
this.handleCreateValue = this.handleCreateValue.bind(this);
this.handleCreateProperty = this.handleCreateProperty.bind(this);
}
componentDidMount() {
setLastArticleSku(null);
this._cancelled = false;
this.unsubProp = propertyAutomat.subscribe((state) => {
if (this._cancelled) return;
if (state.status === 'succeeded') {
this.setState({
propStatus: 'ready',
error: null,
properties: state.properties,
});
} else if (state.status === 'failed') {
this.setState({ propStatus: 'failed', error: backendErrorFrom(state.error) });
}
});
this.unsubAttr = attributeAutomat.subscribe((state) => {
if (this._cancelled) return;
if (state.status === 'succeeded') {
let selectedId = this.state.selectedAttributeId;
if (!selectedId || !state.attributes.some((a) => a.id === selectedId)) {
selectedId = state.attributes[0]?.id ?? null;
}
this.setState({
attrStatus: 'ready',
error: null,
attributes: state.attributes,
selectedAttributeId: selectedId,
});
} else if (state.status === 'failed') {
this.setState({ attrStatus: 'failed', error: backendErrorFrom(state.error) });
}
});
void this.loadAll();
}
componentDidUpdate(prevProps, prevState) {
if (
(prevState.properties !== this.state.properties ||
prevState.propFilter !== this.state.propFilter ||
prevState.propPage !== this.state.propPage ||
prevState.propStatus !== this.state.propStatus) &&
this.propListRef.current
) {
if (this.state.propStatus === 'ready') {
this._initPropSortable();
}
}
if (
(prevState.attributes !== this.state.attributes ||
prevState.attrFilter !== this.state.attrFilter ||
prevState.attrPage !== this.state.attrPage ||
prevState.attrStatus !== this.state.attrStatus) &&
this.attrListRef.current
) {
if (this.state.attrStatus === 'ready') {
this._initAttrSortable();
}
}
if (
(prevState.selectedAttributeId !== this.state.selectedAttributeId ||
prevState.attributes !== this.state.attributes ||
prevState.valFilter !== this.state.valFilter ||
prevState.valPage !== this.state.valPage ||
prevState.attrStatus !== this.state.attrStatus) &&
this.valListRef.current
) {
if (this.state.attrStatus === 'ready') {
this._initValSortable();
}
}
}
componentWillUnmount() {
this._cancelled = true;
this.unsubProp?.();
this.unsubAttr?.();
this._destroySortables();
}
// --- SortableJS Initialization ---
_initPropSortable() {
this._propSortable?.destroy();
const el = this.propListRef.current;
if (!el) return;
const isFiltered = Boolean(this.state.propFilter?.trim());
this._propSortable = Sortable.create(el, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
disabled: isFiltered,
onEnd: (evt) => {
const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return;
const offset = ((this.state.propPage || 1) - 1) * 8;
const realOld = offset + oldIndex;
const realNew = offset + newIndex;
if (realOld === realNew) return;
this.setState(
(prev) => {
const next = [...prev.properties];
const [moved] = next.splice(realOld, 1);
next.splice(realNew, 0, moved);
return { properties: next };
},
() => {
const ids = this.state.properties.map((p) => p.id);
void this._persistPropOrder(ids);
}
);
},
});
}
_initAttrSortable() {
this._attrSortable?.destroy();
const el = this.attrListRef.current;
if (!el) return;
const isFiltered = Boolean(this.state.attrFilter?.trim());
this._attrSortable = Sortable.create(el, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
disabled: isFiltered,
onEnd: (evt) => {
const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return;
const offset = ((this.state.attrPage || 1) - 1) * 8;
const realOld = offset + oldIndex;
const realNew = offset + newIndex;
if (realOld === realNew) return;
this.setState(
(prev) => {
const next = [...prev.attributes];
const [moved] = next.splice(realOld, 1);
next.splice(realNew, 0, moved);
return { attributes: next };
},
() => {
const ids = this.state.attributes.map((a) => a.id);
void this._persistAttrOrder(ids);
}
);
},
});
}
_initValSortable() {
this._valSortable?.destroy();
const el = this.valListRef.current;
if (!el) return;
const isFiltered = Boolean(this.state.valFilter?.trim());
this._valSortable = Sortable.create(el, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
disabled: isFiltered,
onEnd: (evt) => {
const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return;
const offset = ((this.state.valPage || 1) - 1) * 8;
const realOld = offset + oldIndex;
const realNew = offset + newIndex;
if (realOld === realNew) return;
const currentAttr = this.state.attributes.find(
(a) => a.id === this.state.selectedAttributeId
);
if (!currentAttr) return;
const values = currentAttr.values || [];
const next = [...values];
const [moved] = next.splice(realOld, 1);
next.splice(realNew, 0, moved);
const updatedAttributes = this.state.attributes.map((a) =>
a.id === currentAttr.id ? { ...a, values: next } : a
);
this.setState({ attributes: updatedAttributes }, () => {
const ids = next.map((v) => v.id);
void this._persistValOrder(currentAttr.id, ids);
});
},
});
}
_destroySortables() {
this._propSortable?.destroy();
this._propSortable = null;
this._attrSortable?.destroy();
this._attrSortable = null;
this._valSortable?.destroy();
this._valSortable = null;
}
async _persistPropOrder(ids) {
try {
await propertyAutomat.actions.reorderProperties(ids);
} catch (err) {
if (this._cancelled) return;
notifyError(err);
void propertyAutomat.actions.load();
}
}
async _persistAttrOrder(ids) {
try {
await attributeAutomat.actions.reorderAttributes(ids);
} catch (err) {
if (this._cancelled) return;
notifyError(err);
void attributeAutomat.actions.load();
}
}
async _persistValOrder(attributeId, ids) {
try {
await attributeAutomat.actions.reorderValues(attributeId, ids);
} catch (err) {
if (this._cancelled) return;
notifyError(err);
void attributeAutomat.actions.load();
}
}
async loadAll() {
this.setState({
propStatus: propertyAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
attrStatus: attributeAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
error: null,
});
try {
await Promise.all([
propertyAutomat.actions.load(),
attributeAutomat.actions.load(),
]);
} catch (err) {
if (this._cancelled) return;
this.setState({ error: backendErrorFrom(err) });
}
}
handleRetry() {
void this.loadAll();
}
handleTabChange(nextTab) {
if (nextTab === 0) {
this._initAttrSortable();
this._initValSortable();
} else if (nextTab === 1) {
this._initPropSortable();
}
}
handleSelectAttribute(attrId) {
this.setState({ selectedAttributeId: attrId, valFilter: '', valPage: 1 });
}
async handleCreateAttribute() {
const name = this.state.attrFilter.trim();
if (!name || this.state.attrSubmitting) return;
this.setState({ attrSubmitting: true });
try {
const row = await attributeAutomat.actions.createAttribute({
name,
exclusive: true,
});
if (this._cancelled) return;
this.setState({
attrFilter: '',
attrSubmitting: false,
selectedAttributeId: row?.id ?? this.state.selectedAttributeId,
});
enqueueSnackbar(`Attribut „${row?.name || name}“ angelegt.`, { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
this.setState({ attrSubmitting: false });
notifyError(err);
}
}
async handleCreateValue() {
const { selectedAttributeId } = this.state;
const value = this.state.valFilter.trim();
if (!selectedAttributeId || !value || this.state.valSubmitting) return;
this.setState({ valSubmitting: true });
try {
await attributeAutomat.actions.createValue(selectedAttributeId, { value });
if (this._cancelled) return;
this.setState({ valFilter: '', valSubmitting: false });
enqueueSnackbar(`Wert „${value}“ angelegt.`, { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
this.setState({ valSubmitting: false });
notifyError(err);
}
}
async handleCreateProperty() {
const name = this.state.propFilter.trim();
if (!name || this.state.propSubmitting) return;
this.setState({ propSubmitting: true });
try {
const row = await propertyAutomat.actions.createProperty({
name,
exclusive: true,
});
if (this._cancelled) return;
this.setState({ propFilter: '', propSubmitting: false });
enqueueSnackbar(`Merkmal „${row?.name || name}“ angelegt.`, { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
this.setState({ propSubmitting: false });
notifyError(err);
}
}
render() {
const {
attrStatus,
propStatus,
error,
properties,
attributes,
selectedAttributeId,
attrFilter,
attrSubmitting,
valFilter,
valSubmitting,
propFilter,
propSubmitting,
} = this.state;
if (attrStatus === 'loading' && propStatus === 'loading' && !properties.length && !attributes.length) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (error && !properties.length && !attributes.length) {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
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 (
<TabbedPanelTileGrid onChange={this.handleTabChange}>
{/* Tab 1: Auswahl-Merkmale & Werte */}
<PanelTileGrid icon={<StyleIcon />} text="Auswahl-Merkmale">
{/* Tile 1: Auswahl-Merkmale */}
<PanelTile wide={2} minHeight={0} color={BLUE_PANEL}>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<StyleIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Auswahl-Merkmale
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={attrFilter}
disabled={attrSubmitting}
placeholder="Suchen oder anlegen…"
onChange={(next) => this.setState({ attrFilter: next })}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void this.handleCreateAttribute();
}
}}
/>
<Button
variant="contained"
disabled={!attrFilterTrimmed || attrSubmitting}
onClick={() => void this.handleCreateAttribute()}
>
Hinzufügen
</Button>
</Box>
{filteredAttributes.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{attrFilterTrimmed
? `Keine Treffer für „${attrFilterTrimmed}“.`
: 'Noch keine Auswahl-Merkmale angelegt.'}
</Typography>
) : (
<FillSpace
items={filteredAttributes}
totalCount={attributes.length}
minPageSize={8}
maxPageSize={8}
listRef={this.attrListRef}
onPageChange={(page) => this.setState({ attrPage: page })}
renderItem={(a) => (
<ArtikelMerkmalAuswahl
key={a.id}
attribute={a}
isSelected={selectedAttr?.id === a.id}
onSelect={() => this.handleSelectAttribute(a.id)}
/>
)}
/>
)}
</Stack>
</PanelTile>
{/* Tile 2: Werte für das gewählte Auswahl-Merkmal */}
<PanelTile wide={2} minHeight={0} color={PURPLE_PANEL}>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LabelImportantIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{selectedAttr ? `Werte für Auswahl-Merkmal „${selectedAttr.name}“` : 'Werte'}
</Typography>
</Box>
{!selectedAttr ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
Kein Attribut ausgewählt.
</Typography>
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
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.handleCreateValue();
}
}}
/>
<Button
variant="contained"
disabled={!valFilterTrimmed || valSubmitting}
onClick={() => void this.handleCreateValue()}
>
Hinzufügen
</Button>
</Box>
{filteredValues.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{valFilterTrimmed
? `Keine Treffer für „${valFilterTrimmed}“.`
: 'Noch keine Werte angelegt.'}
</Typography>
) : (
<FillSpace
items={filteredValues}
totalCount={selectedValues.length}
minPageSize={8}
maxPageSize={8}
listRef={this.valListRef}
onPageChange={(page) => this.setState({ valPage: page })}
renderItem={(v) => (
<ArtikelMerkmalWert
key={v.id}
value={v}
/>
)}
/>
)}
</>
)}
</Stack>
</PanelTile>
</PanelTileGrid>
{/* Tab 2: Text-Merkmale */}
<PanelTileGrid icon={<TuneIcon />} text="Text-Merkmale">
{/* Tile 3: Text-Merkmale */}
<PanelTile wide={2} minHeight={0} color={ORANGE_PANEL}>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TuneIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Text-Merkmale
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={propFilter}
disabled={propSubmitting}
placeholder="Suchen oder anlegen…"
onChange={(next) => this.setState({ propFilter: next })}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void this.handleCreateProperty();
}
}}
/>
<Button
variant="contained"
disabled={!propFilterTrimmed || propSubmitting}
onClick={() => void this.handleCreateProperty()}
>
Hinzufügen
</Button>
</Box>
{filteredProperties.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{propFilterTrimmed
? `Keine Treffer für „${propFilterTrimmed}“.`
: 'Noch keine Text-Merkmale angelegt.'}
</Typography>
) : (
<FillSpace
items={filteredProperties}
totalCount={properties.length}
minPageSize={8}
maxPageSize={8}
listRef={this.propListRef}
onPageChange={(page) => this.setState({ propPage: page })}
renderItem={(p) => (
<ArtikelMerkmalText
key={p.id}
property={p}
/>
)}
/>
)}
</Stack>
</PanelTile>
</PanelTileGrid>
<TabbedPanelTileGrid>
<AuswahlMerkmaleTab icon={<StyleIcon />} text="Auswahl-Merkmale" />
<TextMerkmaleTab icon={<TuneIcon />} text="Text-Merkmale" />
</TabbedPanelTileGrid>
);
}
}
// #endregion
// #region Exports
export {
AuswahlMerkmaleTab,
TextMerkmaleTab,
ArtikelMerkmalAuswahl,
ArtikelMerkmalWert,
ArtikelMerkmalText,
};
export default withNavigate(ArtikelMerkmalePage);
// #endregion