u
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user