diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index c3f56ff..01aa56e 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -497,6 +497,22 @@ export class ApiClient { 'DELETE', `/api/article/${enc(sku)}/children/${enc(childSku)}${deleteArticle ? '?deleteArticle=true' : ''}` ), + /** GET /api/article/{sku}/articlelist */ + getArticlelist: (sku) => + this.#json('GET', `/api/article/${enc(sku)}/articlelist`), + /** PUT /api/article/{sku}/articlelist */ + setArticlelist: (sku, items) => + this.#json('PUT', `/api/article/${enc(sku)}/articlelist`, { + body: Array.isArray(items) ? { items } : items, + }), + /** GET /api/article/{sku}/optionslist */ + getOptionslist: (sku) => + this.#json('GET', `/api/article/${enc(sku)}/optionslist`), + /** PUT /api/article/{sku}/optionslist */ + setOptionslist: (sku, options) => + this.#json('PUT', `/api/article/${enc(sku)}/optionslist`, { + body: Array.isArray(options) ? { options } : options, + }), }; this.posterminal = { diff --git a/src/api/index.js b/src/api/index.js index e82e6b2..e21f093 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -519,6 +519,42 @@ export const article = { { method: 'DELETE' } ); }, + + /** + * GET /api/article/:sku/articlelist → { items: [...] } + */ + getArticlelist(sku) { + return http(`/api/article/${encodeURIComponent(sku)}/articlelist`); + }, + + /** + * PUT /api/article/:sku/articlelist + * Body: { items: [{ articleId, sku, count, sort }] } + */ + setArticlelist(sku, items) { + return http(`/api/article/${encodeURIComponent(sku)}/articlelist`, { + method: 'PUT', + body: Array.isArray(items) ? { items } : items, + }); + }, + + /** + * GET /api/article/:sku/optionslist → { options: [...] } + */ + getOptionslist(sku) { + return http(`/api/article/${encodeURIComponent(sku)}/optionslist`); + }, + + /** + * PUT /api/article/:sku/optionslist + * Body: { options: [{ articleId, sku, count, sort, mandated }] } + */ + setOptionslist(sku, options) { + return http(`/api/article/${encodeURIComponent(sku)}/optionslist`, { + method: 'PUT', + body: Array.isArray(options) ? { options } : options, + }); + }, }; /** @@ -611,7 +647,7 @@ export const category = { /** * GET /api/category/:id/articles → { articles: [...] } - * Article rows: sku, name, net, taxRate, fullHash, thumbW, thumbH, thumbHash, … + * Article rows: sku, name, net, taxRate, fullHash, thumbW, thumbH, thumbHash, childCount, … */ getArticles(categoryId) { return http(`/api/category/${categoryId}/articles`); diff --git a/src/assets/quixpos-logo.png b/src/assets/quixpos-logo.png new file mode 100644 index 0000000..7df2d5f Binary files /dev/null and b/src/assets/quixpos-logo.png differ diff --git a/src/components/LoginPage.jsx b/src/components/LoginPage.jsx index 3787d36..5041f26 100644 --- a/src/components/LoginPage.jsx +++ b/src/components/LoginPage.jsx @@ -1,6 +1,8 @@ import { PureComponent } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; import GoogleIcon from '@mui/icons-material/Google'; import { enqueueSnackbar } from 'notistack'; import { notifyErrorMessage } from '../utils/notify'; @@ -15,6 +17,8 @@ import { requestGoogleIdToken, } from '../auth/googleSignIn'; +import quixposLogo from '../assets/quixpos-logo.png'; + class LoginPage extends PureComponent { static contextType = StorageManagerContext; @@ -93,17 +97,62 @@ class LoginPage extends PureComponent { justifyContent: 'center', minHeight: '100vh', bgcolor: 'background.default', + p: 2, }} > - + + + + QuixPOS + + + Management Interface Login + + + + ); } diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx index 5e615db..3bad37f 100644 --- a/src/components/article/ArticleTileTab.jsx +++ b/src/components/article/ArticleTileTab.jsx @@ -79,8 +79,19 @@ const SETTINGS_TAB = { help: 'Artikeleinstellungen, Einheiten, Steuersatz und Löschen', }; -function visibleTabs() { - return [...ALWAYS_TABS, SETTINGS_TAB]; +function visibleTabs(article) { + const isFather = Boolean(article?.isFather); + const tabs = ALWAYS_TABS.map((row) => { + if (row.id === ARTICLE_TAB.supplier && isFather) { + return { + ...row, + disabled: true, + help: 'Lieferanten sind für Vaterartikel deaktiviert (werden auf Variantenebene gepflegt).', + }; + } + return row; + }); + return [...tabs, SETTINGS_TAB]; } function readRouteTab() { @@ -264,6 +275,7 @@ class ArticleTileTab extends PureComponent { @@ -300,6 +312,12 @@ class ArticleTileTab extends PureComponent { color: 'text.primary', borderBottom: 'none', }, + '&.Mui-disabled': { + opacity: 0.4, + bgcolor: '#e2e8f0', + cursor: 'not-allowed', + pointerEvents: 'auto', + }, }} /> ); diff --git a/src/components/article/articleRoute.js b/src/components/article/articleRoute.js index 9a617fa..705c209 100644 --- a/src/components/article/articleRoute.js +++ b/src/components/article/articleRoute.js @@ -30,8 +30,12 @@ export function articleSku(article) { return sku || null; } -export function articleTabIds() { - return [...BASE_ARTICLE_TAB_IDS, ARTICLE_TAB.settings]; +export function articleTabIds(article) { + let ids = [...BASE_ARTICLE_TAB_IDS]; + if (article?.isFather) { + ids = ids.filter((id) => id !== ARTICLE_TAB.supplier); + } + return [...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 6b85fd8..b981e13 100644 --- a/src/components/article/boxes/ArticleViewLists.jsx +++ b/src/components/article/boxes/ArticleViewLists.jsx @@ -1,7 +1,9 @@ import { PureComponent } from 'react'; import { Link as RouterLink } from 'react-router-dom'; +import Autocomplete from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; import Chip from '@mui/material/Chip'; import CircularProgress from '@mui/material/CircularProgress'; import Dialog from '@mui/material/Dialog'; @@ -10,6 +12,7 @@ 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 FormControlLabel from '@mui/material/FormControlLabel'; import IconButton from '@mui/material/IconButton'; import InputLabel from '@mui/material/InputLabel'; import Link from '@mui/material/Link'; @@ -21,6 +24,8 @@ 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 LayersIcon from '@mui/icons-material/Layers'; +import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck'; import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; import LinkOffIcon from '@mui/icons-material/LinkOff'; import { PanelTile } from 'dashgui/panels'; @@ -38,18 +43,47 @@ export default class ArticleViewLists extends PureComponent { constructor(props) { super(props); const initialAttributes = selectAttributes() || []; + const hasStueckliste = Boolean( + props.article?.articlelistId != null || props.article?.articlelist?.length > 0 + ); + const hasOptions = Boolean( + props.article?.optionslistId != null || props.article?.options?.length > 0 + ); + let activeListTab = 'stueckliste'; + if (!hasStueckliste && hasOptions) { + activeListTab = 'options'; + } + this.state = { attributes: initialAttributes, assignedAttributes: [], + allArticles: [], busy: false, - isActivating: false, + + // Active sub-tab for compound article: 'stueckliste' | 'options' + activeListTab, + + // Creator mode for standard article: null | 'father' | 'stueckliste' | 'options' + activeCreatorMode: null, + + // Father mode state selectedAttributeId: '', selectedChildValueId: '', newChildText: '', isCustomChildValue: false, - // Remove dialog + // Stückliste item add state + stuecklisteTargetArticle: null, + stuecklisteCount: 1, + + // Options item add state + optionTargetArticle: null, + optionCount: 1, + optionMandated: false, + + // Confirmation dialogs removeTargetChild: null, // { sku, attributeValue } + confirmClearType: null, // 'stueckliste' | 'options' | null }; this._cancelled = false; } @@ -79,6 +113,8 @@ export default class ArticleViewLists extends PureComponent { if (this.props.article?.sku) { void this.loadAssignedAttributes(this.props.article.sku); } + + void this.loadAllArticles(); } componentDidUpdate(prevProps) { @@ -86,6 +122,26 @@ export default class ArticleViewLists extends PureComponent { if (this.props.article?.sku) { void this.loadAssignedAttributes(this.props.article.sku); } + const nextArticle = this.props.article; + const hasStueckliste = Boolean( + nextArticle?.articlelistId != null || nextArticle?.articlelist?.length > 0 + ); + const hasOptions = Boolean( + nextArticle?.optionslistId != null || nextArticle?.options?.length > 0 + ); + let activeListTab = 'stueckliste'; + if (!hasStueckliste && hasOptions) { + activeListTab = 'options'; + } + this.setState({ + activeListTab, + activeCreatorMode: null, + stuecklisteTargetArticle: null, + stuecklisteCount: 1, + optionTargetArticle: null, + optionCount: 1, + optionMandated: false, + }); } } @@ -98,6 +154,18 @@ export default class ArticleViewLists extends PureComponent { return this.context?.article || articleApi; } + async loadAllArticles() { + try { + const data = await (this.context?.articleIndexPromise || this.getApi().index()); + const articles = Array.isArray(data) ? data : (data?.articles || []); + if (!this._cancelled) { + this.setState({ allArticles: articles }); + } + } catch { + // Ignore + } + } + async loadAssignedAttributes(sku) { if (!sku) return; try { @@ -124,16 +192,24 @@ export default class ArticleViewLists extends PureComponent { } } - handleStartActivating = async () => { - this.setState({ isActivating: true }); - await this.loadAssignedAttributes(this.props.article?.sku); - }; + // ── Father Actions ────────────────────────────────────────────────────────── handleEnableFather = async () => { const { article } = this.props; const { selectedAttributeId, assignedAttributes, busy } = this.state; if (busy || !article?.sku || !selectedAttributeId) return; + const isStueckliste = article.articlelistId != null || (article.articleListCount ?? 0) > 0; + const hasOptions = article.optionslistId != null || (article.optionsCount ?? 0) > 0; + if (isStueckliste) { + enqueueSnackbar('Ein Stücklisten-Artikel kann kein Vaterartikel sein.', { variant: 'error' }); + return; + } + if (hasOptions) { + enqueueSnackbar('Ein Artikel mit Optionen kann kein Vaterartikel sein.', { variant: 'error' }); + 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' }); @@ -156,7 +232,7 @@ export default class ArticleViewLists extends PureComponent { } enqueueSnackbar('Vaterartikel-Modus aktiviert.', { variant: 'success' }); - this.setState({ busy: false, isActivating: false }); + this.setState({ busy: false, activeCreatorMode: null }); } catch (err) { if (this._cancelled) return; notifyError(err); @@ -264,18 +340,1020 @@ export default class ArticleViewLists extends PureComponent { } }; + // ── Stückliste Actions ────────────────────────────────────────────────────── + + handleAddItemToStueckliste = async () => { + const { article } = this.props; + const { stuecklisteTargetArticle, stuecklisteCount, busy } = this.state; + if (busy || !article?.sku || !stuecklisteTargetArticle?.sku) return; + + if (stuecklisteTargetArticle.sku === article.sku) { + enqueueSnackbar('Ein Artikel kann sich nicht selbst als Komponente enthalten.', { variant: 'error' }); + return; + } + + const currentItems = Array.isArray(article.articlelist) ? article.articlelist : []; + const count = Math.max(1, Number(stuecklisteCount) || 1); + + const existingIndex = currentItems.findIndex((i) => i.sku === stuecklisteTargetArticle.sku); + let nextItems = []; + if (existingIndex >= 0) { + nextItems = currentItems.map((item, idx) => + idx === existingIndex ? { ...item, count: item.count + count } : item + ); + } else { + nextItems = [ + ...currentItems, + { + articleId: stuecklisteTargetArticle.id, + sku: stuecklisteTargetArticle.sku, + count, + sort: currentItems.length, + }, + ]; + } + + this.setState({ busy: true }); + try { + const api = this.getApi(); + await api.setArticlelist(article.sku, nextItems); + if (this._cancelled) return; + + await artikelAutomat.actions.reloadArticle(article.sku); + if (this._cancelled) return; + + enqueueSnackbar('Komponente hinzugefügt.', { variant: 'success' }); + this.setState({ + busy: false, + stuecklisteTargetArticle: null, + stuecklisteCount: 1, + activeCreatorMode: null, + activeListTab: 'stueckliste', + }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busy: false }); + } + }; + + handleRemoveStuecklisteItem = async (itemSku) => { + const { article } = this.props; + const { busy } = this.state; + if (busy || !article?.sku) return; + + const currentItems = Array.isArray(article.articlelist) ? article.articlelist : []; + const remainingItems = currentItems + .filter((i) => i.sku !== itemSku) + .map((item, idx) => ({ + articleId: item.articleId, + sku: item.sku, + count: item.count, + sort: idx, + })); + + this.setState({ busy: true }); + try { + const api = this.getApi(); + await api.setArticlelist(article.sku, remainingItems); + if (this._cancelled) return; + + await artikelAutomat.actions.reloadArticle(article.sku); + if (this._cancelled) return; + + enqueueSnackbar('Komponente entfernt.', { variant: 'success' }); + this.setState({ busy: false }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busy: false }); + } + }; + + handleClearStueckliste = async () => { + const { article } = this.props; + const { busy } = this.state; + if (busy || !article?.sku) return; + + this.setState({ busy: true, confirmClearType: null }); + try { + const api = this.getApi(); + await api.setArticlelist(article.sku, []); + if (this._cancelled) return; + + await artikelAutomat.actions.reloadArticle(article.sku); + if (this._cancelled) return; + + enqueueSnackbar('Stückliste aufgelöst.', { variant: 'success' }); + this.setState({ + busy: false, + activeListTab: 'options', + }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busy: false }); + } + }; + + // ── Optionen Actions ──────────────────────────────────────────────────────── + + handleAddOption = async () => { + const { article } = this.props; + const { optionTargetArticle, optionCount, optionMandated, busy } = this.state; + if (busy || !article?.sku || !optionTargetArticle?.sku) return; + + if (optionTargetArticle.sku === article.sku) { + enqueueSnackbar('Ein Artikel kann sich nicht selbst als Option enthalten.', { variant: 'error' }); + return; + } + + const currentOptions = Array.isArray(article.options) ? article.options : []; + const count = Math.max(1, Number(optionCount) || 1); + + const existingIndex = currentOptions.findIndex((o) => o.sku === optionTargetArticle.sku); + let nextOptions = []; + if (existingIndex >= 0) { + nextOptions = currentOptions.map((opt, idx) => + idx === existingIndex + ? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) } + : opt + ); + } else { + nextOptions = [ + ...currentOptions, + { + articleId: optionTargetArticle.id, + sku: optionTargetArticle.sku, + count, + sort: currentOptions.length, + mandated: Boolean(optionMandated), + }, + ]; + } + + this.setState({ busy: true }); + try { + const api = this.getApi(); + await api.setOptionslist(article.sku, nextOptions); + if (this._cancelled) return; + + await artikelAutomat.actions.reloadArticle(article.sku); + if (this._cancelled) return; + + enqueueSnackbar('Option hinzugefügt.', { variant: 'success' }); + this.setState({ + busy: false, + optionTargetArticle: null, + optionCount: 1, + optionMandated: false, + activeCreatorMode: null, + activeListTab: 'options', + }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busy: false }); + } + }; + + handleRemoveOption = async (optionSku) => { + const { article } = this.props; + const { busy } = this.state; + if (busy || !article?.sku) return; + + const currentOptions = Array.isArray(article.options) ? article.options : []; + const remainingOptions = currentOptions + .filter((o) => o.sku !== optionSku) + .map((opt, idx) => ({ + articleId: opt.articleId, + sku: opt.sku, + count: opt.count, + sort: idx, + mandated: Boolean(opt.mandated), + })); + + this.setState({ busy: true }); + try { + const api = this.getApi(); + await api.setOptionslist(article.sku, remainingOptions); + if (this._cancelled) return; + + await artikelAutomat.actions.reloadArticle(article.sku); + if (this._cancelled) return; + + enqueueSnackbar('Option entfernt.', { variant: 'success' }); + this.setState({ busy: false }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busy: false }); + } + }; + + handleClearOptions = async () => { + const { article } = this.props; + const { busy } = this.state; + if (busy || !article?.sku) return; + + this.setState({ busy: true, confirmClearType: null }); + try { + const api = this.getApi(); + await api.setOptionslist(article.sku, []); + if (this._cancelled) return; + + await artikelAutomat.actions.reloadArticle(article.sku); + if (this._cancelled) return; + + enqueueSnackbar('Optionen entfernt.', { variant: 'success' }); + this.setState({ + busy: false, + activeListTab: 'stueckliste', + }); + } catch (err) { + if (this._cancelled) return; + notifyError(err); + this.setState({ busy: false }); + } + }; + + renderStuecklisteContent(stuecklisteItems, selectableOtherArticles) { + const { busy, stuecklisteTargetArticle, stuecklisteCount } = this.state; + return ( + + + + + + Stückliste + + + + + + + this.setState({ confirmClearType: 'stueckliste' })} + disabled={busy} + sx={{ p: 0.4, color: 'text.secondary', '&:hover': { color: 'error.main' } }} + > + + + + + + + {/* Add Component Row */} + + this.setState({ stuecklisteTargetArticle: val })} + getOptionLabel={(opt) => + typeof opt === 'string' ? opt : `${opt.sku} - ${opt.name || ''}` + } + isOptionEqualToValue={(opt, val) => opt.sku === val?.sku} + renderInput={(params) => ( + + )} + sx={{ flex: 1, minWidth: 0 }} + /> + + + this.setState({ + stuecklisteCount: Math.max(1, parseInt(e.target.value, 10) || 1), + }) + } + inputProps={{ + min: 1, + style: { padding: '4px 6px', textAlign: 'center', fontSize: '0.82rem' }, + }} + sx={{ width: 44, bgcolor: 'background.paper', flexShrink: 0 }} + /> + + + + + {busy ? : } + + + + + + {/* Items List */} + + {stuecklisteItems.length === 0 ? ( + + Noch keine Komponenten in Stückliste. + + ) : ( + stuecklisteItems.map((item) => ( + + + + + + {item.name || item.sku} + + + {item.sku} + + + + + this.handleRemoveStuecklisteItem(item.sku)} + sx={{ + p: 0.25, + color: 'text.secondary', + '&:hover': { color: 'error.main' }, + }} + > + + + + )) + )} + + + ); + } + + renderStuecklisteCreator(selectableOtherArticles, hasOptions) { + const { busy, stuecklisteTargetArticle, stuecklisteCount } = this.state; + return ( + + + Erste Komponente hinzufügen: + + + this.setState({ stuecklisteTargetArticle: val })} + getOptionLabel={(opt) => + typeof opt === 'string' ? opt : `${opt.sku} - ${opt.name || ''}` + } + isOptionEqualToValue={(opt, val) => opt.sku === val?.sku} + renderInput={(params) => ( + + )} + sx={{ width: '100%' }} + /> + + + + this.setState({ + stuecklisteCount: Math.max(1, parseInt(e.target.value, 10) || 1), + }) + } + inputProps={{ + min: 1, + style: { padding: '4px 6px', textAlign: 'center', fontSize: '0.82rem' }, + }} + sx={{ width: 70 }} + /> + + + + + + + + ); + } + + renderOptionsContent(optionsItems, selectableOtherArticles) { + const { busy, optionTargetArticle, optionCount, optionMandated } = this.state; + return ( + + + + + + Optionen + + + + + + + this.setState({ confirmClearType: 'options' })} + disabled={busy} + sx={{ p: 0.4, color: 'text.secondary', '&:hover': { color: 'error.main' } }} + > + + + + + + + {/* Add Option Row */} + + this.setState({ optionTargetArticle: val })} + getOptionLabel={(opt) => + typeof opt === 'string' ? opt : `${opt.sku} - ${opt.name || ''}` + } + isOptionEqualToValue={(opt, val) => opt.sku === val?.sku} + renderInput={(params) => ( + + )} + sx={{ flex: 1, minWidth: 0 }} + /> + + + this.setState({ + optionCount: Math.max(1, parseInt(e.target.value, 10) || 1), + }) + } + inputProps={{ + min: 1, + style: { padding: '4px 6px', textAlign: 'center', fontSize: '0.82rem' }, + }} + sx={{ width: 44, bgcolor: 'background.paper', flexShrink: 0 }} + /> + + + + + {busy ? : } + + + + + + {/* Mandated checkbox toggle */} + + this.setState({ optionMandated: e.target.checked })} + sx={{ p: 0.5 }} + /> + } + label={ + + Pflicht-Zubehör (mandated) + + } + sx={{ ml: 0.5 }} + /> + + + {/* Options List */} + + {optionsItems.length === 0 ? ( + + Noch keine Optionen hinterlegt. + + ) : ( + optionsItems.map((opt) => ( + + + + + + {opt.name || opt.sku} + + + + {opt.sku} + + {opt.mandated ? ( + + ) : null} + + + + + this.handleRemoveOption(opt.sku)} + sx={{ + p: 0.25, + color: 'text.secondary', + '&:hover': { color: 'error.main' }, + }} + > + + + + )) + )} + + + ); + } + + renderOptionsCreator(selectableOtherArticles, isStueckliste) { + const { busy, optionTargetArticle, optionCount, optionMandated } = this.state; + return ( + + + Erste Option hinzufügen: + + + this.setState({ optionTargetArticle: val })} + getOptionLabel={(opt) => + typeof opt === 'string' ? opt : `${opt.sku} - ${opt.name || ''}` + } + isOptionEqualToValue={(opt, val) => opt.sku === val?.sku} + renderInput={(params) => ( + + )} + sx={{ width: '100%' }} + /> + + + + this.setState({ + optionCount: Math.max(1, parseInt(e.target.value, 10) || 1), + }) + } + inputProps={{ + min: 1, + style: { padding: '4px 6px', textAlign: 'center', fontSize: '0.82rem' }, + }} + sx={{ width: 70 }} + /> + + this.setState({ optionMandated: e.target.checked })} + sx={{ p: 0.5 }} + /> + } + label={ + + Pflicht + + } + sx={{ ml: 0 }} + /> + + + + + + + + ); + } + + renderStandardView(selectableAttributes, assignedAttributeIds) { + const { busy, activeCreatorMode, selectedAttributeId, attributes } = this.state; + return ( + + + + Typ: + + + Standard-Artikel + + + + {activeCreatorMode === 'father' ? ( + + {selectableAttributes.length === 0 ? ( + + Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen. + + ) : ( + + + Merkmal für Varianten + + + + )} + + + + + + + ) : ( + + + + + + )} + + ); + } + render() { const { article: articleData } = this.props; const { attributes, assignedAttributes, + allArticles, busy, - isActivating, + activeCreatorMode, + activeListTab, selectedAttributeId, selectedChildValueId, newChildText, isCustomChildValue, + stuecklisteTargetArticle, + stuecklisteCount, + optionTargetArticle, + optionCount, + optionMandated, removeTargetChild, + confirmClearType, } = this.state; if (!articleData) return null; @@ -284,6 +1362,12 @@ export default class ArticleViewLists extends PureComponent { const isFather = Boolean(articleData.isFather); const children = Array.isArray(articleData.children) ? articleData.children : []; + const stuecklisteItems = Array.isArray(articleData.articlelist) ? articleData.articlelist : []; + const isStueckliste = articleData.articlelistId != null || stuecklisteItems.length > 0; + + const optionsItems = Array.isArray(articleData.options) ? articleData.options : []; + const hasOptions = articleData.optionslistId != null || optionsItems.length > 0; + // Attributes assigned to this article that cannot be selected for father mode const assignedAttributeIds = new Set( (assignedAttributes || []).map((a) => a.attributeId) @@ -292,6 +1376,9 @@ export default class ArticleViewLists extends PureComponent { (a) => !assignedAttributeIds.has(a.id) ); + // Filter available articles for Autocomplete (cannot select self) + const selectableOtherArticles = allArticles.filter((a) => a.sku !== articleData.sku); + // Find father attribute definition & values const fatherAttrDef = isFather ? attributes.find((a) => a.id === articleData.fatherAttributeNameId) @@ -341,7 +1428,7 @@ export default class ArticleViewLists extends PureComponent { ) : null} - {/* Father Mode or Activation (supported on both root and child articles) */} + {/* ── Mode 1: Father Article ──────────────────────────────────────── */} {isFather ? ( @@ -550,135 +1637,70 @@ export default class ArticleViewLists extends PureComponent { )} - ) : ( - /* Case 3: Regular Article (neither father nor child) */ - - - - Vaterartikel: - - - nein - + ) : isStueckliste || hasOptions || activeCreatorMode === 'stueckliste' || activeCreatorMode === 'options' ? ( + /* ── Combined Compound Manager (Stückliste & Optionen) ─────────── */ + + {/* Sub-tabs: Stückliste & Optionen */} + + + - {!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} - - )} + {activeListTab === 'stueckliste' + ? isStueckliste + ? this.renderStuecklisteContent(stuecklisteItems, selectableOtherArticles) + : this.renderStuecklisteCreator(selectableOtherArticles, hasOptions) + : hasOptions + ? this.renderOptionsContent(optionsItems, selectableOtherArticles) + : this.renderOptionsCreator(selectableOtherArticles, isStueckliste)} + ) : ( + this.renderStandardView(selectableAttributes, assignedAttributeIds) )} @@ -724,6 +1746,48 @@ export default class ArticleViewLists extends PureComponent { + + {/* Clear Stückliste / Optionen Confirmation Dialog */} + this.setState({ confirmClearType: null })} + maxWidth="xs" + fullWidth + > + + {confirmClearType === 'stueckliste' ? 'Stückliste auflösen?' : 'Optionen entfernen?'} + + + + {confirmClearType === 'stueckliste' + ? (hasOptions + ? 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen? Die Optionen bleiben dabei erhalten.' + : 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen und den Artikel wieder als Standard-Artikel führen?') + : (isStueckliste + ? 'Möchten Sie alle Optionen von diesem Artikel entfernen? Die Stückliste bleibt dabei erhalten.' + : 'Möchten Sie alle Optionen von diesem Artikel entfernen und ihn wieder als Standard-Artikel führen?')} + + + + + + + ); } diff --git a/src/components/article/boxes/ArticleViewPrice.jsx b/src/components/article/boxes/ArticleViewPrice.jsx index 4429ea7..261ef19 100644 --- a/src/components/article/boxes/ArticleViewPrice.jsx +++ b/src/components/article/boxes/ArticleViewPrice.jsx @@ -3,7 +3,7 @@ import Box from '@mui/material/Box'; import InputAdornment from '@mui/material/InputAdornment'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; -import { EditablePanelTile } from 'dashgui/panels'; +import { EditablePanelTile, PanelTile } from 'dashgui/panels'; import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog'; import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog'; import { StorageManagerContext } from '../../../api/storageManagerContext'; @@ -485,6 +485,53 @@ export default class ArticleViewPrice extends PureComponent { render() { const { article: articleData } = this.props; + + if (articleData?.isFather) { + return ( + + + + Preis + + + + Kein Preis + + + Preise werden auf Variantenebene gepflegt + + + + + ); + } + const { company, tiersDialogOpen, unitEditorOpen } = this.state; const priceValue = priceDraftValue(articleData, company); const basePrice = primaryPrice(articleData); diff --git a/src/components/article/boxes/ArticleViewStock.jsx b/src/components/article/boxes/ArticleViewStock.jsx index 01c1c74..e43afcd 100644 --- a/src/components/article/boxes/ArticleViewStock.jsx +++ b/src/components/article/boxes/ArticleViewStock.jsx @@ -31,8 +31,9 @@ export default class ArticleViewStock extends PureComponent { render() { const { article: articleData } = this.props; const { adjustOpen } = this.state; + const isFather = Boolean(articleData?.isFather); const isTrackStock = - articleData?.track_stock !== false && articleData?.track_stock !== 0; + !isFather && articleData?.track_stock !== false && articleData?.track_stock !== 0; const rawWarehouses = Array.isArray(articleData?.warehouses) ? articleData.warehouses @@ -60,12 +61,16 @@ export default class ArticleViewStock extends PureComponent { - {isTrackStock ? ( + {isFather ? ( + + + Kein Bestand + + + Bestand wird auf Variantenebene geführt + + + ) : isTrackStock ? ( - + {!isFather ? ( + + ) : null} ); } diff --git a/src/components/article/tabs/ArticleSupplierTab.jsx b/src/components/article/tabs/ArticleSupplierTab.jsx index b4567a1..e9ad60f 100644 --- a/src/components/article/tabs/ArticleSupplierTab.jsx +++ b/src/components/article/tabs/ArticleSupplierTab.jsx @@ -197,6 +197,33 @@ export default class ArticleSupplierTab extends PureComponent { busyId, } = this.state; + if (this.props.article?.isFather) { + return ( + + + + Lieferanten sind für Vaterartikel deaktiviert + + + Lieferanten, Einkaufspreise und Lieferanten-Artikelnummern werden für jede Variante separat gepflegt. + + + ); + } + if (loading) { return ( diff --git a/src/components/tiles/ArticleTile.jsx b/src/components/tiles/ArticleTile.jsx index 2f6b2d3..7044c6b 100644 --- a/src/components/tiles/ArticleTile.jsx +++ b/src/components/tiles/ArticleTile.jsx @@ -53,6 +53,33 @@ export class ArticleTile extends Tile { ); } + renderExtra() { + const childCount = Number(this.props.article?.childCount) || 0; + if (childCount <= 0) return null; + return ( + + {childCount} + + ); + } + render() { if (!this.hasPhoto()) { return super.render(); @@ -87,6 +114,7 @@ export class ArticleTile extends Tile { }} /> + {this.renderExtra()}