This commit is contained in:
seb
2026-09-29 18:26:30 +02:00
parent 43e1ab98b7
commit 1b9c39e975
11 changed files with 2150 additions and 6 deletions

View File

@@ -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 = {

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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: <ArtikelLagerPage /> },
{ path: 'marken', element: <ArtikelMarkenPage /> },
{ path: 'vorlagen', element: <ArtikelVorlagenPage /> },
{ path: 'merkmale', element: <ArtikelMerkmalePage /> },
{ path: ':sku/:tab?', element: <ArtikelSkuPage /> },
],
},

View File

@@ -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<AttributeState>}
*/
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;

View File

@@ -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 {

View File

@@ -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<PropertyState>}
*/
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;

View File

@@ -184,7 +184,7 @@ class ArticleTileTab extends PureComponent {
case ARTICLE_TAB.label:
return <ArticleLabelTab article={articleData} />;
case ARTICLE_TAB.attributes:
return <ArticleAttributesTab />;
return <ArticleAttributesTab article={articleData} sku={articleData.sku} />;
case ARTICLE_TAB.compound:
return <ArticleCompoundTab />;
case ARTICLE_TAB.settings:

View File

@@ -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 (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={24} />
</Box>
);
}
// 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 (
<Typography variant="body2" color="text.secondary">
Attribute und Merkmale
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Unified Add Bar: [Merkmal ▼] [Wert ▼ / Input] [+] */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControl size="small" sx={{ minWidth: 170 }}>
<InputLabel id="merkmal-select-label">Merkmal</InputLabel>
<Select
labelId="merkmal-select-label"
value={selectedMerkmalKey}
label="Merkmal"
disabled={saving || merkmalOptions.length === 0}
onChange={this.handleMerkmalChange}
sx={{ bgcolor: 'background.paper' }}
>
{merkmalOptions.map((m) => (
<MenuItem key={m.key} value={m.key}>
{m.name}
</MenuItem>
))}
</Select>
</FormControl>
{isAttr ? (
<FormControl size="small" sx={{ minWidth: 170 }}>
<InputLabel id="item-select-label">Wert</InputLabel>
<Select
labelId="item-select-label"
value={selectedItemId}
label="Wert"
disabled={saving || availableItems.length === 0}
onChange={this.handleItemChange}
sx={{ bgcolor: 'background.paper' }}
>
{availableItems.length === 0 ? (
<MenuItem value="" disabled>
(Keine Werte)
</MenuItem>
) : (
availableItems.map((val) => (
<MenuItem key={val.id} value={val.id}>
{val.value}
</MenuItem>
))
)}
</Select>
</FormControl>
) : (
<TextField
size="small"
placeholder="Wert..."
value={customValueText}
disabled={saving || !selectedMerkmalKey}
onChange={(e) => this.setState({ customValueText: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void this.handleAddItem();
}
}}
sx={{ minWidth: 170, bgcolor: 'background.paper' }}
/>
)}
<Tooltip title="Hinzufügen">
<span>
<IconButton
color="primary"
disabled={
saving ||
!selectedMerkmalKey ||
(isAttr ? !selectedItemId || availableItems.length === 0 : !customValueText.trim())
}
onClick={() => 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,
}}
>
<AddIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
{/* Unified Assigned Merkmale List */}
{assignedGroups.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{assignedGroups.map((group) => (
<Box key={group.key} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography
variant="body2"
sx={{ fontWeight: 600, minWidth: 100, color: 'text.secondary' }}
>
{group.name}:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{group.items.map((item) => (
<Chip
key={item.id}
label={item.value}
size="small"
onDelete={() => {
if (item.type === 'attr') {
void this.handleRemoveAttributeValue(item.removeId);
} else {
void this.handleRemoveProperty(item.removeId);
}
}}
disabled={saving}
color="primary"
variant="outlined"
/>
))}
</Box>
</Box>
))}
</Box>
)}
</Box>
);
}
}

View File

@@ -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': [
{

File diff suppressed because it is too large Load Diff