1843 lines
65 KiB
JavaScript
1843 lines
65 KiB
JavaScript
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 AppDialog from '../../AppDialog';
|
|
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 FormControlLabel from '@mui/material/FormControlLabel';
|
|
import IconButton from '@mui/material/IconButton';
|
|
import Link from '@mui/material/Link';
|
|
import MenuItem from '@mui/material/MenuItem';
|
|
import Tab from '@mui/material/Tab';
|
|
import Tabs from '@mui/material/Tabs';
|
|
import TextInput from '../../TextInput';
|
|
import ArticleSearch from '../ArticleSearch';
|
|
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 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 OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
|
import { PanelTile } from 'dashgui/panels';
|
|
import { enqueueSnackbar } from 'notistack';
|
|
import { artikelDetailPath } from '../articleRoute';
|
|
import { article as articleApi } from '../../../api';
|
|
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
|
|
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
|
import { notifyError } from '../../../utils/notify';
|
|
import articleIndexAutomat from '../../../automats/articleIndexAutomat';
|
|
import {
|
|
MAGENTA_PANEL,
|
|
SHADOW_CARD,
|
|
} from '../../../colors';
|
|
|
|
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,
|
|
|
|
// Active sub-tab for compound article: 'stueckliste' | 'options'
|
|
activeListTab,
|
|
|
|
// Creator mode for standard article: null | 'parent' | 'stueckliste' | 'options'
|
|
activeCreatorMode: null,
|
|
|
|
// Parent mode state
|
|
selectedAttributeId: '',
|
|
selectedChildValueId: '',
|
|
newChildText: '',
|
|
isCustomChildValue: false,
|
|
|
|
// 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;
|
|
}
|
|
|
|
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,
|
|
};
|
|
});
|
|
});
|
|
|
|
this.unsubArtikel = artikelAutomat.subscribe(() => {
|
|
if (this._cancelled) return;
|
|
this.forceUpdate();
|
|
});
|
|
|
|
if (attributeAutomat.state.status === 'idle') {
|
|
void attributeAutomat.actions.load();
|
|
}
|
|
|
|
if (this.props.article?.sku) {
|
|
void this.loadAssignedAttributes(this.props.article.sku);
|
|
}
|
|
|
|
if (this.props.article?.parentSku) {
|
|
void artikelAutomat.actions.loadArticle(this.props.article.parentSku);
|
|
}
|
|
|
|
void this.loadAllArticles();
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
if (prevProps.article?.sku !== this.props.article?.sku) {
|
|
if (this.props.article?.sku) {
|
|
void this.loadAssignedAttributes(this.props.article.sku);
|
|
}
|
|
if (this.props.article?.parentSku) {
|
|
void artikelAutomat.actions.loadArticle(this.props.article.parentSku);
|
|
}
|
|
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';
|
|
}
|
|
if (this.state.allArticles.length === 0) {
|
|
void this.loadAllArticles();
|
|
}
|
|
this.setState({
|
|
activeListTab,
|
|
activeCreatorMode: null,
|
|
stuecklisteTargetArticle: null,
|
|
stuecklisteCount: 1,
|
|
optionTargetArticle: null,
|
|
optionCount: 1,
|
|
optionMandated: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this._cancelled = true;
|
|
this.unsubAttr?.();
|
|
this.unsubArtikel?.();
|
|
}
|
|
|
|
getApi() {
|
|
return articleApi;
|
|
}
|
|
|
|
async loadAllArticles() {
|
|
try {
|
|
const articles = await articleIndexAutomat.actions.ensureLoaded();
|
|
if (!this._cancelled) {
|
|
this.setState({ allArticles: articles });
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load article index:', err);
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// ── Parent Actions ──────────────────────────────────────────────────────────
|
|
|
|
handleEnableParent = 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' });
|
|
return;
|
|
}
|
|
|
|
this.setState({ busy: true });
|
|
try {
|
|
const api = this.getApi();
|
|
const res = await api.toggleParent(article.sku, {
|
|
isParent: 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, activeCreatorMode: null });
|
|
} catch (err) {
|
|
if (this._cancelled) return;
|
|
notifyError(err);
|
|
this.setState({ busy: false });
|
|
}
|
|
};
|
|
|
|
handleDisableParent = 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.toggleParent(article.sku, { isParent: 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 });
|
|
}
|
|
};
|
|
|
|
// ── 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 isTargetParent = Boolean(
|
|
stuecklisteTargetArticle.isParent ||
|
|
stuecklisteTargetArticle.is_parent ||
|
|
getArticle(stuecklisteTargetArticle.sku)?.isParent
|
|
);
|
|
if (isTargetParent) {
|
|
enqueueSnackbar('Ein Vaterartikel kann nicht als Komponente einer Stückliste hinzugefügt werden.', { 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);
|
|
const nextItems =
|
|
existingIndex >= 0
|
|
? currentItems.map((item, idx) =>
|
|
idx === existingIndex ? { ...item, count: item.count + count } : item
|
|
)
|
|
: [
|
|
...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 isTargetParent = Boolean(
|
|
optionTargetArticle.isParent ||
|
|
optionTargetArticle.is_parent ||
|
|
getArticle(optionTargetArticle.sku)?.isParent
|
|
);
|
|
if (isTargetParent) {
|
|
enqueueSnackbar('Ein Vaterartikel kann nicht als Option hinzugefügt werden.', { 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);
|
|
const nextOptions =
|
|
existingIndex >= 0
|
|
? currentOptions.map((opt, idx) =>
|
|
idx === existingIndex
|
|
? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) }
|
|
: opt
|
|
)
|
|
: [
|
|
...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, isStueckliste = true) {
|
|
const { article } = this.props;
|
|
const { busy, stuecklisteTargetArticle, stuecklisteCount } = this.state;
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, flex: 1, minHeight: 0 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<LayersIcon sx={{ color: 'primary.main', fontSize: 18 }} />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.88rem' }}>
|
|
Stückliste
|
|
</Typography>
|
|
{isStueckliste && (
|
|
<Chip
|
|
label={`${stuecklisteItems.length} Komp.`}
|
|
size="small"
|
|
color="primary"
|
|
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 600 }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
{isStueckliste ? (
|
|
<Tooltip title="Stückliste auflösen">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.setState({ confirmClearType: 'stueckliste' })}
|
|
disabled={busy}
|
|
sx={{ p: 0.4, color: 'text.secondary', '&:hover': { color: 'error.main' } }}
|
|
>
|
|
<PowerSettingsNewIcon fontSize="small" sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
) : (
|
|
<Button
|
|
size="small"
|
|
color="inherit"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
this.setState({
|
|
activeCreatorMode: null,
|
|
stuecklisteTargetArticle: null,
|
|
stuecklisteCount: 1,
|
|
})
|
|
}
|
|
>
|
|
Abbrechen
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Add Component Row */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
|
<ArticleSearch
|
|
size="small"
|
|
label="Artikel"
|
|
placeholder="Artikel suchen..."
|
|
value={stuecklisteTargetArticle}
|
|
disabled={busy}
|
|
excludeSkus={[article?.sku, ...stuecklisteItems.map((item) => item.component_sku || item.sku)]}
|
|
onSelect={(val) => this.setState({ stuecklisteTargetArticle: val })}
|
|
sx={{ flex: 1, minWidth: 0 }}
|
|
/>
|
|
|
|
<TextInput
|
|
type="number"
|
|
label="Menge"
|
|
value={stuecklisteCount}
|
|
disabled={busy}
|
|
onChange={(e) =>
|
|
this.setState({
|
|
stuecklisteCount: Math.max(1, parseInt(e.target.value, 10) || 1),
|
|
})
|
|
}
|
|
slotProps={{
|
|
htmlInput: {
|
|
min: 1,
|
|
style: { textAlign: 'center' },
|
|
},
|
|
}}
|
|
sx={{ width: 72, flexShrink: 0 }}
|
|
/>
|
|
|
|
<Tooltip title="Komponente hinzufügen">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
color="primary"
|
|
disabled={busy || !stuecklisteTargetArticle}
|
|
onClick={this.handleAddItemToStueckliste}
|
|
sx={{
|
|
bgcolor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': { bgcolor: 'primary.dark' },
|
|
'&.Mui-disabled': {
|
|
bgcolor: 'action.disabledBackground',
|
|
color: 'action.disabled',
|
|
},
|
|
p: 0.6,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
{/* Items List */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 0.5,
|
|
mt: 0.5,
|
|
}}
|
|
>
|
|
{stuecklisteItems.map((item) => (
|
|
<Box
|
|
key={item.id || item.sku}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
px: 1,
|
|
py: 0.4,
|
|
borderRadius: 1,
|
|
bgcolor: 'background.paper',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
<Chip
|
|
label={`${item.count}x`}
|
|
size="small"
|
|
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
|
|
/>
|
|
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
fontWeight: 600,
|
|
fontSize: '0.82rem',
|
|
lineHeight: 1.2,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{item.name || item.sku}
|
|
</Typography>
|
|
<Link
|
|
component={RouterLink}
|
|
to={artikelDetailPath(item.sku)}
|
|
sx={{
|
|
fontSize: '0.72rem',
|
|
color: 'text.secondary',
|
|
textDecoration: 'none',
|
|
'&:hover': { textDecoration: 'underline' },
|
|
}}
|
|
>
|
|
{item.sku}
|
|
</Link>
|
|
</Box>
|
|
</Box>
|
|
|
|
<IconButton
|
|
size="small"
|
|
disabled={busy}
|
|
onClick={() => this.handleRemoveStuecklisteItem(item.sku)}
|
|
sx={{
|
|
p: 0.25,
|
|
color: 'text.secondary',
|
|
'&:hover': { color: 'error.main' },
|
|
}}
|
|
>
|
|
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Box>
|
|
))
|
|
}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
renderOptionsContent(optionsItems, selectableOtherArticles, hasOptions = true) {
|
|
const { article } = this.props;
|
|
const { busy, optionTargetArticle, optionCount, optionMandated } = this.state;
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, flex: 1, minHeight: 0 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<PlaylistAddCheckIcon sx={{ color: 'primary.main', fontSize: 18 }} />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.88rem' }}>
|
|
Optionen
|
|
</Typography>
|
|
{hasOptions && (
|
|
<Chip
|
|
label={`${optionsItems.length} Opt.`}
|
|
size="small"
|
|
color="primary"
|
|
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 600 }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
{hasOptions ? (
|
|
<Tooltip title="Optionen entfernen">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => this.setState({ confirmClearType: 'options' })}
|
|
disabled={busy}
|
|
sx={{ p: 0.4, color: 'text.secondary', '&:hover': { color: 'error.main' } }}
|
|
>
|
|
<PowerSettingsNewIcon fontSize="small" sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
) : (
|
|
<Button
|
|
size="small"
|
|
color="inherit"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
this.setState({
|
|
activeCreatorMode: null,
|
|
optionTargetArticle: null,
|
|
optionCount: 1,
|
|
optionMandated: false,
|
|
})
|
|
}
|
|
>
|
|
Abbrechen
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Add Option Row */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
|
<ArticleSearch
|
|
size="small"
|
|
label="Option"
|
|
placeholder="Option suchen..."
|
|
value={optionTargetArticle}
|
|
disabled={busy}
|
|
excludeSkus={[article?.sku, ...optionsItems.map((item) => item.component_sku || item.sku)]}
|
|
onSelect={(val) => this.setState({ optionTargetArticle: val })}
|
|
sx={{ flex: 1, minWidth: 0 }}
|
|
/>
|
|
|
|
<TextInput
|
|
type="number"
|
|
label="Menge"
|
|
value={optionCount}
|
|
disabled={busy}
|
|
onChange={(e) =>
|
|
this.setState({
|
|
optionCount: Math.max(1, parseInt(e.target.value, 10) || 1),
|
|
})
|
|
}
|
|
slotProps={{
|
|
htmlInput: {
|
|
min: 1,
|
|
style: { textAlign: 'center' },
|
|
},
|
|
}}
|
|
sx={{ width: 72, flexShrink: 0 }}
|
|
/>
|
|
|
|
<Tooltip title="Option hinzufügen">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
color="primary"
|
|
disabled={busy || !optionTargetArticle}
|
|
onClick={this.handleAddOption}
|
|
sx={{
|
|
bgcolor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': { bgcolor: 'primary.dark' },
|
|
'&.Mui-disabled': {
|
|
bgcolor: 'action.disabledBackground',
|
|
color: 'action.disabled',
|
|
},
|
|
p: 0.6,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
{/* Mandated checkbox toggle */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-start', mt: -0.5 }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
size="small"
|
|
checked={optionMandated}
|
|
onChange={(e) => this.setState({ optionMandated: e.target.checked })}
|
|
sx={{ p: 0.5 }}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.74rem' }}>
|
|
Fest
|
|
</Typography>
|
|
}
|
|
sx={{ ml: 0.5 }}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Options List */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 0.5,
|
|
mt: 0.25,
|
|
}}
|
|
>
|
|
{optionsItems.map((opt) => (
|
|
<Box
|
|
key={opt.id || opt.sku}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
px: 1,
|
|
py: 0.4,
|
|
borderRadius: 1,
|
|
bgcolor: 'background.paper',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
<Chip
|
|
label={`${opt.count}x`}
|
|
size="small"
|
|
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
|
|
/>
|
|
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
fontWeight: 600,
|
|
fontSize: '0.82rem',
|
|
lineHeight: 1.2,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{opt.name || opt.sku}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
<Link
|
|
component={RouterLink}
|
|
to={artikelDetailPath(opt.sku)}
|
|
sx={{
|
|
fontSize: '0.72rem',
|
|
color: 'text.secondary',
|
|
textDecoration: 'none',
|
|
'&:hover': { textDecoration: 'underline' },
|
|
}}
|
|
>
|
|
{opt.sku}
|
|
</Link>
|
|
{opt.mandated ? (
|
|
<Chip
|
|
label="Fest"
|
|
size="small"
|
|
color="warning"
|
|
variant="outlined"
|
|
sx={{ height: 16, fontSize: '0.65rem', fontWeight: 600 }}
|
|
/>
|
|
) : null}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
|
|
<IconButton
|
|
size="small"
|
|
disabled={busy}
|
|
onClick={() => this.handleRemoveOption(opt.sku)}
|
|
sx={{
|
|
p: 0.25,
|
|
color: 'text.secondary',
|
|
'&:hover': { color: 'error.main' },
|
|
}}
|
|
>
|
|
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Box>
|
|
))
|
|
}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
|
|
renderCompoundManager(
|
|
isStueckliste,
|
|
hasOptions,
|
|
stuecklisteItems,
|
|
optionsItems,
|
|
selectableOtherArticles
|
|
) {
|
|
const { activeListTab } = this.state;
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, flex: 1, minHeight: 0 }}>
|
|
<Tabs
|
|
value={activeListTab}
|
|
onChange={(_e, val) => this.setState({ activeListTab: val })}
|
|
sx={{
|
|
minHeight: 32,
|
|
mb: 0.5,
|
|
borderBottom: '1px solid',
|
|
borderColor: 'divider',
|
|
'& .MuiTab-root': {
|
|
minHeight: 32,
|
|
py: 0.25,
|
|
px: 1.5,
|
|
textTransform: 'none',
|
|
fontSize: '0.8rem',
|
|
fontWeight: 600,
|
|
},
|
|
}}
|
|
>
|
|
<Tab
|
|
value="stueckliste"
|
|
label={isStueckliste ? `Stückliste (${stuecklisteItems.length})` : '+ Stückliste'}
|
|
icon={<LayersIcon sx={{ fontSize: 16 }} />}
|
|
iconPosition="start"
|
|
/>
|
|
<Tab
|
|
value="options"
|
|
label={hasOptions ? `Optionen (${optionsItems.length})` : '+ Optionen'}
|
|
icon={<PlaylistAddCheckIcon sx={{ fontSize: 16 }} />}
|
|
iconPosition="start"
|
|
/>
|
|
</Tabs>
|
|
|
|
{activeListTab === 'stueckliste'
|
|
? this.renderStuecklisteContent(stuecklisteItems, selectableOtherArticles, isStueckliste)
|
|
: this.renderOptionsContent(optionsItems, selectableOtherArticles, hasOptions)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
renderChildView(parentSku, parentArticle, attributes, assignedAttributes) {
|
|
const { article: articleData } = this.props;
|
|
const parentAttrName =
|
|
parentArticle?.parentAttributeName ||
|
|
(parentArticle?.parentAttributeNameId
|
|
? attributes.find((a) => a.id === parentArticle.parentAttributeNameId)?.name
|
|
: null) ||
|
|
'Varianten';
|
|
|
|
const selfInParent = (parentArticle?.children || []).find(
|
|
(c) => c.sku === articleData.sku || c.id === articleData.id
|
|
);
|
|
const assignedMatch = (assignedAttributes || []).find(
|
|
(a) =>
|
|
(parentArticle?.parentAttributeNameId != null &&
|
|
Number(a.attributeId) === Number(parentArticle.parentAttributeNameId)) ||
|
|
(a.attributeName &&
|
|
parentAttrName &&
|
|
String(a.attributeName).toLowerCase() === String(parentAttrName).toLowerCase())
|
|
);
|
|
|
|
const variationValue =
|
|
selfInParent?.attributeValue ||
|
|
assignedMatch?.attributeValue ||
|
|
null;
|
|
|
|
const siblings = (parentArticle?.children || []).filter(
|
|
(c) => c.sku !== articleData.sku && c.id !== articleData.id
|
|
);
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}>
|
|
{/* Header: Variation [Grün] für [Farbe] */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, flexWrap: 'wrap' }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.88rem' }}>
|
|
Variation
|
|
</Typography>
|
|
{variationValue ? (
|
|
<Chip
|
|
label={variationValue}
|
|
size="small"
|
|
color="primary"
|
|
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 600 }}
|
|
/>
|
|
) : null}
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.88rem' }}>
|
|
für
|
|
</Typography>
|
|
<Chip
|
|
label={parentAttrName}
|
|
size="small"
|
|
variant="outlined"
|
|
color="primary"
|
|
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 600 }}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Link Vater */}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mt: 0.25 }}>
|
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
|
Vaterartikel:
|
|
</Typography>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
px: 1,
|
|
py: 0.6,
|
|
borderRadius: 1,
|
|
bgcolor: 'background.paper',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
transition: 'all 0.15s ease-in-out',
|
|
'&:hover': {
|
|
borderColor: 'primary.main',
|
|
boxShadow: SHADOW_CARD,
|
|
},
|
|
}}
|
|
>
|
|
<Link
|
|
component={RouterLink}
|
|
to={artikelDetailPath(parentSku)}
|
|
sx={{
|
|
minWidth: 0,
|
|
flex: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
textDecoration: 'none',
|
|
color: 'inherit',
|
|
py: 0.25,
|
|
cursor: 'pointer',
|
|
'&:hover .link-title': {
|
|
color: 'primary.main',
|
|
},
|
|
'&:hover .link-sku': {
|
|
color: 'primary.dark',
|
|
textDecoration: 'underline',
|
|
},
|
|
}}
|
|
>
|
|
<Typography
|
|
className="link-title"
|
|
variant="body2"
|
|
sx={{
|
|
fontWeight: 600,
|
|
fontSize: '0.86rem',
|
|
lineHeight: 1.3,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
color: 'text.primary',
|
|
transition: 'color 0.15s ease',
|
|
}}
|
|
>
|
|
{parentArticle?.name || parentSku}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
|
|
<Typography
|
|
className="link-sku"
|
|
variant="caption"
|
|
sx={{
|
|
fontSize: '0.78rem',
|
|
fontWeight: 600,
|
|
color: 'primary.main',
|
|
fontFamily: 'monospace',
|
|
letterSpacing: '0.02em',
|
|
}}
|
|
>
|
|
{parentSku}
|
|
</Typography>
|
|
<OpenInNewIcon sx={{ fontSize: 13, color: 'primary.main', opacity: 0.8 }} />
|
|
</Box>
|
|
</Link>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Link(s) Geschwister */}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mt: 0.5 }}>
|
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
|
Geschwister:
|
|
</Typography>
|
|
{siblings.length === 0 ? (
|
|
<Typography variant="body2" color="text.secondary" sx={{ py: 0.5, fontSize: '0.8rem' }}>
|
|
Keine weiteren Varianten vorhanden.
|
|
</Typography>
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
{siblings.map((sibling) => (
|
|
<Box
|
|
key={sibling.id || sibling.sku}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
px: 1,
|
|
py: 0.6,
|
|
borderRadius: 1,
|
|
bgcolor: 'background.paper',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
transition: 'all 0.15s ease-in-out',
|
|
'&:hover': {
|
|
borderColor: 'primary.main',
|
|
boxShadow: SHADOW_CARD,
|
|
},
|
|
}}
|
|
>
|
|
<Link
|
|
component={RouterLink}
|
|
to={artikelDetailPath(sibling.sku)}
|
|
sx={{
|
|
minWidth: 0,
|
|
flex: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
textDecoration: 'none',
|
|
color: 'inherit',
|
|
py: 0.25,
|
|
cursor: 'pointer',
|
|
'&:hover .child-name': {
|
|
color: 'primary.main',
|
|
},
|
|
'&:hover .child-sku': {
|
|
color: 'primary.dark',
|
|
textDecoration: 'underline',
|
|
},
|
|
}}
|
|
>
|
|
<Typography
|
|
className="child-name"
|
|
variant="body2"
|
|
sx={{
|
|
fontWeight: 600,
|
|
fontSize: '0.86rem',
|
|
lineHeight: 1.3,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
color: 'text.primary',
|
|
transition: 'color 0.15s ease',
|
|
}}
|
|
>
|
|
{sibling.attributeValue || sibling.name || sibling.sku}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
|
|
<Typography
|
|
className="child-sku"
|
|
variant="caption"
|
|
sx={{
|
|
fontSize: '0.78rem',
|
|
fontWeight: 600,
|
|
color: 'primary.main',
|
|
fontFamily: 'monospace',
|
|
letterSpacing: '0.02em',
|
|
}}
|
|
>
|
|
{sibling.sku}
|
|
</Typography>
|
|
<OpenInNewIcon sx={{ fontSize: 13, color: 'primary.main', opacity: 0.8 }} />
|
|
</Box>
|
|
</Link>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
renderStandardView(selectableAttributes, assignedAttributeIds) {
|
|
const { busy, activeCreatorMode, selectedAttributeId } = this.state;
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
{activeCreatorMode === 'parent' ? (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<AccountTreeIcon sx={{ color: 'primary.main', fontSize: 18 }} />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.88rem' }}>
|
|
Vaterartikel aktivieren
|
|
</Typography>
|
|
</Box>
|
|
<Button
|
|
size="small"
|
|
color="inherit"
|
|
disabled={busy}
|
|
onClick={() => this.setState({ activeCreatorMode: null, selectedAttributeId: '' })}
|
|
>
|
|
Abbrechen
|
|
</Button>
|
|
</Box>
|
|
|
|
{selectableAttributes.length === 0 ? (
|
|
<Typography variant="caption" color="text.secondary">
|
|
Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen.
|
|
</Typography>
|
|
) : (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
|
|
<TextInput
|
|
select
|
|
fullWidth
|
|
label="Merkmal für Varianten"
|
|
value={selectedAttributeId}
|
|
onChange={(e) => this.setState({ selectedAttributeId: e.target.value })}
|
|
sx={{ flex: 1, minWidth: 0 }}
|
|
>
|
|
<MenuItem value="" disabled>
|
|
<em>Merkmal wählen...</em>
|
|
</MenuItem>
|
|
{selectableAttributes.map((a) => (
|
|
<MenuItem key={a.id} value={a.id}>
|
|
{a.name}
|
|
</MenuItem>
|
|
))}
|
|
</TextInput>
|
|
|
|
<Button
|
|
variant="contained"
|
|
disabled={
|
|
busy ||
|
|
!selectedAttributeId ||
|
|
assignedAttributeIds.has(Number(selectedAttributeId)) ||
|
|
selectableAttributes.length === 0
|
|
}
|
|
onClick={this.handleEnableParent}
|
|
sx={{ height: 38, flexShrink: 0 }}
|
|
>
|
|
{busy ? <CircularProgress size={14} color="inherit" /> : 'Aktivieren'}
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
|
<Button
|
|
variant="outlined"
|
|
color="primary"
|
|
startIcon={<AccountTreeIcon sx={{ fontSize: 16 }} />}
|
|
onClick={() => this.setState({ activeCreatorMode: 'parent' })}
|
|
>
|
|
Als Vaterartikel aktivieren
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
color="primary"
|
|
startIcon={<LayersIcon sx={{ fontSize: 16 }} />}
|
|
onClick={() =>
|
|
this.setState({ activeCreatorMode: 'stueckliste', activeListTab: 'stueckliste' })
|
|
}
|
|
>
|
|
Als Stückliste anlegen
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
color="primary"
|
|
startIcon={<PlaylistAddCheckIcon sx={{ fontSize: 16 }} />}
|
|
onClick={() =>
|
|
this.setState({ activeCreatorMode: 'options', activeListTab: 'options' })
|
|
}
|
|
>
|
|
Optionen hinzufügen
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
render() {
|
|
const { article: articleData } = this.props;
|
|
const {
|
|
attributes,
|
|
assignedAttributes,
|
|
allArticles,
|
|
busy,
|
|
activeCreatorMode,
|
|
selectedChildValueId,
|
|
newChildText,
|
|
isCustomChildValue,
|
|
removeTargetChild,
|
|
confirmClearType,
|
|
} = this.state;
|
|
|
|
if (!articleData) return null;
|
|
|
|
const isChild = articleData.parentArticleId != null || Boolean(articleData.parentSku);
|
|
const isParent = Boolean(articleData.isParent);
|
|
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 parent mode
|
|
const assignedAttributeIds = new Set(
|
|
(assignedAttributes || []).map((a) => a.attributeId)
|
|
);
|
|
const selectableAttributes = attributes.filter(
|
|
(a) => !assignedAttributeIds.has(a.id)
|
|
);
|
|
|
|
// Filter available articles for Autocomplete (cannot select self, and cannot select parent articles)
|
|
const selectableOtherArticles = allArticles.filter(
|
|
(a) => a.sku !== articleData.sku && !a.isParent && !a.is_parent
|
|
);
|
|
|
|
// Find parent attribute definition & values
|
|
const parentAttrDef = isParent
|
|
? attributes.find((a) => a.id === articleData.parentAttributeNameId)
|
|
: null;
|
|
const allParentValues = parentAttrDef?.values || [];
|
|
const assignedValueIds = new Set(
|
|
children.map((c) => c.attributeValueId).filter((id) => id != null)
|
|
);
|
|
const availableParentValues = allParentValues.filter((v) => !assignedValueIds.has(v.id));
|
|
|
|
const parentSku = articleData.parentSku;
|
|
const parentArticle = parentSku ? getArticle(parentSku) : null;
|
|
|
|
return (
|
|
<PanelTile wide={1} minHeight={200} color={MAGENTA_PANEL}>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 1 }}>
|
|
{/* ── Mode 1: Parent Article ──────────────────────────────────────── */}
|
|
{isParent ? (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, flex: 1, minHeight: 0 }}>
|
|
{isChild && parentSku ? (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
|
<AccountTreeIcon sx={{ color: 'text.secondary', fontSize: 16 }} />
|
|
<Typography variant="caption" color="text.secondary">
|
|
Übergeordnete Variante von:
|
|
</Typography>
|
|
<Link
|
|
component={RouterLink}
|
|
to={artikelDetailPath(parentSku)}
|
|
sx={{
|
|
fontWeight: 600,
|
|
fontSize: '0.82rem',
|
|
color: 'primary.main',
|
|
textDecoration: 'none',
|
|
fontFamily: 'monospace',
|
|
'&:hover': { textDecoration: 'underline' },
|
|
}}
|
|
>
|
|
{parentSku}
|
|
</Link>
|
|
</Box>
|
|
) : null}
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.88rem' }}>
|
|
Vater für Auswahl-Merkmal
|
|
</Typography>
|
|
<Chip
|
|
label={articleData.parentAttributeName || parentAttrDef?.name || 'Varianten'}
|
|
size="small"
|
|
color="primary"
|
|
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 600 }}
|
|
/>
|
|
</Box>
|
|
|
|
{children.length === 0 ? (
|
|
<Tooltip title="Vaterartikel-Modus deaktivieren">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
onClick={this.handleDisableParent}
|
|
disabled={busy}
|
|
sx={{ p: 0.4, color: 'text.secondary', '&:hover': { color: 'error.main' } }}
|
|
>
|
|
<PowerSettingsNewIcon fontSize="small" sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
) : null}
|
|
</Box>
|
|
|
|
{/* Add Child Variant Row */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
|
{isCustomChildValue || availableParentValues.length === 0 ? (
|
|
<TextInput
|
|
label="Neuer Wert"
|
|
placeholder="Neuer Wert..."
|
|
value={newChildText}
|
|
disabled={busy}
|
|
onChange={(e) => this.setState({ newChildText: e.target.value })}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
void this.handleAddChild();
|
|
}
|
|
}}
|
|
sx={{ flex: 1, minWidth: 0 }}
|
|
/>
|
|
) : (
|
|
<TextInput
|
|
select
|
|
label="Variante"
|
|
value={selectedChildValueId}
|
|
disabled={busy}
|
|
onChange={(e) => this.setState({ selectedChildValueId: e.target.value })}
|
|
sx={{ flex: 1, minWidth: 0 }}
|
|
>
|
|
<MenuItem value="" disabled>
|
|
<em>Variante wählen...</em>
|
|
</MenuItem>
|
|
{availableParentValues.map((v) => (
|
|
<MenuItem key={v.id} value={v.id}>
|
|
{v.value}
|
|
</MenuItem>
|
|
))}
|
|
</TextInput>
|
|
)}
|
|
|
|
<Tooltip title="Variante hinzufügen">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
color="primary"
|
|
disabled={
|
|
busy ||
|
|
(isCustomChildValue || availableParentValues.length === 0
|
|
? !newChildText.trim()
|
|
: !selectedChildValueId)
|
|
}
|
|
onClick={this.handleAddChild}
|
|
sx={{
|
|
bgcolor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': { bgcolor: 'primary.dark' },
|
|
'&.Mui-disabled': {
|
|
bgcolor: 'action.disabledBackground',
|
|
color: 'action.disabled',
|
|
},
|
|
p: 0.6,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
{/* Toggle custom value / predefined value if values exist */}
|
|
{allParentValues.length > 0 && availableParentValues.length > 0 ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: -0.25 }}>
|
|
<Link
|
|
component="button"
|
|
type="button"
|
|
variant="caption"
|
|
onClick={() =>
|
|
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'}
|
|
</Link>
|
|
</Box>
|
|
) : null}
|
|
|
|
{/* Children List */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 0.5,
|
|
mt: 0.5,
|
|
}}
|
|
>
|
|
{children.length === 0 ? (
|
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: '0.8rem' }}>
|
|
Noch keine Varianten angelegt.
|
|
</Typography>
|
|
) : (
|
|
children.map((child) => (
|
|
<Box
|
|
key={child.id || child.sku}
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
px: 1,
|
|
py: 0.6,
|
|
borderRadius: 1,
|
|
bgcolor: 'background.paper',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
transition: 'all 0.15s ease-in-out',
|
|
'&:hover': {
|
|
borderColor: 'primary.main',
|
|
boxShadow: SHADOW_CARD,
|
|
},
|
|
}}
|
|
>
|
|
<Link
|
|
component={RouterLink}
|
|
to={artikelDetailPath(child.sku)}
|
|
sx={{
|
|
minWidth: 0,
|
|
flex: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
textDecoration: 'none',
|
|
color: 'inherit',
|
|
py: 0.25,
|
|
mr: 1,
|
|
cursor: 'pointer',
|
|
'&:hover .child-name': {
|
|
color: 'primary.main',
|
|
},
|
|
'&:hover .child-sku': {
|
|
color: 'primary.dark',
|
|
textDecoration: 'underline',
|
|
},
|
|
}}
|
|
>
|
|
<Typography
|
|
className="child-name"
|
|
variant="body2"
|
|
sx={{
|
|
fontWeight: 600,
|
|
fontSize: '0.86rem',
|
|
lineHeight: 1.3,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
color: 'text.primary',
|
|
transition: 'color 0.15s ease',
|
|
}}
|
|
>
|
|
{child.attributeValue || child.name || child.sku}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
|
|
<Typography
|
|
className="child-sku"
|
|
variant="caption"
|
|
sx={{
|
|
fontSize: '0.78rem',
|
|
fontWeight: 600,
|
|
color: 'primary.main',
|
|
fontFamily: 'monospace',
|
|
letterSpacing: '0.02em',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 0.35,
|
|
}}
|
|
>
|
|
{child.sku}
|
|
</Typography>
|
|
<OpenInNewIcon sx={{ fontSize: 13, color: 'primary.main', opacity: 0.8 }} />
|
|
</Box>
|
|
</Link>
|
|
|
|
<IconButton
|
|
size="small"
|
|
disabled={busy}
|
|
onClick={() => this.handleOpenRemoveDialog(child)}
|
|
title="Variante entfernen"
|
|
sx={{
|
|
p: 0.35,
|
|
color: 'text.secondary',
|
|
'&:hover': { color: 'error.main' },
|
|
}}
|
|
>
|
|
<DeleteOutlinedIcon sx={{ fontSize: 17 }} />
|
|
</IconButton>
|
|
</Box>
|
|
))
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
) : isChild && parentSku ? (
|
|
/* ── Mode 2: Child Variant Article ───────────────────────────────── */
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}>
|
|
{this.renderChildView(
|
|
parentSku,
|
|
parentArticle,
|
|
attributes,
|
|
assignedAttributes
|
|
)}
|
|
<Box sx={{ mt: 0.5, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
|
|
{isStueckliste ||
|
|
hasOptions ||
|
|
activeCreatorMode === 'stueckliste' ||
|
|
activeCreatorMode === 'options'
|
|
? this.renderCompoundManager(
|
|
isStueckliste,
|
|
hasOptions,
|
|
stuecklisteItems,
|
|
optionsItems,
|
|
selectableOtherArticles
|
|
)
|
|
: this.renderStandardView(selectableAttributes, assignedAttributeIds)}
|
|
</Box>
|
|
</Box>
|
|
) : isStueckliste ||
|
|
hasOptions ||
|
|
activeCreatorMode === 'stueckliste' ||
|
|
activeCreatorMode === 'options' ? (
|
|
/* ── Mode 3: Combined Compound Manager (Stückliste & Optionen) ───── */
|
|
this.renderCompoundManager(
|
|
isStueckliste,
|
|
hasOptions,
|
|
stuecklisteItems,
|
|
optionsItems,
|
|
selectableOtherArticles
|
|
)
|
|
) : (
|
|
/* ── Mode 4: Standard Article ────────────────────────────────────── */
|
|
this.renderStandardView(selectableAttributes, assignedAttributeIds)
|
|
)}
|
|
</Box>
|
|
|
|
{/* Remove Child Confirmation Dialog */}
|
|
<AppDialog
|
|
open={Boolean(removeTargetChild)}
|
|
onClose={this.handleCloseRemoveDialog}
|
|
maxWidth="xs"
|
|
fullWidth
|
|
>
|
|
<DialogTitle sx={{ pb: 1, fontSize: '1rem', fontWeight: 600 }}>
|
|
Variante entfernen
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
<DialogContentText sx={{ fontSize: '0.88rem' }}>
|
|
Möchten Sie die Variante{' '}
|
|
<strong>
|
|
„{removeTargetChild?.attributeValue || removeTargetChild?.name || removeTargetChild?.sku}“
|
|
</strong>{' '}
|
|
({removeTargetChild?.sku}) vom Vaterartikel trennen oder den Artikel vollständig löschen?
|
|
</DialogContentText>
|
|
</DialogContent>
|
|
<DialogActions sx={{ px: 2, pb: 2, gap: 0.5, flexWrap: 'wrap' }}>
|
|
<Button size="small" onClick={this.handleCloseRemoveDialog} color="inherit">
|
|
Abbrechen
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
startIcon={<LinkOffIcon />}
|
|
onClick={() => void this.handleConfirmRemoveChild(false)}
|
|
>
|
|
Nur trennen
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
variant="contained"
|
|
color="error"
|
|
startIcon={<DeleteOutlinedIcon />}
|
|
onClick={() => void this.handleConfirmRemoveChild(true)}
|
|
>
|
|
Vollständig löschen
|
|
</Button>
|
|
</DialogActions>
|
|
</AppDialog>
|
|
|
|
{/* Clear Stückliste / Optionen Confirmation Dialog */}
|
|
<AppDialog
|
|
open={Boolean(confirmClearType)}
|
|
onClose={() => this.setState({ confirmClearType: null })}
|
|
maxWidth="xs"
|
|
fullWidth
|
|
>
|
|
<DialogTitle sx={{ pb: 1, fontSize: '1rem', fontWeight: 600 }}>
|
|
{confirmClearType === 'stueckliste' ? 'Stückliste auflösen?' : 'Optionen entfernen?'}
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
<DialogContentText sx={{ fontSize: '0.88rem' }}>
|
|
{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?')}
|
|
</DialogContentText>
|
|
</DialogContent>
|
|
<DialogActions sx={{ px: 2, pb: 2 }}>
|
|
<Button size="small" onClick={() => this.setState({ confirmClearType: null })} color="inherit">
|
|
Abbrechen
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
variant="contained"
|
|
color="error"
|
|
onClick={() => {
|
|
if (confirmClearType === 'stueckliste') {
|
|
void this.handleClearStueckliste();
|
|
} else if (confirmClearType === 'options') {
|
|
void this.handleClearOptions();
|
|
}
|
|
}}
|
|
>
|
|
Auflösen
|
|
</Button>
|
|
</DialogActions>
|
|
</AppDialog>
|
|
</PanelTile>
|
|
);
|
|
}
|
|
}
|