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 */} + + + Variante entfernen + + + + Möchten Sie die Variante{' '} + + „{removeTargetChild?.attributeValue || removeTargetChild?.name || removeTargetChild?.sku}“ + {' '} + ({removeTargetChild?.sku}) vom Vaterartikel trennen oder den Artikel vollständig löschen? + + + + + + + + ); } 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 - - ); - } -}