diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js
index 36064ca..04fa316 100644
--- a/src/api/ApiClient.js
+++ b/src/api/ApiClient.js
@@ -71,6 +71,48 @@ export class ApiClient {
delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`),
};
+ this.attributename = {
+ /** GET /api/attributename */
+ list: () => this.#json('GET', '/api/attributename'),
+ /** POST /api/attributename */
+ create: (body) => this.#json('POST', '/api/attributename', { body }),
+ /** PATCH /api/attributename/{id} */
+ update: (id, body) => this.#json('PATCH', `/api/attributename/${enc(id)}`, { body }),
+ /** DELETE /api/attributename/{id} */
+ delete: (id) => this.#json('DELETE', `/api/attributename/${enc(id)}`),
+ /** PUT /api/attributename/order */
+ order: (body) => this.#json('PUT', '/api/attributename/order', { body }),
+ };
+
+ this.attributevalue = {
+ /** GET /api/attributename/{attributeId}/value */
+ listByAttributeId: (attributeId) =>
+ this.#json('GET', `/api/attributename/${enc(attributeId)}/value`),
+ /** POST /api/attributename/{attributeId}/value */
+ create: (attributeId, body) =>
+ this.#json('POST', `/api/attributename/${enc(attributeId)}/value`, { body }),
+ /** PATCH /api/attributevalue/{id} */
+ update: (id, body) => this.#json('PATCH', `/api/attributevalue/${enc(id)}`, { body }),
+ /** DELETE /api/attributevalue/{id} */
+ delete: (id) => this.#json('DELETE', `/api/attributevalue/${enc(id)}`),
+ /** PUT /api/attributename/{attributeId}/value/order */
+ order: (attributeId, body) =>
+ this.#json('PUT', `/api/attributename/${enc(attributeId)}/value/order`, { body }),
+ };
+
+ this.propertyname = {
+ /** GET /api/propertyname */
+ list: () => this.#json('GET', '/api/propertyname'),
+ /** POST /api/propertyname */
+ create: (body) => this.#json('POST', '/api/propertyname', { body }),
+ /** PATCH /api/propertyname/{id} */
+ update: (id, body) => this.#json('PATCH', `/api/propertyname/${enc(id)}`, { body }),
+ /** DELETE /api/propertyname/{id} */
+ delete: (id) => this.#json('DELETE', `/api/propertyname/${enc(id)}`),
+ /** PUT /api/propertyname/order */
+ order: (body) => this.#json('PUT', '/api/propertyname/order', { body }),
+ };
+
this.supplier = {
/** GET /api/supplier → { suppliers: Supplier[] } */
list: () => this.#json('GET', '/api/supplier'),
@@ -425,6 +467,21 @@ export class ApiClient {
/** DELETE /api/article/{sku}/supplier/{id} */
deleteSupplier: (sku, id) =>
this.#empty('DELETE', `/api/article/${enc(sku)}/supplier/${enc(id)}`),
+ /** GET /api/article/{sku}/attributevalue */
+ getAttributeValues: (sku) =>
+ this.#json('GET', `/api/article/${enc(sku)}/attributevalue`),
+ /** PUT /api/article/{sku}/attributevalue */
+ setAttributeValues: (sku, body) =>
+ this.#json('PUT', `/api/article/${enc(sku)}/attributevalue`, { body }),
+ /** GET /api/article/{sku}/property */
+ getProperties: (sku) =>
+ this.#json('GET', `/api/article/${enc(sku)}/property`),
+ /** PUT /api/article/{sku}/property */
+ setProperties: (sku, body) =>
+ this.#json('PUT', `/api/article/${enc(sku)}/property`, { body }),
+ /** DELETE /api/article/{sku}/property/{propertyId} */
+ deleteProperty: (sku, propertyId) =>
+ this.#json('DELETE', `/api/article/${enc(sku)}/property/${enc(propertyId)}`),
};
this.posterminal = {
diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js
index 4fbbb94..412a64a 100644
--- a/src/api/StorageManager.js
+++ b/src/api/StorageManager.js
@@ -9,6 +9,8 @@ import authAutomat from '../automats/authAutomat';
import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat';
import posterminalAutomat from '../automats/posterminalAutomat';
import warehouseAutomat from '../automats/warehouseAutomat';
+import attributeAutomat from '../automats/attributeAutomat';
+import propertyAutomat from '../automats/propertyAutomat';
/**
@@ -683,6 +685,16 @@ export class StorageManager extends ApiClient {
console.error('[storageMgr] warehouse SSE refresh failed', err);
});
});
+ es.addEventListener('attributename', () => {
+ void attributeAutomat.actions.load().catch((err) => {
+ console.error('[storageMgr] attributename SSE refresh failed', err);
+ });
+ });
+ es.addEventListener('propertyname', () => {
+ void propertyAutomat.actions.load().catch((err) => {
+ console.error('[storageMgr] propertyname SSE refresh failed', err);
+ });
+ });
for (const sku of new Set([
...this.#articleWatchers.keys(),
...this.#historyWatchers.keys(),
diff --git a/src/api/index.js b/src/api/index.js
index b9ef5b8..513301a 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -432,6 +432,53 @@ export const article = {
body,
});
},
+ /**
+ * GET /api/article/:sku/attributevalue → { attributevalues: ArticleAttributeValue[] }
+ */
+ getAttributeValues(sku) {
+ return http(`/api/article/${encodeURIComponent(sku)}/attributevalue`);
+ },
+
+ /**
+ * PUT /api/article/:sku/attributevalue
+ * Body: { attributevalues: number[] | { attributeValueId: number, sort?: number }[] }
+ * → 200 { attributevalues: ArticleAttributeValue[] }
+ */
+ setAttributeValues(sku, attributevalues) {
+ return http(`/api/article/${encodeURIComponent(sku)}/attributevalue`, {
+ method: 'PUT',
+ body: { attributevalues },
+ });
+ },
+
+ /**
+ * GET /api/article/:sku/property → { properties: ArticleProperty[] }
+ */
+ getProperties(sku) {
+ return http(`/api/article/${encodeURIComponent(sku)}/property`);
+ },
+
+ /**
+ * PUT /api/article/:sku/property
+ * Body: { properties: { propertyId: number, value: string, sort?: number }[] }
+ * → 200 { properties: ArticleProperty[] }
+ */
+ setProperties(sku, properties) {
+ return http(`/api/article/${encodeURIComponent(sku)}/property`, {
+ method: 'PUT',
+ body: { properties },
+ });
+ },
+
+ /**
+ * DELETE /api/article/:sku/property/:propertyId → 200 { ok: true }
+ */
+ deleteProperty(sku, propertyId) {
+ return http(
+ `/api/article/${encodeURIComponent(sku)}/property/${encodeURIComponent(propertyId)}`,
+ { method: 'DELETE' }
+ );
+ },
};
/**
@@ -679,6 +726,93 @@ export const brand = {
},
};
+export const attributename = {
+ /** GET /api/attributename → { attributenames: Attributename[] } */
+ list() {
+ return http('/api/attributename');
+ },
+
+ /** POST /api/attributename → 201 { attributename } */
+ create(body) {
+ return http('/api/attributename', { method: 'POST', body });
+ },
+
+ /** PATCH /api/attributename/:id → 200 { attributename } */
+ update(id, body) {
+ return http(`/api/attributename/${encodeURIComponent(id)}`, { method: 'PATCH', body });
+ },
+
+ /** DELETE /api/attributename/:id → 200 */
+ delete(id) {
+ return http(`/api/attributename/${encodeURIComponent(id)}`, { method: 'DELETE' });
+ },
+
+ /** PUT /api/attributename/order → 200 */
+ order(attributenameIds) {
+ return http('/api/attributename/order', { method: 'PUT', body: { attributenameIds } });
+ },
+};
+
+export const attributevalue = {
+ /** GET /api/attributename/:attributeId/value → { values: AttributeValue[] } */
+ listByAttributeId(attributeId) {
+ return http(`/api/attributename/${encodeURIComponent(attributeId)}/value`);
+ },
+
+ /** POST /api/attributename/:attributeId/value → 201 { attributevalue } */
+ create(attributeId, body) {
+ return http(`/api/attributename/${encodeURIComponent(attributeId)}/value`, {
+ method: 'POST',
+ body,
+ });
+ },
+
+ /** PATCH /api/attributevalue/:id → 200 { attributevalue } */
+ update(id, body) {
+ return http(`/api/attributevalue/${encodeURIComponent(id)}`, { method: 'PATCH', body });
+ },
+
+ /** DELETE /api/attributevalue/:id → 200 */
+ delete(id) {
+ return http(`/api/attributevalue/${encodeURIComponent(id)}`, { method: 'DELETE' });
+ },
+
+ /** PUT /api/attributename/:attributeId/value/order → 200 */
+ order(attributeId, attributevalueIds) {
+ return http(`/api/attributename/${encodeURIComponent(attributeId)}/value/order`, {
+ method: 'PUT',
+ body: { attributevalueIds },
+ });
+ },
+};
+
+export const propertyname = {
+ /** GET /api/propertyname → { propertynames: Propertyname[] } */
+ list() {
+ return http('/api/propertyname');
+ },
+
+ /** POST /api/propertyname → 201 { propertyname } */
+ create(body) {
+ return http('/api/propertyname', { method: 'POST', body });
+ },
+
+ /** PATCH /api/propertyname/:id → 200 { propertyname } */
+ update(id, body) {
+ return http(`/api/propertyname/${encodeURIComponent(id)}`, { method: 'PATCH', body });
+ },
+
+ /** DELETE /api/propertyname/:id → 200 */
+ delete(id) {
+ return http(`/api/propertyname/${encodeURIComponent(id)}`, { method: 'DELETE' });
+ },
+
+ /** PUT /api/propertyname/order → 200 */
+ order(propertynameIds) {
+ return http('/api/propertyname/order', { method: 'PUT', body: { propertynameIds } });
+ },
+};
+
/**
* Company-scoped suppliers / Lieferanten (selectedCompanyId from session).
* Shape: Supplier = { id, name, createdAt, updatedAt }
@@ -1587,6 +1721,8 @@ export default {
auth,
article,
articleIndex,
+ attributename,
+ attributevalue,
brand,
category,
company,
@@ -1599,6 +1735,7 @@ export default {
order,
posterminal,
printer,
+ propertyname,
quote,
supplier,
thumb,
diff --git a/src/appRouter.jsx b/src/appRouter.jsx
index dd69701..dc7727b 100644
--- a/src/appRouter.jsx
+++ b/src/appRouter.jsx
@@ -8,6 +8,7 @@ import RedirectArtikelKategorien from './routes/artikel/RedirectArtikelKategorie
import ArtikelLagerPage from './routes/artikel/lager';
import ArtikelMarkenPage from './routes/artikel/marken';
import ArtikelVorlagenPage from './routes/artikel/vorlagen';
+import ArtikelMerkmalePage from './routes/artikel/merkmale';
import ArtikelSkuPage from './routes/artikel/_sku';
import LieferungListePage from './routes/lieferung';
import LieferungNeuPage from './routes/lieferung/neu';
@@ -56,6 +57,7 @@ const routes = [
{ path: 'lager', element: },
{ path: 'marken', element: },
{ path: 'vorlagen', element: },
+ { path: 'merkmale', element: },
{ path: ':sku/:tab?', element: },
],
},
diff --git a/src/automats/attributeAutomat.js b/src/automats/attributeAutomat.js
new file mode 100644
index 0000000..11c9df5
--- /dev/null
+++ b/src/automats/attributeAutomat.js
@@ -0,0 +1,190 @@
+import { Automat } from 'react-automat';
+import { attributename as attributenameApi, attributevalue as attributevalueApi } from '../api';
+import authAutomat from './authAutomat';
+
+/**
+ * @typedef {Object} AttributeValue
+ * @property {number} id
+ * @property {number} attributeId
+ * @property {string} value
+ * @property {number} sort
+ */
+
+/**
+ * @typedef {Object} AttributeName
+ * @property {number} id
+ * @property {string} name
+ * @property {number} sort
+ * @property {boolean} exclusive
+ * @property {AttributeValue[]} values
+ */
+
+/**
+ * @typedef {Object} AttributeState
+ * @property {AttributeName[]} attributes
+ * @property {'idle'|'loading'|'succeeded'|'failed'} status
+ * @property {string|null} error
+ */
+
+/** @type {AttributeState} */
+const initialState = {
+ attributes: [],
+ status: 'idle',
+ error: null,
+};
+
+function sortValues(values) {
+ if (!Array.isArray(values)) return [];
+ return [...values].sort((a, b) => {
+ const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
+ if (sDiff !== 0) return sDiff;
+ return (a.id ?? 0) - (b.id ?? 0);
+ });
+}
+
+function normalizeAttributes(attributes) {
+ if (!Array.isArray(attributes)) return [];
+ return [...attributes]
+ .map((attr) => ({
+ ...attr,
+ exclusive: Boolean(attr.exclusive),
+ values: sortValues(attr.values),
+ }))
+ .sort((a, b) => {
+ const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
+ if (sDiff !== 0) return sDiff;
+ return (a.id ?? 0) - (b.id ?? 0);
+ });
+}
+
+let attributeAutomat;
+
+const actions = {
+ /** Load all attribute names and their values for the selected company. */
+ async load() {
+ attributeAutomat.setState({ status: 'loading', error: null });
+ try {
+ const res = await attributenameApi.list();
+ const attributes = normalizeAttributes(res.attributenames);
+ attributeAutomat.setState({ attributes, status: 'succeeded' });
+ return attributes;
+ } catch (err) {
+ attributeAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
+ throw err;
+ }
+ },
+
+ /**
+ * Create an attribute name.
+ * @param {{ name: string, sort?: number, exclusive?: boolean }} body
+ */
+ async createAttribute(body) {
+ const res = await attributenameApi.create(body);
+ await actions.load();
+ return res.attributename;
+ },
+
+ /**
+ * Update an attribute name.
+ * @param {number} id
+ * @param {{ name?: string, sort?: number, exclusive?: boolean }} body
+ */
+ async updateAttribute(id, body) {
+ const res = await attributenameApi.update(id, body);
+ await actions.load();
+ return res.attributename;
+ },
+
+ /**
+ * Delete an attribute name.
+ * @param {number} id
+ */
+ async deleteAttribute(id) {
+ await attributenameApi.delete(id);
+ await actions.load();
+ },
+
+ /**
+ * Reorder attribute names.
+ * @param {number[]} attributenameIds
+ */
+ async reorderAttributes(attributenameIds) {
+ await attributenameApi.order(attributenameIds);
+ await actions.load();
+ },
+
+ /**
+ * Create a value for an attribute.
+ * @param {number} attributeId
+ * @param {{ value: string, sort?: number }} body
+ */
+ async createValue(attributeId, body) {
+ const res = await attributevalueApi.create(attributeId, body);
+ await actions.load();
+ return res.attributevalue;
+ },
+
+ /**
+ * Update an attribute value.
+ * @param {number} id
+ * @param {{ value?: string, sort?: number }} body
+ */
+ async updateValue(id, body) {
+ const res = await attributevalueApi.update(id, body);
+ await actions.load();
+ return res.attributevalue;
+ },
+
+ /**
+ * Delete an attribute value.
+ * @param {number} id
+ */
+ async deleteValue(id) {
+ await attributevalueApi.delete(id);
+ await actions.load();
+ },
+
+ /**
+ * Reorder values of an attribute.
+ * @param {number} attributeId
+ * @param {number[]} attributevalueIds
+ */
+ async reorderValues(attributeId, attributevalueIds) {
+ await attributevalueApi.order(attributeId, attributevalueIds);
+ await actions.load();
+ },
+
+ /** Reset to idle state (called on logout / company switch). */
+ reset() {
+ attributeAutomat.setState(initialState);
+ },
+};
+
+/**
+ * Global attribute state container.
+ * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
+ *
+ * @type {Automat}
+ */
+attributeAutomat = new Automat(initialState, actions, { name: 'attribute' });
+
+// Selectors
+export function selectAttributes() {
+ return attributeAutomat.state.attributes;
+}
+
+export function selectAttributeStatus() {
+ return attributeAutomat.state.status;
+}
+
+// Subscribe to auth changes
+authAutomat.subscribe((auth) => {
+ const companyId = auth.user?.selectedCompanyId;
+ if (auth.status === 'authed' && companyId) {
+ void attributeAutomat.actions.load();
+ } else if (auth.status === 'anon') {
+ attributeAutomat.actions.reset();
+ }
+});
+
+export default attributeAutomat;
diff --git a/src/automats/index.js b/src/automats/index.js
index 0160b02..248ee70 100644
--- a/src/automats/index.js
+++ b/src/automats/index.js
@@ -45,6 +45,8 @@ export {
export { default as brandAutomat } from './brandAutomat';
export { default as unitAutomat } from './unitAutomat';
+export { default as attributeAutomat, selectAttributes, selectAttributeStatus } from './attributeAutomat';
+export { default as propertyAutomat, selectProperties, selectPropertyStatus } from './propertyAutomat';
export { default as posterminalAutomat } from './posterminalAutomat';
export {
diff --git a/src/automats/propertyAutomat.js b/src/automats/propertyAutomat.js
new file mode 100644
index 0000000..9a816d9
--- /dev/null
+++ b/src/automats/propertyAutomat.js
@@ -0,0 +1,129 @@
+import { Automat } from 'react-automat';
+import { propertyname as propertynameApi } from '../api';
+import authAutomat from './authAutomat';
+
+/**
+ * @typedef {Object} PropertyName
+ * @property {number} id
+ * @property {string} name
+ * @property {number} sort
+ */
+
+/**
+ * @typedef {Object} PropertyState
+ * @property {PropertyName[]} properties
+ * @property {'idle'|'loading'|'succeeded'|'failed'} status
+ * @property {string|null} error
+ */
+
+/** @type {PropertyState} */
+const initialState = {
+ properties: [],
+ status: 'idle',
+ error: null,
+};
+
+function normalizeProperties(properties) {
+ if (!Array.isArray(properties)) return [];
+ return [...properties]
+ .map((p) => ({
+ ...p,
+ exclusive: Boolean(p.exclusive),
+ }))
+ .sort((a, b) => {
+ const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
+ if (sDiff !== 0) return sDiff;
+ return (a.id ?? 0) - (b.id ?? 0);
+ });
+}
+
+let propertyAutomat;
+
+const actions = {
+ /** Load all property names for the selected company. */
+ async load() {
+ propertyAutomat.setState({ status: 'loading', error: null });
+ try {
+ const res = await propertynameApi.list();
+ const properties = normalizeProperties(res.propertynames);
+ propertyAutomat.setState({ properties, status: 'succeeded' });
+ return properties;
+ } catch (err) {
+ propertyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
+ throw err;
+ }
+ },
+
+ /**
+ * Create a property name.
+ * @param {{ name: string, sort?: number, exclusive?: boolean }} body
+ */
+ async createProperty(body) {
+ const res = await propertynameApi.create(body);
+ await actions.load();
+ return res.propertyname;
+ },
+
+ /**
+ * Update a property name.
+ * @param {number} id
+ * @param {{ name?: string, sort?: number, exclusive?: boolean }} body
+ */
+ async updateProperty(id, body) {
+ const res = await propertynameApi.update(id, body);
+ await actions.load();
+ return res.propertyname;
+ },
+
+ /**
+ * Delete a property name.
+ * @param {number} id
+ */
+ async deleteProperty(id) {
+ await propertynameApi.delete(id);
+ await actions.load();
+ },
+
+ /**
+ * Reorder property names.
+ * @param {number[]} propertynameIds
+ */
+ async reorderProperties(propertynameIds) {
+ await propertynameApi.order(propertynameIds);
+ await actions.load();
+ },
+
+ /** Reset to idle state (called on logout / company switch). */
+ reset() {
+ propertyAutomat.setState(initialState);
+ },
+};
+
+/**
+ * Global property state container.
+ * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
+ *
+ * @type {Automat}
+ */
+propertyAutomat = new Automat(initialState, actions, { name: 'property' });
+
+// Selectors
+export function selectProperties() {
+ return propertyAutomat.state.properties;
+}
+
+export function selectPropertyStatus() {
+ return propertyAutomat.state.status;
+}
+
+// Subscribe to auth changes
+authAutomat.subscribe((auth) => {
+ const companyId = auth.user?.selectedCompanyId;
+ if (auth.status === 'authed' && companyId) {
+ void propertyAutomat.actions.load();
+ } else if (auth.status === 'anon') {
+ propertyAutomat.actions.reset();
+ }
+});
+
+export default propertyAutomat;
diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx
index 5dfad09..ce46d43 100644
--- a/src/components/article/ArticleTileTab.jsx
+++ b/src/components/article/ArticleTileTab.jsx
@@ -184,7 +184,7 @@ class ArticleTileTab extends PureComponent {
case ARTICLE_TAB.label:
return ;
case ARTICLE_TAB.attributes:
- return ;
+ return ;
case ARTICLE_TAB.compound:
return ;
case ARTICLE_TAB.settings:
diff --git a/src/components/article/tabs/ArticleAttributesTab.jsx b/src/components/article/tabs/ArticleAttributesTab.jsx
index 6357848..0436332 100644
--- a/src/components/article/tabs/ArticleAttributesTab.jsx
+++ b/src/components/article/tabs/ArticleAttributesTab.jsx
@@ -1,12 +1,525 @@
-import { Component } from 'react';
+import { PureComponent } from 'react';
+import Box from '@mui/material/Box';
+import CircularProgress from '@mui/material/CircularProgress';
+import IconButton from '@mui/material/IconButton';
+import MenuItem from '@mui/material/MenuItem';
+import Select from '@mui/material/Select';
+import FormControl from '@mui/material/FormControl';
+import InputLabel from '@mui/material/InputLabel';
+import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
+import Chip from '@mui/material/Chip';
+import Tooltip from '@mui/material/Tooltip';
+import AddIcon from '@mui/icons-material/Add';
+import { notifyError } from '../../../utils/notify';
+import { enqueueSnackbar } from 'notistack';
+import { StorageManagerContext } from '../../../api/storageManagerContext';
+import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
+import propertyAutomat, { selectProperties } from '../../../automats/propertyAutomat';
+import { article as articleApi } from '../../../api';
+
+export default class ArticleAttributesTab extends PureComponent {
+ static contextType = StorageManagerContext;
+
+ constructor(props) {
+ super(props);
+ const initialAttributes = selectAttributes() || [];
+ const initialProperties = selectProperties() || [];
+
+ const firstAttr = initialAttributes[0];
+ const initialKey = firstAttr
+ ? `attr-${firstAttr.id}`
+ : initialProperties[0]
+ ? `prop-${initialProperties[0].id}`
+ : '';
+
+ this.state = {
+ loading: true,
+ saving: false,
+ assignedAttributes: [], // ArticleAttributeValue[]
+ assignedProperties: [], // ArticleProperty[]
+ globalAttributes: initialAttributes,
+ globalProperties: initialProperties,
+
+ selectedMerkmalKey: initialKey,
+ selectedItemId: firstAttr?.values?.[0]?.id ?? '',
+ customValueText: '',
+ };
+ this._cancelled = false;
+ }
+
+ componentDidMount() {
+ this._cancelled = false;
+ this.unsubAttr = attributeAutomat.subscribe((state) => {
+ if (this._cancelled) return;
+ this.setState((prev) => {
+ let nextKey = prev.selectedMerkmalKey;
+ let nextItemId = prev.selectedItemId;
+
+ if (!nextKey) {
+ const first = state.attributes[0];
+ if (first) {
+ nextKey = `attr-${first.id}`;
+ nextItemId = first.values?.[0]?.id ?? '';
+ }
+ }
+ return {
+ globalAttributes: state.attributes,
+ selectedMerkmalKey: nextKey,
+ selectedItemId: nextItemId,
+ };
+ });
+ });
+
+ this.unsubProp = propertyAutomat.subscribe((state) => {
+ if (this._cancelled) return;
+ this.setState((prev) => {
+ let nextKey = prev.selectedMerkmalKey;
+ if (!nextKey && !prev.globalAttributes.length && state.properties[0]) {
+ nextKey = `prop-${state.properties[0].id}`;
+ }
+ return {
+ globalProperties: state.properties,
+ selectedMerkmalKey: nextKey,
+ };
+ });
+ });
+
+ if (attributeAutomat.state.status === 'idle') {
+ void attributeAutomat.actions.load();
+ }
+ if (propertyAutomat.state.status === 'idle') {
+ void propertyAutomat.actions.load();
+ }
+
+ void this.loadArticleData(this.props.sku);
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.sku !== this.props.sku) {
+ void this.loadArticleData(this.props.sku);
+ }
+ }
+
+ componentWillUnmount() {
+ this._cancelled = true;
+ this.unsubAttr?.();
+ this.unsubProp?.();
+ }
+
+ async loadArticleData(sku) {
+ if (!sku) return;
+ this.setState({ loading: true });
+ try {
+ const api = this.context?.article || articleApi;
+ const [attrRes, propRes] = await Promise.all([
+ api.getAttributeValues(sku),
+ api.getProperties(sku),
+ ]);
+ if (this._cancelled) return;
+
+ this.setState({
+ assignedAttributes: Array.isArray(attrRes?.attributevalues) ? attrRes.attributevalues : [],
+ assignedProperties: Array.isArray(propRes?.properties) ? propRes.properties : [],
+ loading: false,
+ });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ loading: false });
+ }
+ }
+
+ handleMerkmalChange = (event) => {
+ const key = event.target.value;
+ const { globalAttributes } = this.state;
+
+ let nextItemId = '';
+ if (key.startsWith('attr-')) {
+ const attrId = Number(key.replace('attr-', ''));
+ const attr = globalAttributes.find((a) => a.id === attrId);
+ nextItemId = attr?.values?.[0]?.id ?? '';
+ }
+
+ this.setState({
+ selectedMerkmalKey: key,
+ selectedItemId: nextItemId,
+ customValueText: '',
+ });
+ };
+
+ handleItemChange = (event) => {
+ this.setState({
+ selectedItemId: Number(event.target.value),
+ });
+ };
+
+ handleAddItem = async () => {
+ const { sku } = this.props;
+ const {
+ selectedMerkmalKey,
+ selectedItemId,
+ customValueText,
+ assignedAttributes,
+ assignedProperties,
+ globalAttributes,
+ globalProperties,
+ saving,
+ } = this.state;
+
+ if (saving || !sku || !selectedMerkmalKey) return;
+ const api = this.context?.article || articleApi;
+
+ if (selectedMerkmalKey.startsWith('attr-')) {
+ const attrId = Number(selectedMerkmalKey.replace('attr-', ''));
+ const targetAttr = globalAttributes.find((a) => a.id === attrId);
+ if (!targetAttr || !selectedItemId) return;
+
+ const isExclusive = Boolean(targetAttr.exclusive);
+ const targetValId = Number(selectedItemId);
+ const isAlreadyAssigned = assignedAttributes.some((a) => a.attributeValueId === targetValId);
+
+ if (isAlreadyAssigned) {
+ enqueueSnackbar('Bereits zugeordnet.', { variant: 'info' });
+ return;
+ }
+
+ let nextValues = [];
+ if (isExclusive) {
+ const others = assignedAttributes.filter((a) => a.attributeId !== targetAttr.id);
+ nextValues = [...others.map((a) => a.attributeValueId), targetValId];
+ } else {
+ nextValues = [...assignedAttributes.map((a) => a.attributeValueId), targetValId];
+ }
+
+ this.setState({ saving: true });
+ try {
+ const res = await api.setAttributeValues(sku, nextValues);
+ if (this._cancelled) return;
+ this.setState({
+ assignedAttributes: Array.isArray(res?.attributevalues) ? res.attributevalues : [],
+ saving: false,
+ });
+ enqueueSnackbar('Zugeordnet.', { variant: 'success' });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ saving: false });
+ }
+ } else if (selectedMerkmalKey.startsWith('prop-')) {
+ const propId = Number(selectedMerkmalKey.replace('prop-', ''));
+ const targetProp = globalProperties.find((p) => p.id === propId);
+ const val = customValueText.trim();
+ if (!targetProp || !val) return;
+
+ const isExclusive = Boolean(targetProp.exclusive);
+ if (!isExclusive) {
+ const isAlreadyAssigned = assignedProperties.some(
+ (p) => p.propertyId === propId && p.value.trim().toLowerCase() === val.toLowerCase()
+ );
+ if (isAlreadyAssigned) {
+ enqueueSnackbar('Bereits zugeordnet.', { variant: 'info' });
+ return;
+ }
+ }
+ let nextProps = [];
+ if (isExclusive) {
+ const existing = assignedProperties.filter((p) => p.propertyId !== propId);
+ nextProps = [
+ ...existing.map((p) => ({ propertyId: p.propertyId, value: p.value, sort: p.sort })),
+ { propertyId: propId, value: val },
+ ];
+ } else {
+ nextProps = [
+ ...assignedProperties.map((p) => ({ propertyId: p.propertyId, value: p.value, sort: p.sort })),
+ { propertyId: propId, value: val },
+ ];
+ }
+
+ this.setState({ saving: true });
+ try {
+ const res = await api.setProperties(sku, nextProps);
+ if (this._cancelled) return;
+ this.setState({
+ assignedProperties: Array.isArray(res?.properties) ? res.properties : [],
+ customValueText: '',
+ saving: false,
+ });
+ enqueueSnackbar('Zugeordnet.', { variant: 'success' });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ saving: false });
+ }
+ }
+ };
+
+ handleRemoveAttributeValue = async (attributeValueId) => {
+ const { sku } = this.props;
+ const { assignedAttributes, saving } = this.state;
+ if (saving || !sku) return;
+
+ const nextValues = assignedAttributes
+ .filter((a) => a.attributeValueId !== attributeValueId)
+ .map((a) => a.attributeValueId);
+
+ this.setState({ saving: true });
+ try {
+ const api = this.context?.article || articleApi;
+ const res = await api.setAttributeValues(sku, nextValues);
+ if (this._cancelled) return;
+ this.setState({
+ assignedAttributes: Array.isArray(res?.attributevalues) ? res.attributevalues : [],
+ saving: false,
+ });
+ enqueueSnackbar('Entfernt.', { variant: 'success' });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ saving: false });
+ }
+ };
+
+ handleRemoveProperty = async (rowId) => {
+ const { sku } = this.props;
+ const { assignedProperties, saving } = this.state;
+ if (saving || !sku) return;
+
+ const nextProperties = assignedProperties
+ .filter((p) => p.id !== rowId)
+ .map((p) => ({ propertyId: p.propertyId, value: p.value, sort: p.sort }));
+
+ this.setState({ saving: true });
+ try {
+ const api = this.context?.article || articleApi;
+ const res = await api.setProperties(sku, nextProperties);
+ if (this._cancelled) return;
+ this.setState({
+ assignedProperties: Array.isArray(res?.properties) ? res.properties : [],
+ saving: false,
+ });
+ enqueueSnackbar('Entfernt.', { variant: 'success' });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ saving: false });
+ }
+ };
-export default class ArticleAttributesTab extends Component {
render() {
+ const {
+ loading,
+ saving,
+ assignedAttributes,
+ assignedProperties,
+ globalAttributes,
+ globalProperties,
+ selectedMerkmalKey,
+ selectedItemId,
+ customValueText,
+ } = this.state;
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ // Unified list of selectable Merkmale
+ const merkmalOptions = [
+ ...globalAttributes.map((a) => ({
+ key: `attr-${a.id}`,
+ id: a.id,
+ type: 'attr',
+ name: a.name,
+ exclusive: a.exclusive,
+ values: a.values || [],
+ })),
+ ...globalProperties.map((p) => ({
+ key: `prop-${p.id}`,
+ id: p.id,
+ type: 'prop',
+ name: p.name,
+ exclusive: p.exclusive,
+ values: [],
+ })),
+ ];
+
+ const selectedOption = merkmalOptions.find((m) => m.key === selectedMerkmalKey) ?? null;
+ const isAttr = selectedOption?.type === 'attr';
+ const availableItems = selectedOption?.values || [];
+
+ // Unified list of assigned groups sorted by definition order
+ const assignedGroups = [];
+
+ for (const item of assignedAttributes) {
+ const attrDef = globalAttributes.find((a) => a.id === item.attributeId);
+ const name = attrDef?.name ?? item.attributeName ?? `Merkmal #${item.attributeId}`;
+ let group = assignedGroups.find((g) => g.key === `attr-${item.attributeId}`);
+ if (!group) {
+ group = {
+ key: `attr-${item.attributeId}`,
+ name,
+ sort: attrDef?.sort ?? 999,
+ items: [],
+ };
+ assignedGroups.push(group);
+ }
+ group.items.push({
+ id: item.id ?? item.attributeValueId,
+ value: item.value,
+ type: 'attr',
+ removeId: item.attributeValueId,
+ });
+ }
+
+ for (const item of assignedProperties) {
+ const propDef = globalProperties.find((p) => p.id === item.propertyId);
+ const name = propDef?.name ?? item.name ?? `Merkmal #${item.propertyId}`;
+ let group = assignedGroups.find((g) => g.key === `prop-${item.propertyId}`);
+ if (!group) {
+ group = {
+ key: `prop-${item.propertyId}`,
+ name,
+ sort: propDef?.sort ?? 999,
+ items: [],
+ };
+ assignedGroups.push(group);
+ }
+ group.items.push({
+ id: item.id,
+ value: item.value,
+ type: 'prop',
+ removeId: item.id,
+ });
+ }
+
+ assignedGroups.sort((a, b) => a.sort - b.sort || a.name.localeCompare(b.name));
+
return (
-
- Attribute und Merkmale
-
+
+ {/* Unified Add Bar: [Merkmal ▼] [Wert ▼ / Input] [+] */}
+
+
+ Merkmal
+
+
+
+ {isAttr ? (
+
+ Wert
+
+
+ ) : (
+ this.setState({ customValueText: e.target.value })}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ void this.handleAddItem();
+ }
+ }}
+ sx={{ minWidth: 170, bgcolor: 'background.paper' }}
+ />
+ )}
+
+
+
+ void this.handleAddItem()}
+ sx={{
+ bgcolor: 'primary.main',
+ color: 'primary.contrastText',
+ '&:hover': { bgcolor: 'primary.dark' },
+ '&.Mui-disabled': { bgcolor: 'action.disabledBackground', color: 'action.disabled' },
+ p: 0.85,
+ }}
+ >
+
+
+
+
+
+
+ {/* Unified Assigned Merkmale List */}
+ {assignedGroups.length > 0 && (
+
+ {assignedGroups.map((group) => (
+
+
+ {group.name}:
+
+
+ {group.items.map((item) => (
+ {
+ if (item.type === 'attr') {
+ void this.handleRemoveAttributeValue(item.removeId);
+ } else {
+ void this.handleRemoveProperty(item.removeId);
+ }
+ }}
+ disabled={saving}
+ color="primary"
+ variant="outlined"
+ />
+ ))}
+
+
+ ))}
+
+ )}
+
);
}
}
diff --git a/src/nav.js b/src/nav.js
index dd05cb8..7626d4d 100644
--- a/src/nav.js
+++ b/src/nav.js
@@ -16,6 +16,7 @@ import StorefrontIcon from '@mui/icons-material/Storefront';
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import LabelIcon from '@mui/icons-material/Label';
+import TuneIcon from '@mui/icons-material/Tune';
import { isKategorienPath } from './components/category/categoryRoute';
import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat';
@@ -159,6 +160,13 @@ export const SUB_NAV = {
icon: LabelIcon,
help: 'Druckvorlagen für Etiketten und Barcodes bearbeiten',
},
+ {
+ segment: 'merkmale',
+ label: 'Merkmale',
+ title: 'Merkmale & Attribute',
+ icon: TuneIcon,
+ help: 'Globale Merkmale, Attribute und Werte verwalten',
+ },
],
'/lieferung': [
{
diff --git a/src/routes/artikel/merkmale.jsx b/src/routes/artikel/merkmale.jsx
new file mode 100644
index 0000000..e274d48
--- /dev/null
+++ b/src/routes/artikel/merkmale.jsx
@@ -0,0 +1,1094 @@
+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 Tooltip from '@mui/material/Tooltip';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import Checkbox from '@mui/material/Checkbox';
+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';
+import Sortable from 'sortablejs';
+import { enqueueSnackbar } from 'notistack';
+import BackendError from '../../components/BackendError';
+import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
+import { PanelTile, PanelTileGrid } 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';
+
+function draftsFrom(items, key = 'name') {
+ return Object.fromEntries((items || []).map((row) => [row.id, row[key]]));
+}
+
+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,
+
+ properties: initialProperties,
+ propDrafts: draftsFrom(initialProperties, 'name'),
+ newPropName: '',
+ newPropExclusive: true,
+
+ attributes: initialAttributes,
+ attrDrafts: draftsFrom(initialAttributes, 'name'),
+ newAttrName: '',
+ newAttrExclusive: true,
+ selectedAttributeId: initialSelectedAttrId,
+
+ valDrafts: draftsFrom(
+ initialAttributes.find((a) => a.id === initialSelectedAttrId)?.values || [],
+ 'value'
+ ),
+ newValText: '',
+
+ busyKey: null,
+ };
+
+ this._cancelled = false;
+ this.propListRef = createRef();
+ this.attrListRef = createRef();
+ this.valListRef = createRef();
+
+ this._propSortable = null;
+ this._attrSortable = null;
+ this._valSortable = null;
+ }
+
+ componentDidMount() {
+ setLastArticleSku(null);
+ this._cancelled = false;
+
+ this.unsubProp = propertyAutomat.subscribe((state) => {
+ if (this._cancelled) return;
+ if (state.status === 'succeeded') {
+ this.applyProperties(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') {
+ this.applyAttributes(state.attributes);
+ } 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 && this.propListRef.current) {
+ this._initPropSortable();
+ }
+ if (prevState.attributes !== this.state.attributes && this.attrListRef.current) {
+ this._initAttrSortable();
+ }
+ if (
+ (prevState.selectedAttributeId !== this.state.selectedAttributeId ||
+ prevState.attributes !== this.state.attributes) &&
+ this.valListRef.current
+ ) {
+ 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;
+
+ this._propSortable = Sortable.create(el, {
+ handle: '.drag-handle',
+ animation: 150,
+ ghostClass: 'sortable-ghost',
+ chosenClass: 'sortable-chosen',
+ onEnd: (evt) => {
+ const { oldIndex, newIndex } = evt;
+ if (oldIndex === newIndex) return;
+
+ this.setState(
+ (prev) => {
+ const next = [...prev.properties];
+ const [moved] = next.splice(oldIndex, 1);
+ next.splice(newIndex, 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;
+
+ this._attrSortable = Sortable.create(el, {
+ handle: '.drag-handle',
+ animation: 150,
+ ghostClass: 'sortable-ghost',
+ chosenClass: 'sortable-chosen',
+ onEnd: (evt) => {
+ const { oldIndex, newIndex } = evt;
+ if (oldIndex === newIndex) return;
+
+ this.setState(
+ (prev) => {
+ const next = [...prev.attributes];
+ const [moved] = next.splice(oldIndex, 1);
+ next.splice(newIndex, 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;
+
+ this._valSortable = Sortable.create(el, {
+ handle: '.drag-handle',
+ animation: 150,
+ ghostClass: 'sortable-ghost',
+ chosenClass: 'sortable-chosen',
+ onEnd: (evt) => {
+ const { oldIndex, newIndex } = evt;
+ if (oldIndex === newIndex) 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(oldIndex, 1);
+ next.splice(newIndex, 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();
+ }
+ }
+
+ // --- 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',
+ 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();
+ };
+
+ // --- Property Actions ---
+
+ async handleCreateProperty() {
+ const name = this.state.newPropName.trim();
+ if (!name || this.state.busyKey != null) return;
+
+ this.setState({ busyKey: 'new-prop' });
+ try {
+ const row = await propertyAutomat.actions.createProperty({
+ name,
+ exclusive: this.state.newPropExclusive,
+ });
+ 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();
+ if (this.state.busyKey != null) return;
+
+ 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 });
+ }
+ }
+
+ 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 || this.state.busyKey != null) 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 (this.state.busyKey != null) return;
+ 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 || this.state.busyKey != null) return;
+
+ this.setState({ busyKey: 'new-attr' });
+ try {
+ const row = await attributeAutomat.actions.createAttribute({
+ name,
+ exclusive: this.state.newAttrExclusive,
+ });
+ if (this._cancelled) return;
+ this.setState({
+ newAttrName: '',
+ busyKey: null,
+ selectedAttributeId: row?.id ?? this.state.selectedAttributeId,
+ });
+ enqueueSnackbar(`Attribut „${row?.name || name}" angelegt.`, { variant: 'success' });
+ } catch (err) {
+ if (this._cancelled) return;
+ 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 || this.state.busyKey != null) return;
+
+ this.setState({ busyKey: `attr-${attr.id}` });
+ try {
+ await attributeAutomat.actions.updateAttribute(attr.id, { name: draft });
+ if (this._cancelled) return;
+ this.setState({ busyKey: null });
+ enqueueSnackbar('Attribut gespeichert.', { variant: 'success' });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState((prev) => ({
+ busyKey: null,
+ attrDrafts: { ...prev.attrDrafts, [attr.id]: attr.name },
+ }));
+ }
+ }
+
+ async handleToggleAttributeExclusive(attr, e) {
+ e?.stopPropagation();
+ if (this.state.busyKey != null) return;
+
+ const nextExclusive = !attr.exclusive;
+ this.setState({ busyKey: `attr-ex-${attr.id}` });
+ try {
+ await attributeAutomat.actions.updateAttribute(attr.id, { exclusive: nextExclusive });
+ if (this._cancelled) return;
+ this.setState({ busyKey: null });
+ enqueueSnackbar(
+ `Attribut „${attr.name}“ auf ${nextExclusive ? 'Einzelauswahl' : 'Mehrfachauswahl'} gestellt.`,
+ { variant: 'info' }
+ );
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ busyKey: null });
+ }
+ }
+
+ async handleDeleteAttribute(attr, e) {
+ e?.stopPropagation();
+ if (this.state.busyKey != null) return;
+ 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 || this.state.busyKey != null || !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 || this.state.busyKey != null) 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 (this.state.busyKey != null) return;
+ 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 });
+ }
+ }
+
+ render() {
+ const {
+ attrStatus,
+ propStatus,
+ error,
+ properties,
+ propDrafts,
+ newPropName,
+ newPropExclusive,
+ attributes,
+ attrDrafts,
+ newAttrName,
+ newAttrExclusive,
+ selectedAttributeId,
+ valDrafts,
+ newValText,
+ busyKey,
+ } = this.state;
+
+ const busy = busyKey != null;
+
+ if (attrStatus === 'loading' && propStatus === 'loading' && !properties.length && !attributes.length) {
+ return (
+
+
+
+ );
+ }
+
+ if (error && !properties.length && !attributes.length) {
+ return ;
+ }
+
+ const selectedAttr = attributes.find((a) => a.id === selectedAttributeId) ?? attributes[0] ?? null;
+ const selectedValues = selectedAttr?.values || [];
+
+ return (
+ <>
+ {/* SortableJS ghost / chosen styling from /artikel/lager */}
+
+
+
+ {/* Tile 1: Merkmale (Freitext) */}
+
+
+
+
+
+ Merkmale
+
+
+
+
+
+ Reihenfolge per Drag & Drop anpassen.
+
+
+
+ {properties.length === 0 ? (
+
+ Noch keine Merkmale angelegt.
+
+ ) : (
+ 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)}
+ sx={{
+ height: 22,
+ fontSize: '0.72rem',
+ fontWeight: 600,
+ cursor: 'pointer',
+ flexShrink: 0,
+ }}
+ />
+
+ void this.handleDeleteProperty(p)}
+ >
+
+
+
+ ))
+ )}
+
+
+
+
+ this.setState({ newPropName: next })}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ void this.handleCreateProperty();
+ }
+ }}
+ sx={{ flex: 1 }}
+ />
+
+
+ this.setState({ newPropExclusive: e.target.checked })}
+ sx={{ p: 0.5 }}
+ />
+ }
+ label={
+
+ Einzelauswahl (max. 1 Wert pro Artikel)
+
+ }
+ sx={{ ml: 0.5, mt: -0.5 }}
+ />
+
+
+
+
+ {/* Tile 2: Attribute (Auswahllisten) */}
+
+
+
+
+
+ Attribute
+
+
+
+
+
+ Reihenfolge per Drag & Drop anpassen.
+
+
+
+ {attributes.length === 0 ? (
+
+ Noch keine Attribute angelegt.
+
+ ) : (
+ 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 ? 'rgba(59, 130, 246, 0.12)' : 'transparent',
+ border: isSelected ? '1px solid rgba(59, 130, 246, 0.3)' : '1px solid transparent',
+ transition: 'all 0.15s ease',
+ '&:hover': {
+ bgcolor: isSelected ? 'rgba(59, 130, 246, 0.16)' : 'rgba(0, 0, 0, 0.04)',
+ },
+ }}
+ 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)}
+ sx={{
+ height: 22,
+ fontSize: '0.72rem',
+ fontWeight: 600,
+ cursor: 'pointer',
+ flexShrink: 0,
+ }}
+ />
+
+
+ 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 }}
+ />
+
+
+ this.setState({ newAttrExclusive: e.target.checked })}
+ sx={{ p: 0.5 }}
+ />
+ }
+ label={
+
+ Einzelauswahl (max. 1 Wert pro Artikel)
+
+ }
+ sx={{ ml: 0.5, mt: -0.5 }}
+ />
+
+
+
+
+ {/* Tile 3: Werte für das gewählte Attribut */}
+
+
+
+
+
+ {selectedAttr ? `Werte für „${selectedAttr.name}“` : 'Werte'}
+
+ {selectedAttr ? (
+
+ ) : null}
+
+
+
+ {selectedAttr
+ ? 'Reihenfolge per Drag & Drop anpassen.'
+ : 'Wähle links ein Attribut aus, um Werte zu bearbeiten.'}
+
+
+ {!selectedAttr ? (
+
+ Kein Attribut ausgewählt.
+
+ ) : (
+ <>
+
+ {selectedValues.length === 0 ? (
+
+ Noch keine Werte für „{selectedAttr.name}“ hinterlegt.
+
+ ) : (
+ 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 }}
+ />
+
+
+ >
+ )}
+
+
+
+ >
+ );
+ }
+}
+
+export default withNavigate(ArtikelMerkmalePage);