u
This commit is contained in:
@@ -71,6 +71,48 @@ export class ApiClient {
|
|||||||
delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`),
|
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 = {
|
this.supplier = {
|
||||||
/** GET /api/supplier → { suppliers: Supplier[] } */
|
/** GET /api/supplier → { suppliers: Supplier[] } */
|
||||||
list: () => this.#json('GET', '/api/supplier'),
|
list: () => this.#json('GET', '/api/supplier'),
|
||||||
@@ -425,6 +467,21 @@ export class ApiClient {
|
|||||||
/** DELETE /api/article/{sku}/supplier/{id} */
|
/** DELETE /api/article/{sku}/supplier/{id} */
|
||||||
deleteSupplier: (sku, id) =>
|
deleteSupplier: (sku, id) =>
|
||||||
this.#empty('DELETE', `/api/article/${enc(sku)}/supplier/${enc(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 = {
|
this.posterminal = {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import authAutomat from '../automats/authAutomat';
|
|||||||
import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat';
|
import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat';
|
||||||
import posterminalAutomat from '../automats/posterminalAutomat';
|
import posterminalAutomat from '../automats/posterminalAutomat';
|
||||||
import warehouseAutomat from '../automats/warehouseAutomat';
|
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);
|
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([
|
for (const sku of new Set([
|
||||||
...this.#articleWatchers.keys(),
|
...this.#articleWatchers.keys(),
|
||||||
...this.#historyWatchers.keys(),
|
...this.#historyWatchers.keys(),
|
||||||
|
|||||||
137
src/api/index.js
137
src/api/index.js
@@ -432,6 +432,53 @@ export const article = {
|
|||||||
body,
|
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).
|
* Company-scoped suppliers / Lieferanten (selectedCompanyId from session).
|
||||||
* Shape: Supplier = { id, name, createdAt, updatedAt }
|
* Shape: Supplier = { id, name, createdAt, updatedAt }
|
||||||
@@ -1587,6 +1721,8 @@ export default {
|
|||||||
auth,
|
auth,
|
||||||
article,
|
article,
|
||||||
articleIndex,
|
articleIndex,
|
||||||
|
attributename,
|
||||||
|
attributevalue,
|
||||||
brand,
|
brand,
|
||||||
category,
|
category,
|
||||||
company,
|
company,
|
||||||
@@ -1599,6 +1735,7 @@ export default {
|
|||||||
order,
|
order,
|
||||||
posterminal,
|
posterminal,
|
||||||
printer,
|
printer,
|
||||||
|
propertyname,
|
||||||
quote,
|
quote,
|
||||||
supplier,
|
supplier,
|
||||||
thumb,
|
thumb,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import RedirectArtikelKategorien from './routes/artikel/RedirectArtikelKategorie
|
|||||||
import ArtikelLagerPage from './routes/artikel/lager';
|
import ArtikelLagerPage from './routes/artikel/lager';
|
||||||
import ArtikelMarkenPage from './routes/artikel/marken';
|
import ArtikelMarkenPage from './routes/artikel/marken';
|
||||||
import ArtikelVorlagenPage from './routes/artikel/vorlagen';
|
import ArtikelVorlagenPage from './routes/artikel/vorlagen';
|
||||||
|
import ArtikelMerkmalePage from './routes/artikel/merkmale';
|
||||||
import ArtikelSkuPage from './routes/artikel/_sku';
|
import ArtikelSkuPage from './routes/artikel/_sku';
|
||||||
import LieferungListePage from './routes/lieferung';
|
import LieferungListePage from './routes/lieferung';
|
||||||
import LieferungNeuPage from './routes/lieferung/neu';
|
import LieferungNeuPage from './routes/lieferung/neu';
|
||||||
@@ -56,6 +57,7 @@ const routes = [
|
|||||||
{ path: 'lager', element: <ArtikelLagerPage /> },
|
{ path: 'lager', element: <ArtikelLagerPage /> },
|
||||||
{ path: 'marken', element: <ArtikelMarkenPage /> },
|
{ path: 'marken', element: <ArtikelMarkenPage /> },
|
||||||
{ path: 'vorlagen', element: <ArtikelVorlagenPage /> },
|
{ path: 'vorlagen', element: <ArtikelVorlagenPage /> },
|
||||||
|
{ path: 'merkmale', element: <ArtikelMerkmalePage /> },
|
||||||
{ path: ':sku/:tab?', element: <ArtikelSkuPage /> },
|
{ path: ':sku/:tab?', element: <ArtikelSkuPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
190
src/automats/attributeAutomat.js
Normal file
190
src/automats/attributeAutomat.js
Normal 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;
|
||||||
@@ -45,6 +45,8 @@ export {
|
|||||||
|
|
||||||
export { default as brandAutomat } from './brandAutomat';
|
export { default as brandAutomat } from './brandAutomat';
|
||||||
export { default as unitAutomat } from './unitAutomat';
|
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 { default as posterminalAutomat } from './posterminalAutomat';
|
||||||
export {
|
export {
|
||||||
|
|||||||
129
src/automats/propertyAutomat.js
Normal file
129
src/automats/propertyAutomat.js
Normal 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;
|
||||||
@@ -184,7 +184,7 @@ class ArticleTileTab extends PureComponent {
|
|||||||
case ARTICLE_TAB.label:
|
case ARTICLE_TAB.label:
|
||||||
return <ArticleLabelTab article={articleData} />;
|
return <ArticleLabelTab article={articleData} />;
|
||||||
case ARTICLE_TAB.attributes:
|
case ARTICLE_TAB.attributes:
|
||||||
return <ArticleAttributesTab />;
|
return <ArticleAttributesTab article={articleData} sku={articleData.sku} />;
|
||||||
case ARTICLE_TAB.compound:
|
case ARTICLE_TAB.compound:
|
||||||
return <ArticleCompoundTab />;
|
return <ArticleCompoundTab />;
|
||||||
case ARTICLE_TAB.settings:
|
case ARTICLE_TAB.settings:
|
||||||
|
|||||||
@@ -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 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() {
|
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 (
|
return (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
Attribute und Merkmale
|
{/* Unified Add Bar: [Merkmal ▼] [Wert ▼ / Input] [+] */}
|
||||||
</Typography>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import StorefrontIcon from '@mui/icons-material/Storefront';
|
|||||||
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
||||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||||
import LabelIcon from '@mui/icons-material/Label';
|
import LabelIcon from '@mui/icons-material/Label';
|
||||||
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
import { isKategorienPath } from './components/category/categoryRoute';
|
import { isKategorienPath } from './components/category/categoryRoute';
|
||||||
import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat';
|
import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat';
|
||||||
|
|
||||||
@@ -159,6 +160,13 @@ export const SUB_NAV = {
|
|||||||
icon: LabelIcon,
|
icon: LabelIcon,
|
||||||
help: 'Druckvorlagen für Etiketten und Barcodes bearbeiten',
|
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': [
|
'/lieferung': [
|
||||||
{
|
{
|
||||||
|
|||||||
1094
src/routes/artikel/merkmale.jsx
Normal file
1094
src/routes/artikel/merkmale.jsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user