diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js
index 04fa316..c3f56ff 100644
--- a/src/api/ApiClient.js
+++ b/src/api/ApiClient.js
@@ -482,6 +482,21 @@ export class ApiClient {
/** DELETE /api/article/{sku}/property/{propertyId} */
deleteProperty: (sku, propertyId) =>
this.#json('DELETE', `/api/article/${enc(sku)}/property/${enc(propertyId)}`),
+ /** POST /api/article/{sku}/father */
+ toggleFather: (sku, body) =>
+ this.#json('POST', `/api/article/${enc(sku)}/father`, { body }),
+ /** GET /api/article/{sku}/children */
+ getChildren: (sku) =>
+ this.#json('GET', `/api/article/${enc(sku)}/children`),
+ /** POST /api/article/{sku}/children */
+ addChild: (sku, body) =>
+ this.#json('POST', `/api/article/${enc(sku)}/children`, { body }),
+ /** DELETE /api/article/{sku}/children/{childSku} */
+ removeChild: (sku, childSku, { deleteArticle = false } = {}) =>
+ this.#json(
+ 'DELETE',
+ `/api/article/${enc(sku)}/children/${enc(childSku)}${deleteArticle ? '?deleteArticle=true' : ''}`
+ ),
};
this.posterminal = {
diff --git a/src/api/index.js b/src/api/index.js
index 513301a..e82e6b2 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -479,6 +479,46 @@ export const article = {
{ method: 'DELETE' }
);
},
+
+ /**
+ * POST /api/article/:sku/father
+ * Body: { isFather: boolean, attributeNameId?: number }
+ */
+ toggleFather(sku, body) {
+ return http(`/api/article/${encodeURIComponent(sku)}/father`, {
+ method: 'POST',
+ body,
+ });
+ },
+
+ /**
+ * GET /api/article/:sku/children → { children: [...] }
+ */
+ getChildren(sku) {
+ return http(`/api/article/${encodeURIComponent(sku)}/children`);
+ },
+
+ /**
+ * POST /api/article/:sku/children
+ * Body: { attributeValueId?: number, value?: string, name?: string }
+ */
+ addChild(sku, body) {
+ return http(`/api/article/${encodeURIComponent(sku)}/children`, {
+ method: 'POST',
+ body,
+ });
+ },
+
+ /**
+ * DELETE /api/article/:sku/children/:childSku
+ */
+ removeChild(sku, childSku, options = {}) {
+ const qs = options?.deleteArticle ? '?deleteArticle=true' : '';
+ return http(
+ `/api/article/${encodeURIComponent(sku)}/children/${encodeURIComponent(childSku)}${qs}`,
+ { method: 'DELETE' }
+ );
+ },
};
/**
diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx
index ce46d43..5e615db 100644
--- a/src/components/article/ArticleTileTab.jsx
+++ b/src/components/article/ArticleTileTab.jsx
@@ -15,7 +15,6 @@ import {
ARTICLE_TAB,
DEFAULT_ARTICLE_TAB,
artikelDetailPath,
- articleShowsCompoundTab,
articleSku,
articleTabFromPathname,
readPreferredArticleTab,
@@ -27,7 +26,6 @@ import { matchPath } from 'react-router-dom';
import { PanelTile } from 'dashgui/panels';
import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from 'dashgui/panels/panelTileLayout';
import ArticleAttributesTab from './tabs/ArticleAttributesTab';
-import ArticleCompoundTab from './tabs/ArticleCompoundTab';
import ArticleHistoryTab from './tabs/ArticleHistoryTab';
import ArticleLabelTab from './tabs/ArticleLabelTab';
import ArticleSeoTab from './tabs/ArticleSeoTab';
@@ -73,14 +71,6 @@ const ALWAYS_TABS = [
},
];
-const COMPOUND_TAB = {
- id: ARTICLE_TAB.compound,
- label: 'Compound Editor',
- color: '#e9d5ff',
- Icon: AccountTreeIcon,
- help: 'Stückliste und Unterartikel für diesen Set-Artikel verwalten',
-};
-
const SETTINGS_TAB = {
id: ARTICLE_TAB.settings,
label: 'Einstellungen',
@@ -89,13 +79,8 @@ const SETTINGS_TAB = {
help: 'Artikeleinstellungen, Einheiten, Steuersatz und Löschen',
};
-function visibleTabs(article) {
- const tabs = [...ALWAYS_TABS];
- if (articleShowsCompoundTab(article)) {
- tabs.push(COMPOUND_TAB);
- }
- tabs.push(SETTINGS_TAB);
- return tabs;
+function visibleTabs() {
+ return [...ALWAYS_TABS, SETTINGS_TAB];
}
function readRouteTab() {
@@ -185,8 +170,6 @@ class ArticleTileTab extends PureComponent {
return ;
case ARTICLE_TAB.attributes:
return ;
- case ARTICLE_TAB.compound:
- return ;
case ARTICLE_TAB.settings:
return (
0 ||
- article?.optionsCount > 0 ||
- article?.childCount > 0 ||
- article?.fatherArticleId != null
- );
-}
-
-export function articleTabIds(article) {
- const ids = [...BASE_ARTICLE_TAB_IDS];
- if (articleShowsCompoundTab(article)) {
- ids.push(ARTICLE_TAB.compound);
- }
- ids.push(ARTICLE_TAB.settings);
- return ids;
+export function articleTabIds() {
+ return [...BASE_ARTICLE_TAB_IDS, ARTICLE_TAB.settings];
}
export function isArticleTabId(tab) {
diff --git a/src/components/article/boxes/ArticleViewLists.jsx b/src/components/article/boxes/ArticleViewLists.jsx
index 2bff3b7..6b85fd8 100644
--- a/src/components/article/boxes/ArticleViewLists.jsx
+++ b/src/components/article/boxes/ArticleViewLists.jsx
@@ -1,64 +1,729 @@
-import { Component, PureComponent } from 'react';
+import { PureComponent } from 'react';
import { Link as RouterLink } from 'react-router-dom';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Chip from '@mui/material/Chip';
+import CircularProgress from '@mui/material/CircularProgress';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogContentText from '@mui/material/DialogContentText';
+import DialogTitle from '@mui/material/DialogTitle';
+import FormControl from '@mui/material/FormControl';
+import IconButton from '@mui/material/IconButton';
+import InputLabel from '@mui/material/InputLabel';
import Link from '@mui/material/Link';
+import MenuItem from '@mui/material/MenuItem';
+import Select from '@mui/material/Select';
+import TextField from '@mui/material/TextField';
+import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
+import AddIcon from '@mui/icons-material/Add';
+import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
+import AccountTreeIcon from '@mui/icons-material/AccountTree';
+import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
+import LinkOffIcon from '@mui/icons-material/LinkOff';
import { PanelTile } from 'dashgui/panels';
+import { enqueueSnackbar } from 'notistack';
import { artikelDetailPath } from '../articleRoute';
-
-class CountLine extends Component {
- render() {
- const { label, count } = this.props;
- if (count == null) return null;
- return (
-
- {label}: {count}
-
- );
- }
-}
-
-class RelationLine extends Component {
- render() {
- const { label, active, children } = this.props;
- return (
-
- {label}:{' '}
- {active ? (
- children ?? 'ja'
- ) : (
-
- nein
-
- )}
-
- );
- }
-}
+import { article as articleApi } from '../../../api';
+import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
+import artikelAutomat from '../../../automats/artikelAutomat';
+import { notifyError } from '../../../utils/notify';
+import { StorageManagerContext } from '../../../api/storageManagerContext';
export default class ArticleViewLists extends PureComponent {
+ static contextType = StorageManagerContext;
+
+ constructor(props) {
+ super(props);
+ const initialAttributes = selectAttributes() || [];
+ this.state = {
+ attributes: initialAttributes,
+ assignedAttributes: [],
+ busy: false,
+ isActivating: false,
+ selectedAttributeId: '',
+ selectedChildValueId: '',
+ newChildText: '',
+ isCustomChildValue: false,
+
+ // Remove dialog
+ removeTargetChild: null, // { sku, attributeValue }
+ };
+ this._cancelled = false;
+ }
+
+ componentDidMount() {
+ this._cancelled = false;
+ this.unsubAttr = attributeAutomat.subscribe((state) => {
+ if (this._cancelled) return;
+ this.setState((prev) => {
+ const assignedIds = new Set(prev.assignedAttributes.map((a) => a.attributeId));
+ const selectable = state.attributes.filter((a) => !assignedIds.has(a.id));
+ let nextSelectedId = prev.selectedAttributeId;
+ if ((!nextSelectedId || assignedIds.has(Number(nextSelectedId))) && selectable[0]) {
+ nextSelectedId = selectable[0].id;
+ }
+ return {
+ attributes: state.attributes,
+ selectedAttributeId: nextSelectedId,
+ };
+ });
+ });
+
+ if (attributeAutomat.state.status === 'idle') {
+ void attributeAutomat.actions.load();
+ }
+
+ if (this.props.article?.sku) {
+ void this.loadAssignedAttributes(this.props.article.sku);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.article?.sku !== this.props.article?.sku) {
+ if (this.props.article?.sku) {
+ void this.loadAssignedAttributes(this.props.article.sku);
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ this._cancelled = true;
+ this.unsubAttr?.();
+ }
+
+ getApi() {
+ return this.context?.article || articleApi;
+ }
+
+ async loadAssignedAttributes(sku) {
+ if (!sku) return;
+ try {
+ const api = this.getApi();
+ const res = await api.getAttributeValues(sku);
+ if (this._cancelled) return;
+ const assigned = Array.isArray(res?.attributevalues) ? res.attributevalues : [];
+ this.setState((prev) => {
+ const assignedIds = new Set(assigned.map((a) => a.attributeId));
+ const selectable = prev.attributes.filter((a) => !assignedIds.has(a.id));
+ let nextSelectedId = prev.selectedAttributeId;
+ if ((!nextSelectedId || assignedIds.has(Number(nextSelectedId))) && selectable[0]) {
+ nextSelectedId = selectable[0].id;
+ } else if (selectable.length === 0) {
+ nextSelectedId = '';
+ }
+ return {
+ assignedAttributes: assigned,
+ selectedAttributeId: nextSelectedId,
+ };
+ });
+ } catch {
+ // Ignore
+ }
+ }
+
+ handleStartActivating = async () => {
+ this.setState({ isActivating: true });
+ await this.loadAssignedAttributes(this.props.article?.sku);
+ };
+
+ handleEnableFather = async () => {
+ const { article } = this.props;
+ const { selectedAttributeId, assignedAttributes, busy } = this.state;
+ if (busy || !article?.sku || !selectedAttributeId) return;
+
+ const assignedIds = new Set(assignedAttributes.map((a) => a.attributeId));
+ if (assignedIds.has(Number(selectedAttributeId))) {
+ enqueueSnackbar('Dieses Merkmal ist dem Artikel bereits zugewiesen.', { variant: 'warning' });
+ return;
+ }
+
+ this.setState({ busy: true });
+ try {
+ const api = this.getApi();
+ const res = await api.toggleFather(article.sku, {
+ isFather: true,
+ attributeNameId: Number(selectedAttributeId),
+ });
+ if (this._cancelled) return;
+
+ if (res?.article) {
+ artikelAutomat.actions.setArticle(res.article);
+ } else {
+ await artikelAutomat.actions.reloadArticle(article.sku);
+ }
+
+ enqueueSnackbar('Vaterartikel-Modus aktiviert.', { variant: 'success' });
+ this.setState({ busy: false, isActivating: false });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ busy: false });
+ }
+ };
+
+ handleDisableFather = async () => {
+ const { article } = this.props;
+ const { busy } = this.state;
+ if (busy || !article?.sku) return;
+
+ this.setState({ busy: true });
+ try {
+ const api = this.getApi();
+ const res = await api.toggleFather(article.sku, { isFather: false });
+ if (this._cancelled) return;
+
+ if (res?.article) {
+ artikelAutomat.actions.setArticle(res.article);
+ } else {
+ await artikelAutomat.actions.reloadArticle(article.sku);
+ }
+
+ enqueueSnackbar('Vaterartikel-Modus deaktiviert.', { variant: 'success' });
+ this.setState({ busy: false });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ busy: false });
+ }
+ };
+
+ handleAddChild = async () => {
+ const { article } = this.props;
+ const { selectedChildValueId, newChildText, isCustomChildValue, busy } = this.state;
+ if (busy || !article?.sku) return;
+
+ const payload = {};
+ if (isCustomChildValue) {
+ const val = newChildText.trim();
+ if (!val) return;
+ payload.value = val;
+ } else {
+ if (!selectedChildValueId) return;
+ payload.attributeValueId = Number(selectedChildValueId);
+ }
+
+ this.setState({ busy: true });
+ try {
+ const api = this.getApi();
+ await api.addChild(article.sku, payload);
+ if (this._cancelled) return;
+
+ await artikelAutomat.actions.reloadArticle(article.sku);
+ if (this._cancelled) return;
+
+ enqueueSnackbar('Variante erfolgreich angelegt.', { variant: 'success' });
+ this.setState({
+ busy: false,
+ selectedChildValueId: '',
+ newChildText: '',
+ });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ busy: false });
+ }
+ };
+
+ handleOpenRemoveDialog = (child) => {
+ this.setState({ removeTargetChild: child });
+ };
+
+ handleCloseRemoveDialog = () => {
+ this.setState({ removeTargetChild: null });
+ };
+
+ handleConfirmRemoveChild = async (deletePermanently) => {
+ const { article } = this.props;
+ const { removeTargetChild, busy } = this.state;
+ if (busy || !article?.sku || !removeTargetChild?.sku) return;
+
+ const childSku = removeTargetChild.sku;
+ this.setState({ busy: true, removeTargetChild: null });
+ try {
+ const api = this.getApi();
+ await api.removeChild(article.sku, childSku, {
+ deleteArticle: deletePermanently,
+ });
+ if (this._cancelled) return;
+
+ await artikelAutomat.actions.reloadArticle(article.sku);
+ if (this._cancelled) return;
+
+ enqueueSnackbar(
+ deletePermanently ? 'Variante vollständig gelöscht.' : 'Variante vom Vaterartikel getrennt.',
+ { variant: 'success' }
+ );
+ this.setState({ busy: false });
+ } catch (err) {
+ if (this._cancelled) return;
+ notifyError(err);
+ this.setState({ busy: false });
+ }
+ };
+
render() {
const { article: articleData } = this.props;
+ const {
+ attributes,
+ assignedAttributes,
+ busy,
+ isActivating,
+ selectedAttributeId,
+ selectedChildValueId,
+ newChildText,
+ isCustomChildValue,
+ removeTargetChild,
+ } = this.state;
+
+ if (!articleData) return null;
+
+ const isChild = articleData.fatherArticleId != null || Boolean(articleData.fatherSku);
+ const isFather = Boolean(articleData.isFather);
+ const children = Array.isArray(articleData.children) ? articleData.children : [];
+
+ // Attributes assigned to this article that cannot be selected for father mode
+ const assignedAttributeIds = new Set(
+ (assignedAttributes || []).map((a) => a.attributeId)
+ );
+ const selectableAttributes = attributes.filter(
+ (a) => !assignedAttributeIds.has(a.id)
+ );
+
+ // Find father attribute definition & values
+ const fatherAttrDef = isFather
+ ? attributes.find((a) => a.id === articleData.fatherAttributeNameId)
+ : null;
+ const allFatherValues = fatherAttrDef?.values || [];
+ const assignedValueIds = new Set(
+ children.map((c) => c.attributeValueId).filter((id) => id != null)
+ );
+ const availableFatherValues = allFatherValues.filter((v) => !assignedValueIds.has(v.id));
+
return (
- 0}>
- {articleData.childCount}
-
-
- {articleData.fatherSku ? (
-
+ {/* If article is a Child / Variant of another article, show link to its father */}
+ {isChild && articleData.fatherSku ? (
+
- {articleData.fatherSku}
-
+
+
+
+ Variante von:
+
+
+
+ {articleData.fatherSku}
+
+
) : null}
-
-
-
-
+
+ {/* Father Mode or Activation (supported on both root and child articles) */}
+ {isFather ? (
+
+
+
+
+ Vaterartikel
+
+
+
+
+ {children.length === 0 ? (
+
+
+
+
+
+
+
+ ) : null}
+
+
+ {/* Add Child Variant Row */}
+
+ {isCustomChildValue || availableFatherValues.length === 0 ? (
+ this.setState({ newChildText: e.target.value })}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ void this.handleAddChild();
+ }
+ }}
+ sx={{
+ flex: 1,
+ minWidth: 0,
+ bgcolor: 'background.paper',
+ '& .MuiInputBase-input': { py: 0.5, px: 1, fontSize: '0.82rem' },
+ }}
+ />
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {busy ? : }
+
+
+
+
+
+ {/* Toggle custom value / predefined value if values exist */}
+ {allFatherValues.length > 0 && availableFatherValues.length > 0 ? (
+
+
+ this.setState((prev) => ({
+ isCustomChildValue: !prev.isCustomChildValue,
+ selectedChildValueId: '',
+ newChildText: '',
+ }))
+ }
+ sx={{ fontSize: '0.72rem', color: 'text.secondary', textDecoration: 'none' }}
+ >
+ {isCustomChildValue ? 'Aus Liste wählen' : 'Neuen Wert tippen'}
+
+
+ ) : null}
+
+ {/* Children List */}
+
+ {children.length === 0 ? (
+
+ Noch keine Varianten angelegt.
+
+ ) : (
+ children.map((child) => (
+
+
+
+ {child.attributeValue || child.name || child.sku}
+
+
+ {child.sku}
+
+
+
+ this.handleOpenRemoveDialog(child)}
+ sx={{
+ p: 0.25,
+ color: 'text.secondary',
+ '&:hover': { color: 'error.main' },
+ }}
+ >
+
+
+
+ ))
+ )}
+
+
+ ) : (
+ /* Case 3: Regular Article (neither father nor child) */
+
+
+
+ Vaterartikel:
+
+
+ nein
+
+
+
+ {!isActivating ? (
+
+ ) : (
+
+ {selectableAttributes.length === 0 ? (
+
+ Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen.
+
+ ) : (
+
+
+ Merkmal für Varianten
+
+
+
+ )}
+
+
+
+
+
+
+ )}
+
+ )}
+
+ {/* Counts Line at Bottom */}
+ {(articleData.optionsCount != null || articleData.articleListCount != null) && (
+
+ {articleData.optionsCount != null && (
+
+ Optionen: {articleData.optionsCount}
+
+ )}
+ {articleData.articleListCount != null && (
+
+ Stücklisten: {articleData.articleListCount}
+
+ )}
+
+ )}
+
+
+ {/* Remove Child Confirmation Dialog */}
+
);
}
diff --git a/src/components/article/tabs/ArticleCompoundTab.jsx b/src/components/article/tabs/ArticleCompoundTab.jsx
deleted file mode 100644
index adbc842..0000000
--- a/src/components/article/tabs/ArticleCompoundTab.jsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Component } from 'react';
-import Typography from '@mui/material/Typography';
-
-export default class ArticleCompoundTab extends Component {
- render() {
- return (
-
- Compound Editor
-
- );
- }
-}