u
This commit is contained in:
@@ -482,6 +482,21 @@ export class ApiClient {
|
||||
/** DELETE /api/article/{sku}/property/{propertyId} */
|
||||
deleteProperty: (sku, propertyId) =>
|
||||
this.#json('DELETE', `/api/article/${enc(sku)}/property/${enc(propertyId)}`),
|
||||
/** POST /api/article/{sku}/father */
|
||||
toggleFather: (sku, body) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/father`, { body }),
|
||||
/** GET /api/article/{sku}/children */
|
||||
getChildren: (sku) =>
|
||||
this.#json('GET', `/api/article/${enc(sku)}/children`),
|
||||
/** POST /api/article/{sku}/children */
|
||||
addChild: (sku, body) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/children`, { body }),
|
||||
/** DELETE /api/article/{sku}/children/{childSku} */
|
||||
removeChild: (sku, childSku, { deleteArticle = false } = {}) =>
|
||||
this.#json(
|
||||
'DELETE',
|
||||
`/api/article/${enc(sku)}/children/${enc(childSku)}${deleteArticle ? '?deleteArticle=true' : ''}`
|
||||
),
|
||||
};
|
||||
|
||||
this.posterminal = {
|
||||
|
||||
@@ -479,6 +479,46 @@ export const article = {
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/article/:sku/father
|
||||
* Body: { isFather: boolean, attributeNameId?: number }
|
||||
*/
|
||||
toggleFather(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/father`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/children → { children: [...] }
|
||||
*/
|
||||
getChildren(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/children`);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/article/:sku/children
|
||||
* Body: { attributeValueId?: number, value?: string, name?: string }
|
||||
*/
|
||||
addChild(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/children`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/article/:sku/children/:childSku
|
||||
*/
|
||||
removeChild(sku, childSku, options = {}) {
|
||||
const qs = options?.deleteArticle ? '?deleteArticle=true' : '';
|
||||
return http(
|
||||
`/api/article/${encodeURIComponent(sku)}/children/${encodeURIComponent(childSku)}${qs}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ARTICLE_TAB,
|
||||
DEFAULT_ARTICLE_TAB,
|
||||
artikelDetailPath,
|
||||
articleShowsCompoundTab,
|
||||
articleSku,
|
||||
articleTabFromPathname,
|
||||
readPreferredArticleTab,
|
||||
@@ -27,7 +26,6 @@ import { matchPath } from 'react-router-dom';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from 'dashgui/panels/panelTileLayout';
|
||||
import ArticleAttributesTab from './tabs/ArticleAttributesTab';
|
||||
import ArticleCompoundTab from './tabs/ArticleCompoundTab';
|
||||
import ArticleHistoryTab from './tabs/ArticleHistoryTab';
|
||||
import ArticleLabelTab from './tabs/ArticleLabelTab';
|
||||
import ArticleSeoTab from './tabs/ArticleSeoTab';
|
||||
@@ -73,14 +71,6 @@ const ALWAYS_TABS = [
|
||||
},
|
||||
];
|
||||
|
||||
const COMPOUND_TAB = {
|
||||
id: ARTICLE_TAB.compound,
|
||||
label: 'Compound Editor',
|
||||
color: '#e9d5ff',
|
||||
Icon: AccountTreeIcon,
|
||||
help: 'Stückliste und Unterartikel für diesen Set-Artikel verwalten',
|
||||
};
|
||||
|
||||
const SETTINGS_TAB = {
|
||||
id: ARTICLE_TAB.settings,
|
||||
label: 'Einstellungen',
|
||||
@@ -89,13 +79,8 @@ const SETTINGS_TAB = {
|
||||
help: 'Artikeleinstellungen, Einheiten, Steuersatz und Löschen',
|
||||
};
|
||||
|
||||
function visibleTabs(article) {
|
||||
const tabs = [...ALWAYS_TABS];
|
||||
if (articleShowsCompoundTab(article)) {
|
||||
tabs.push(COMPOUND_TAB);
|
||||
}
|
||||
tabs.push(SETTINGS_TAB);
|
||||
return tabs;
|
||||
function visibleTabs() {
|
||||
return [...ALWAYS_TABS, SETTINGS_TAB];
|
||||
}
|
||||
|
||||
function readRouteTab() {
|
||||
@@ -185,8 +170,6 @@ class ArticleTileTab extends PureComponent {
|
||||
return <ArticleLabelTab article={articleData} />;
|
||||
case ARTICLE_TAB.attributes:
|
||||
return <ArticleAttributesTab article={articleData} sku={articleData.sku} />;
|
||||
case ARTICLE_TAB.compound:
|
||||
return <ArticleCompoundTab />;
|
||||
case ARTICLE_TAB.settings:
|
||||
return (
|
||||
<ArticleSettingsTab
|
||||
|
||||
@@ -6,7 +6,6 @@ export const ARTICLE_TAB = {
|
||||
seo: 'seo',
|
||||
label: 'label',
|
||||
attributes: 'attributes',
|
||||
compound: 'compound',
|
||||
settings: 'settings',
|
||||
};
|
||||
|
||||
@@ -31,22 +30,8 @@ export function articleSku(article) {
|
||||
return sku || null;
|
||||
}
|
||||
|
||||
export function articleShowsCompoundTab(article) {
|
||||
return (
|
||||
article?.articleListCount > 0 ||
|
||||
article?.optionsCount > 0 ||
|
||||
article?.childCount > 0 ||
|
||||
article?.fatherArticleId != null
|
||||
);
|
||||
}
|
||||
|
||||
export function articleTabIds(article) {
|
||||
const ids = [...BASE_ARTICLE_TAB_IDS];
|
||||
if (articleShowsCompoundTab(article)) {
|
||||
ids.push(ARTICLE_TAB.compound);
|
||||
}
|
||||
ids.push(ARTICLE_TAB.settings);
|
||||
return ids;
|
||||
export function articleTabIds() {
|
||||
return [...BASE_ARTICLE_TAB_IDS, ARTICLE_TAB.settings];
|
||||
}
|
||||
|
||||
export function isArticleTabId(tab) {
|
||||
|
||||
@@ -1,64 +1,729 @@
|
||||
import { Component, PureComponent } from 'react';
|
||||
import { PureComponent } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Link from '@mui/material/Link';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import LinkOffIcon from '@mui/icons-material/LinkOff';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { artikelDetailPath } from '../articleRoute';
|
||||
|
||||
class CountLine extends Component {
|
||||
render() {
|
||||
const { label, count } = this.props;
|
||||
if (count == null) return null;
|
||||
return (
|
||||
<Typography variant="body2">
|
||||
{label}: {count}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RelationLine extends Component {
|
||||
render() {
|
||||
const { label, active, children } = this.props;
|
||||
return (
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
{label}:{' '}
|
||||
{active ? (
|
||||
children ?? 'ja'
|
||||
) : (
|
||||
<Typography component="span" variant="body2" color="text.secondary">
|
||||
nein
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
}
|
||||
import { article as articleApi } from '../../../api';
|
||||
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
|
||||
import artikelAutomat from '../../../automats/artikelAutomat';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
|
||||
export default class ArticleViewLists extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const initialAttributes = selectAttributes() || [];
|
||||
this.state = {
|
||||
attributes: initialAttributes,
|
||||
assignedAttributes: [],
|
||||
busy: false,
|
||||
isActivating: false,
|
||||
selectedAttributeId: '',
|
||||
selectedChildValueId: '',
|
||||
newChildText: '',
|
||||
isCustomChildValue: false,
|
||||
|
||||
// Remove dialog
|
||||
removeTargetChild: null, // { sku, attributeValue }
|
||||
};
|
||||
this._cancelled = false;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
this.unsubAttr = attributeAutomat.subscribe((state) => {
|
||||
if (this._cancelled) return;
|
||||
this.setState((prev) => {
|
||||
const assignedIds = new Set(prev.assignedAttributes.map((a) => a.attributeId));
|
||||
const selectable = state.attributes.filter((a) => !assignedIds.has(a.id));
|
||||
let nextSelectedId = prev.selectedAttributeId;
|
||||
if ((!nextSelectedId || assignedIds.has(Number(nextSelectedId))) && selectable[0]) {
|
||||
nextSelectedId = selectable[0].id;
|
||||
}
|
||||
return {
|
||||
attributes: state.attributes,
|
||||
selectedAttributeId: nextSelectedId,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
if (attributeAutomat.state.status === 'idle') {
|
||||
void attributeAutomat.actions.load();
|
||||
}
|
||||
|
||||
if (this.props.article?.sku) {
|
||||
void this.loadAssignedAttributes(this.props.article.sku);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.article?.sku !== this.props.article?.sku) {
|
||||
if (this.props.article?.sku) {
|
||||
void this.loadAssignedAttributes(this.props.article.sku);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
this.unsubAttr?.();
|
||||
}
|
||||
|
||||
getApi() {
|
||||
return this.context?.article || articleApi;
|
||||
}
|
||||
|
||||
async loadAssignedAttributes(sku) {
|
||||
if (!sku) return;
|
||||
try {
|
||||
const api = this.getApi();
|
||||
const res = await api.getAttributeValues(sku);
|
||||
if (this._cancelled) return;
|
||||
const assigned = Array.isArray(res?.attributevalues) ? res.attributevalues : [];
|
||||
this.setState((prev) => {
|
||||
const assignedIds = new Set(assigned.map((a) => a.attributeId));
|
||||
const selectable = prev.attributes.filter((a) => !assignedIds.has(a.id));
|
||||
let nextSelectedId = prev.selectedAttributeId;
|
||||
if ((!nextSelectedId || assignedIds.has(Number(nextSelectedId))) && selectable[0]) {
|
||||
nextSelectedId = selectable[0].id;
|
||||
} else if (selectable.length === 0) {
|
||||
nextSelectedId = '';
|
||||
}
|
||||
return {
|
||||
assignedAttributes: assigned,
|
||||
selectedAttributeId: nextSelectedId,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
handleStartActivating = async () => {
|
||||
this.setState({ isActivating: true });
|
||||
await this.loadAssignedAttributes(this.props.article?.sku);
|
||||
};
|
||||
|
||||
handleEnableFather = async () => {
|
||||
const { article } = this.props;
|
||||
const { selectedAttributeId, assignedAttributes, busy } = this.state;
|
||||
if (busy || !article?.sku || !selectedAttributeId) return;
|
||||
|
||||
const assignedIds = new Set(assignedAttributes.map((a) => a.attributeId));
|
||||
if (assignedIds.has(Number(selectedAttributeId))) {
|
||||
enqueueSnackbar('Dieses Merkmal ist dem Artikel bereits zugewiesen.', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const api = this.getApi();
|
||||
const res = await api.toggleFather(article.sku, {
|
||||
isFather: true,
|
||||
attributeNameId: Number(selectedAttributeId),
|
||||
});
|
||||
if (this._cancelled) return;
|
||||
|
||||
if (res?.article) {
|
||||
artikelAutomat.actions.setArticle(res.article);
|
||||
} else {
|
||||
await artikelAutomat.actions.reloadArticle(article.sku);
|
||||
}
|
||||
|
||||
enqueueSnackbar('Vaterartikel-Modus aktiviert.', { variant: 'success' });
|
||||
this.setState({ busy: false, isActivating: false });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
};
|
||||
|
||||
handleDisableFather = async () => {
|
||||
const { article } = this.props;
|
||||
const { busy } = this.state;
|
||||
if (busy || !article?.sku) return;
|
||||
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const api = this.getApi();
|
||||
const res = await api.toggleFather(article.sku, { isFather: false });
|
||||
if (this._cancelled) return;
|
||||
|
||||
if (res?.article) {
|
||||
artikelAutomat.actions.setArticle(res.article);
|
||||
} else {
|
||||
await artikelAutomat.actions.reloadArticle(article.sku);
|
||||
}
|
||||
|
||||
enqueueSnackbar('Vaterartikel-Modus deaktiviert.', { variant: 'success' });
|
||||
this.setState({ busy: false });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
};
|
||||
|
||||
handleAddChild = async () => {
|
||||
const { article } = this.props;
|
||||
const { selectedChildValueId, newChildText, isCustomChildValue, busy } = this.state;
|
||||
if (busy || !article?.sku) return;
|
||||
|
||||
const payload = {};
|
||||
if (isCustomChildValue) {
|
||||
const val = newChildText.trim();
|
||||
if (!val) return;
|
||||
payload.value = val;
|
||||
} else {
|
||||
if (!selectedChildValueId) return;
|
||||
payload.attributeValueId = Number(selectedChildValueId);
|
||||
}
|
||||
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const api = this.getApi();
|
||||
await api.addChild(article.sku, payload);
|
||||
if (this._cancelled) return;
|
||||
|
||||
await artikelAutomat.actions.reloadArticle(article.sku);
|
||||
if (this._cancelled) return;
|
||||
|
||||
enqueueSnackbar('Variante erfolgreich angelegt.', { variant: 'success' });
|
||||
this.setState({
|
||||
busy: false,
|
||||
selectedChildValueId: '',
|
||||
newChildText: '',
|
||||
});
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
};
|
||||
|
||||
handleOpenRemoveDialog = (child) => {
|
||||
this.setState({ removeTargetChild: child });
|
||||
};
|
||||
|
||||
handleCloseRemoveDialog = () => {
|
||||
this.setState({ removeTargetChild: null });
|
||||
};
|
||||
|
||||
handleConfirmRemoveChild = async (deletePermanently) => {
|
||||
const { article } = this.props;
|
||||
const { removeTargetChild, busy } = this.state;
|
||||
if (busy || !article?.sku || !removeTargetChild?.sku) return;
|
||||
|
||||
const childSku = removeTargetChild.sku;
|
||||
this.setState({ busy: true, removeTargetChild: null });
|
||||
try {
|
||||
const api = this.getApi();
|
||||
await api.removeChild(article.sku, childSku, {
|
||||
deleteArticle: deletePermanently,
|
||||
});
|
||||
if (this._cancelled) return;
|
||||
|
||||
await artikelAutomat.actions.reloadArticle(article.sku);
|
||||
if (this._cancelled) return;
|
||||
|
||||
enqueueSnackbar(
|
||||
deletePermanently ? 'Variante vollständig gelöscht.' : 'Variante vom Vaterartikel getrennt.',
|
||||
{ variant: 'success' }
|
||||
);
|
||||
this.setState({ busy: false });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { article: articleData } = this.props;
|
||||
const {
|
||||
attributes,
|
||||
assignedAttributes,
|
||||
busy,
|
||||
isActivating,
|
||||
selectedAttributeId,
|
||||
selectedChildValueId,
|
||||
newChildText,
|
||||
isCustomChildValue,
|
||||
removeTargetChild,
|
||||
} = this.state;
|
||||
|
||||
if (!articleData) return null;
|
||||
|
||||
const isChild = articleData.fatherArticleId != null || Boolean(articleData.fatherSku);
|
||||
const isFather = Boolean(articleData.isFather);
|
||||
const children = Array.isArray(articleData.children) ? articleData.children : [];
|
||||
|
||||
// Attributes assigned to this article that cannot be selected for father mode
|
||||
const assignedAttributeIds = new Set(
|
||||
(assignedAttributes || []).map((a) => a.attributeId)
|
||||
);
|
||||
const selectableAttributes = attributes.filter(
|
||||
(a) => !assignedAttributeIds.has(a.id)
|
||||
);
|
||||
|
||||
// Find father attribute definition & values
|
||||
const fatherAttrDef = isFather
|
||||
? attributes.find((a) => a.id === articleData.fatherAttributeNameId)
|
||||
: null;
|
||||
const allFatherValues = fatherAttrDef?.values || [];
|
||||
const assignedValueIds = new Set(
|
||||
children.map((c) => c.attributeValueId).filter((id) => id != null)
|
||||
);
|
||||
const availableFatherValues = allFatherValues.filter((v) => !assignedValueIds.has(v.id));
|
||||
|
||||
return (
|
||||
<PanelTile wide={1} minHeight={200} color="#fbcfe8">
|
||||
<RelationLine label="Vaterartikel" active={articleData.childCount > 0}>
|
||||
{articleData.childCount}
|
||||
</RelationLine>
|
||||
<RelationLine
|
||||
label="Kindartikel"
|
||||
active={articleData.fatherArticleId != null}
|
||||
>
|
||||
{articleData.fatherSku ? (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={artikelDetailPath(articleData.fatherSku)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 1 }}>
|
||||
{/* If article is a Child / Variant of another article, show link to its father */}
|
||||
{isChild && articleData.fatherSku ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{articleData.fatherSku}
|
||||
</Link>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<AccountTreeIcon sx={{ color: 'text.secondary', fontSize: 16 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Variante von:
|
||||
</Typography>
|
||||
</Box>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={artikelDetailPath(articleData.fatherSku)}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.82rem',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{articleData.fatherSku}
|
||||
</Link>
|
||||
</Box>
|
||||
) : null}
|
||||
</RelationLine>
|
||||
<CountLine label="Optionen" count={articleData.optionsCount} />
|
||||
<CountLine label="Stücklisten" count={articleData.articleListCount} />
|
||||
<CountLine label="Varianten" count={articleData.childCount} />
|
||||
|
||||
{/* Father Mode or Activation (supported on both root and child articles) */}
|
||||
{isFather ? (
|
||||
<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 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.88rem' }}>
|
||||
Vaterartikel
|
||||
</Typography>
|
||||
<Chip
|
||||
label={articleData.fatherAttributeName || fatherAttrDef?.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.handleDisableFather}
|
||||
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 || availableFatherValues.length === 0 ? (
|
||||
<TextField
|
||||
size="small"
|
||||
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,
|
||||
bgcolor: 'background.paper',
|
||||
'& .MuiInputBase-input': { py: 0.5, px: 1, fontSize: '0.82rem' },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormControl size="small" sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={selectedChildValueId}
|
||||
displayEmpty
|
||||
disabled={busy}
|
||||
onChange={(e) => this.setState({ selectedChildValueId: e.target.value })}
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
fontSize: '0.82rem',
|
||||
height: 32,
|
||||
'& .MuiSelect-select': { py: 0.5, px: 1 },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="" disabled>
|
||||
<em>Variante wählen...</em>
|
||||
</MenuItem>
|
||||
{availableFatherValues.map((v) => (
|
||||
<MenuItem key={v.id} value={v.id} sx={{ fontSize: '0.82rem' }}>
|
||||
{v.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<Tooltip title="Variante hinzufügen">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
disabled={
|
||||
busy ||
|
||||
(isCustomChildValue || availableFatherValues.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 */}
|
||||
{allFatherValues.length > 0 && availableFatherValues.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,
|
||||
maxHeight: 160,
|
||||
overflowY: 'auto',
|
||||
pr: 0.25,
|
||||
}}
|
||||
>
|
||||
{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.4,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{child.attributeValue || child.name || child.sku}
|
||||
</Typography>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={artikelDetailPath(child.sku)}
|
||||
sx={{
|
||||
fontSize: '0.72rem',
|
||||
color: 'text.secondary',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{child.sku}
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => this.handleOpenRemoveDialog(child)}
|
||||
sx={{
|
||||
p: 0.25,
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'error.main' },
|
||||
}}
|
||||
>
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
/* Case 3: Regular Article (neither father nor child) */
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
Vaterartikel:
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
nein
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{!isActivating ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
color="inherit"
|
||||
onClick={this.handleStartActivating}
|
||||
sx={{
|
||||
fontSize: '0.78rem',
|
||||
textTransform: 'none',
|
||||
bgcolor: 'background.paper',
|
||||
borderColor: 'divider',
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
Als Vaterartikel aktivieren
|
||||
</Button>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{selectableAttributes.length === 0 ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen.
|
||||
</Typography>
|
||||
) : (
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="activate-attr-label" sx={{ fontSize: '0.8rem' }}>
|
||||
Merkmal für Varianten
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="activate-attr-label"
|
||||
label="Merkmal für Varianten"
|
||||
value={selectedAttributeId}
|
||||
size="small"
|
||||
onChange={(e) => this.setState({ selectedAttributeId: e.target.value })}
|
||||
sx={{ fontSize: '0.82rem', height: 32 }}
|
||||
>
|
||||
{attributes.map((a) => {
|
||||
const isAssigned = assignedAttributeIds.has(a.id);
|
||||
return (
|
||||
<MenuItem
|
||||
key={a.id}
|
||||
value={a.id}
|
||||
disabled={isAssigned}
|
||||
sx={{ fontSize: '0.82rem' }}
|
||||
>
|
||||
{a.name}
|
||||
{isAssigned ? ' (bereits zugewiesen)' : ''}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.75, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
size="small"
|
||||
color="inherit"
|
||||
disabled={busy}
|
||||
onClick={() => this.setState({ isActivating: false })}
|
||||
sx={{ fontSize: '0.75rem', py: 0.25 }}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={
|
||||
busy ||
|
||||
!selectedAttributeId ||
|
||||
assignedAttributeIds.has(Number(selectedAttributeId)) ||
|
||||
selectableAttributes.length === 0
|
||||
}
|
||||
onClick={this.handleEnableFather}
|
||||
sx={{ fontSize: '0.75rem', py: 0.25 }}
|
||||
>
|
||||
{busy ? <CircularProgress size={14} color="inherit" /> : 'Aktivieren'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Counts Line at Bottom */}
|
||||
{(articleData.optionsCount != null || articleData.articleListCount != null) && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
pt: 0.75,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'rgba(0,0,0,0.08)',
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{articleData.optionsCount != null && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Optionen: {articleData.optionsCount}
|
||||
</Typography>
|
||||
)}
|
||||
{articleData.articleListCount != null && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Stücklisten: {articleData.articleListCount}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Remove Child Confirmation Dialog */}
|
||||
<Dialog
|
||||
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>
|
||||
</Dialog>
|
||||
</PanelTile>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Component } from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export default class ArticleCompoundTab extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Compound Editor
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user