From 1b9c39e9759e89e0239bcb77387623836d8201a5 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 29 Sep 2026 18:26:30 +0200 Subject: [PATCH] u --- src/api/ApiClient.js | 57 + src/api/StorageManager.js | 12 + src/api/index.js | 137 +++ src/appRouter.jsx | 2 + src/automats/attributeAutomat.js | 190 +++ src/automats/index.js | 2 + src/automats/propertyAutomat.js | 129 ++ src/components/article/ArticleTileTab.jsx | 2 +- .../article/tabs/ArticleAttributesTab.jsx | 523 +++++++- src/nav.js | 8 + src/routes/artikel/merkmale.jsx | 1094 +++++++++++++++++ 11 files changed, 2150 insertions(+), 6 deletions(-) create mode 100644 src/automats/attributeAutomat.js create mode 100644 src/automats/propertyAutomat.js create mode 100644 src/routes/artikel/merkmale.jsx 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);