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,
}}
>
- }
- disabled={busy || loading || !ready}
- onClick={this.handleLogin}
+
- {busy ? 'Signing in…' : 'Sign in with Google'}
-
+
+
+
+ QuixPOS
+
+
+ Management Interface Login
+
+
+ }
+ disabled={busy || loading || !ready}
+ onClick={this.handleLogin}
+ sx={{ mt: 1, py: 1.25, textTransform: 'none', fontWeight: 600 }}
+ >
+ {busy ? 'Anmeldung läuft…' : 'Mit Google anmelden'}
+
+
);
}
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
+
+
+
+ )}
+
+
+
+
+
+
+ ) : (
+
+ }
+ onClick={() => this.setState({ activeCreatorMode: 'father' })}
+ sx={{
+ fontSize: '0.78rem',
+ textTransform: 'none',
+ bgcolor: 'background.paper',
+ borderColor: 'divider',
+ py: 0.5,
+ justifyContent: 'flex-start',
+ }}
+ >
+ Als Vaterartikel aktivieren
+
+ }
+ onClick={() =>
+ this.setState({ activeCreatorMode: 'stueckliste', activeListTab: 'stueckliste' })
+ }
+ sx={{
+ fontSize: '0.78rem',
+ textTransform: 'none',
+ bgcolor: 'background.paper',
+ borderColor: 'divider',
+ py: 0.5,
+ justifyContent: 'flex-start',
+ }}
+ >
+ Als Stückliste anlegen
+
+ }
+ onClick={() =>
+ this.setState({ activeCreatorMode: 'options', activeListTab: 'options' })
+ }
+ sx={{
+ fontSize: '0.78rem',
+ textTransform: 'none',
+ bgcolor: 'background.paper',
+ borderColor: 'divider',
+ py: 0.5,
+ justifyContent: 'flex-start',
+ }}
+ >
+ Optionen hinzufügen
+
+
+ )}
+
+ );
+ }
+
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 */}
+
+ }
+ onClick={() => this.setState({ activeListTab: 'stueckliste' })}
+ sx={{
+ py: 0.2,
+ px: 1,
+ fontSize: '0.75rem',
+ textTransform: 'none',
+ height: 26,
+ borderRadius: 1.5,
+ borderColor: activeListTab === 'stueckliste' ? 'primary.main' : 'divider',
+ bgcolor: activeListTab === 'stueckliste' ? 'primary.main' : 'background.paper',
+ }}
+ >
+ {isStueckliste ? `Stückliste (${stuecklisteItems.length})` : '+ Stückliste'}
+
+ }
+ onClick={() => this.setState({ activeListTab: 'options' })}
+ sx={{
+ py: 0.2,
+ px: 1,
+ fontSize: '0.75rem',
+ textTransform: 'none',
+ height: 26,
+ borderRadius: 1.5,
+ borderColor: activeListTab === 'options' ? 'primary.main' : 'divider',
+ bgcolor: activeListTab === 'options' ? 'primary.main' : 'background.paper',
+ }}
+ >
+ {hasOptions ? `Optionen (${optionsItems.length})` : '+ 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 */}
+
);
}
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()}