This commit is contained in:
seb
2026-09-29 18:26:30 +02:00
parent 43e1ab98b7
commit 1b9c39e975
11 changed files with 2150 additions and 6 deletions

View File

@@ -184,7 +184,7 @@ class ArticleTileTab extends PureComponent {
case ARTICLE_TAB.label:
return <ArticleLabelTab article={articleData} />;
case ARTICLE_TAB.attributes:
return <ArticleAttributesTab />;
return <ArticleAttributesTab article={articleData} sku={articleData.sku} />;
case ARTICLE_TAB.compound:
return <ArticleCompoundTab />;
case ARTICLE_TAB.settings:

View File

@@ -1,12 +1,525 @@
import { Component } from 'react';
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip';
import AddIcon from '@mui/icons-material/Add';
import { notifyError } from '../../../utils/notify';
import { enqueueSnackbar } from 'notistack';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
import propertyAutomat, { selectProperties } from '../../../automats/propertyAutomat';
import { article as articleApi } from '../../../api';
export default class ArticleAttributesTab extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
const initialAttributes = selectAttributes() || [];
const initialProperties = selectProperties() || [];
const firstAttr = initialAttributes[0];
const initialKey = firstAttr
? `attr-${firstAttr.id}`
: initialProperties[0]
? `prop-${initialProperties[0].id}`
: '';
this.state = {
loading: true,
saving: false,
assignedAttributes: [], // ArticleAttributeValue[]
assignedProperties: [], // ArticleProperty[]
globalAttributes: initialAttributes,
globalProperties: initialProperties,
selectedMerkmalKey: initialKey,
selectedItemId: firstAttr?.values?.[0]?.id ?? '',
customValueText: '',
};
this._cancelled = false;
}
componentDidMount() {
this._cancelled = false;
this.unsubAttr = attributeAutomat.subscribe((state) => {
if (this._cancelled) return;
this.setState((prev) => {
let nextKey = prev.selectedMerkmalKey;
let nextItemId = prev.selectedItemId;
if (!nextKey) {
const first = state.attributes[0];
if (first) {
nextKey = `attr-${first.id}`;
nextItemId = first.values?.[0]?.id ?? '';
}
}
return {
globalAttributes: state.attributes,
selectedMerkmalKey: nextKey,
selectedItemId: nextItemId,
};
});
});
this.unsubProp = propertyAutomat.subscribe((state) => {
if (this._cancelled) return;
this.setState((prev) => {
let nextKey = prev.selectedMerkmalKey;
if (!nextKey && !prev.globalAttributes.length && state.properties[0]) {
nextKey = `prop-${state.properties[0].id}`;
}
return {
globalProperties: state.properties,
selectedMerkmalKey: nextKey,
};
});
});
if (attributeAutomat.state.status === 'idle') {
void attributeAutomat.actions.load();
}
if (propertyAutomat.state.status === 'idle') {
void propertyAutomat.actions.load();
}
void this.loadArticleData(this.props.sku);
}
componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) {
void this.loadArticleData(this.props.sku);
}
}
componentWillUnmount() {
this._cancelled = true;
this.unsubAttr?.();
this.unsubProp?.();
}
async loadArticleData(sku) {
if (!sku) return;
this.setState({ loading: true });
try {
const api = this.context?.article || articleApi;
const [attrRes, propRes] = await Promise.all([
api.getAttributeValues(sku),
api.getProperties(sku),
]);
if (this._cancelled) return;
this.setState({
assignedAttributes: Array.isArray(attrRes?.attributevalues) ? attrRes.attributevalues : [],
assignedProperties: Array.isArray(propRes?.properties) ? propRes.properties : [],
loading: false,
});
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ loading: false });
}
}
handleMerkmalChange = (event) => {
const key = event.target.value;
const { globalAttributes } = this.state;
let nextItemId = '';
if (key.startsWith('attr-')) {
const attrId = Number(key.replace('attr-', ''));
const attr = globalAttributes.find((a) => a.id === attrId);
nextItemId = attr?.values?.[0]?.id ?? '';
}
this.setState({
selectedMerkmalKey: key,
selectedItemId: nextItemId,
customValueText: '',
});
};
handleItemChange = (event) => {
this.setState({
selectedItemId: Number(event.target.value),
});
};
handleAddItem = async () => {
const { sku } = this.props;
const {
selectedMerkmalKey,
selectedItemId,
customValueText,
assignedAttributes,
assignedProperties,
globalAttributes,
globalProperties,
saving,
} = this.state;
if (saving || !sku || !selectedMerkmalKey) return;
const api = this.context?.article || articleApi;
if (selectedMerkmalKey.startsWith('attr-')) {
const attrId = Number(selectedMerkmalKey.replace('attr-', ''));
const targetAttr = globalAttributes.find((a) => a.id === attrId);
if (!targetAttr || !selectedItemId) return;
const isExclusive = Boolean(targetAttr.exclusive);
const targetValId = Number(selectedItemId);
const isAlreadyAssigned = assignedAttributes.some((a) => a.attributeValueId === targetValId);
if (isAlreadyAssigned) {
enqueueSnackbar('Bereits zugeordnet.', { variant: 'info' });
return;
}
let nextValues = [];
if (isExclusive) {
const others = assignedAttributes.filter((a) => a.attributeId !== targetAttr.id);
nextValues = [...others.map((a) => a.attributeValueId), targetValId];
} else {
nextValues = [...assignedAttributes.map((a) => a.attributeValueId), targetValId];
}
this.setState({ saving: true });
try {
const res = await api.setAttributeValues(sku, nextValues);
if (this._cancelled) return;
this.setState({
assignedAttributes: Array.isArray(res?.attributevalues) ? res.attributevalues : [],
saving: false,
});
enqueueSnackbar('Zugeordnet.', { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ saving: false });
}
} else if (selectedMerkmalKey.startsWith('prop-')) {
const propId = Number(selectedMerkmalKey.replace('prop-', ''));
const targetProp = globalProperties.find((p) => p.id === propId);
const val = customValueText.trim();
if (!targetProp || !val) return;
const isExclusive = Boolean(targetProp.exclusive);
if (!isExclusive) {
const isAlreadyAssigned = assignedProperties.some(
(p) => p.propertyId === propId && p.value.trim().toLowerCase() === val.toLowerCase()
);
if (isAlreadyAssigned) {
enqueueSnackbar('Bereits zugeordnet.', { variant: 'info' });
return;
}
}
let nextProps = [];
if (isExclusive) {
const existing = assignedProperties.filter((p) => p.propertyId !== propId);
nextProps = [
...existing.map((p) => ({ propertyId: p.propertyId, value: p.value, sort: p.sort })),
{ propertyId: propId, value: val },
];
} else {
nextProps = [
...assignedProperties.map((p) => ({ propertyId: p.propertyId, value: p.value, sort: p.sort })),
{ propertyId: propId, value: val },
];
}
this.setState({ saving: true });
try {
const res = await api.setProperties(sku, nextProps);
if (this._cancelled) return;
this.setState({
assignedProperties: Array.isArray(res?.properties) ? res.properties : [],
customValueText: '',
saving: false,
});
enqueueSnackbar('Zugeordnet.', { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ saving: false });
}
}
};
handleRemoveAttributeValue = async (attributeValueId) => {
const { sku } = this.props;
const { assignedAttributes, saving } = this.state;
if (saving || !sku) return;
const nextValues = assignedAttributes
.filter((a) => a.attributeValueId !== attributeValueId)
.map((a) => a.attributeValueId);
this.setState({ saving: true });
try {
const api = this.context?.article || articleApi;
const res = await api.setAttributeValues(sku, nextValues);
if (this._cancelled) return;
this.setState({
assignedAttributes: Array.isArray(res?.attributevalues) ? res.attributevalues : [],
saving: false,
});
enqueueSnackbar('Entfernt.', { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ saving: false });
}
};
handleRemoveProperty = async (rowId) => {
const { sku } = this.props;
const { assignedProperties, saving } = this.state;
if (saving || !sku) return;
const nextProperties = assignedProperties
.filter((p) => p.id !== rowId)
.map((p) => ({ propertyId: p.propertyId, value: p.value, sort: p.sort }));
this.setState({ saving: true });
try {
const api = this.context?.article || articleApi;
const res = await api.setProperties(sku, nextProperties);
if (this._cancelled) return;
this.setState({
assignedProperties: Array.isArray(res?.properties) ? res.properties : [],
saving: false,
});
enqueueSnackbar('Entfernt.', { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ saving: false });
}
};
export default class ArticleAttributesTab extends Component {
render() {
const {
loading,
saving,
assignedAttributes,
assignedProperties,
globalAttributes,
globalProperties,
selectedMerkmalKey,
selectedItemId,
customValueText,
} = this.state;
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={24} />
</Box>
);
}
// Unified list of selectable Merkmale
const merkmalOptions = [
...globalAttributes.map((a) => ({
key: `attr-${a.id}`,
id: a.id,
type: 'attr',
name: a.name,
exclusive: a.exclusive,
values: a.values || [],
})),
...globalProperties.map((p) => ({
key: `prop-${p.id}`,
id: p.id,
type: 'prop',
name: p.name,
exclusive: p.exclusive,
values: [],
})),
];
const selectedOption = merkmalOptions.find((m) => m.key === selectedMerkmalKey) ?? null;
const isAttr = selectedOption?.type === 'attr';
const availableItems = selectedOption?.values || [];
// Unified list of assigned groups sorted by definition order
const assignedGroups = [];
for (const item of assignedAttributes) {
const attrDef = globalAttributes.find((a) => a.id === item.attributeId);
const name = attrDef?.name ?? item.attributeName ?? `Merkmal #${item.attributeId}`;
let group = assignedGroups.find((g) => g.key === `attr-${item.attributeId}`);
if (!group) {
group = {
key: `attr-${item.attributeId}`,
name,
sort: attrDef?.sort ?? 999,
items: [],
};
assignedGroups.push(group);
}
group.items.push({
id: item.id ?? item.attributeValueId,
value: item.value,
type: 'attr',
removeId: item.attributeValueId,
});
}
for (const item of assignedProperties) {
const propDef = globalProperties.find((p) => p.id === item.propertyId);
const name = propDef?.name ?? item.name ?? `Merkmal #${item.propertyId}`;
let group = assignedGroups.find((g) => g.key === `prop-${item.propertyId}`);
if (!group) {
group = {
key: `prop-${item.propertyId}`,
name,
sort: propDef?.sort ?? 999,
items: [],
};
assignedGroups.push(group);
}
group.items.push({
id: item.id,
value: item.value,
type: 'prop',
removeId: item.id,
});
}
assignedGroups.sort((a, b) => a.sort - b.sort || a.name.localeCompare(b.name));
return (
<Typography variant="body2" color="text.secondary">
Attribute und Merkmale
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Unified Add Bar: [Merkmal ▼] [Wert ▼ / Input] [+] */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControl size="small" sx={{ minWidth: 170 }}>
<InputLabel id="merkmal-select-label">Merkmal</InputLabel>
<Select
labelId="merkmal-select-label"
value={selectedMerkmalKey}
label="Merkmal"
disabled={saving || merkmalOptions.length === 0}
onChange={this.handleMerkmalChange}
sx={{ bgcolor: 'background.paper' }}
>
{merkmalOptions.map((m) => (
<MenuItem key={m.key} value={m.key}>
{m.name}
</MenuItem>
))}
</Select>
</FormControl>
{isAttr ? (
<FormControl size="small" sx={{ minWidth: 170 }}>
<InputLabel id="item-select-label">Wert</InputLabel>
<Select
labelId="item-select-label"
value={selectedItemId}
label="Wert"
disabled={saving || availableItems.length === 0}
onChange={this.handleItemChange}
sx={{ bgcolor: 'background.paper' }}
>
{availableItems.length === 0 ? (
<MenuItem value="" disabled>
(Keine Werte)
</MenuItem>
) : (
availableItems.map((val) => (
<MenuItem key={val.id} value={val.id}>
{val.value}
</MenuItem>
))
)}
</Select>
</FormControl>
) : (
<TextField
size="small"
placeholder="Wert..."
value={customValueText}
disabled={saving || !selectedMerkmalKey}
onChange={(e) => this.setState({ customValueText: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void this.handleAddItem();
}
}}
sx={{ minWidth: 170, bgcolor: 'background.paper' }}
/>
)}
<Tooltip title="Hinzufügen">
<span>
<IconButton
color="primary"
disabled={
saving ||
!selectedMerkmalKey ||
(isAttr ? !selectedItemId || availableItems.length === 0 : !customValueText.trim())
}
onClick={() => void this.handleAddItem()}
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
'&.Mui-disabled': { bgcolor: 'action.disabledBackground', color: 'action.disabled' },
p: 0.85,
}}
>
<AddIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
{/* Unified Assigned Merkmale List */}
{assignedGroups.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{assignedGroups.map((group) => (
<Box key={group.key} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography
variant="body2"
sx={{ fontWeight: 600, minWidth: 100, color: 'text.secondary' }}
>
{group.name}:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{group.items.map((item) => (
<Chip
key={item.id}
label={item.value}
size="small"
onDelete={() => {
if (item.type === 'attr') {
void this.handleRemoveAttributeValue(item.removeId);
} else {
void this.handleRemoveProperty(item.removeId);
}
}}
disabled={saving}
color="primary"
variant="outlined"
/>
))}
</Box>
</Box>
))}
</Box>
)}
</Box>
);
}
}