u
This commit is contained in:
559
src/components/merkmale/AuswahlMerkmaleTab.jsx
Normal file
559
src/components/merkmale/AuswahlMerkmaleTab.jsx
Normal file
@@ -0,0 +1,559 @@
|
||||
import { PureComponent, createRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import StyleIcon from '@mui/icons-material/Style';
|
||||
import LabelImportantIcon from '@mui/icons-material/LabelImportant';
|
||||
import Sortable from 'sortablejs';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import FillSpace from 'dashgui/FillSpace';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import BackendError from '../BackendError';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import attributeAutomat, { selectAttributes } from '../../automats/attributeAutomat';
|
||||
import ArtikelMerkmalAuswahl from './ArtikelMerkmalAuswahl';
|
||||
import ArtikelMerkmalWert from './ArtikelMerkmalWert';
|
||||
import { BLUE_PANEL, PURPLE_PANEL } from '../../colors';
|
||||
|
||||
/**
|
||||
* Tab 1: Auswahl-Merkmale & Werte
|
||||
*/
|
||||
class AuswahlMerkmaleTab extends PureComponent {
|
||||
// #region Constructor & State
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const initialAttributes = selectAttributes() || [];
|
||||
const initialSelectedAttrId = initialAttributes[0]?.id ?? null;
|
||||
|
||||
this.state = {
|
||||
attrStatus: attributeAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
|
||||
error: attributeAutomat.state.error || null,
|
||||
attributes: initialAttributes,
|
||||
selectedAttributeId: initialSelectedAttrId,
|
||||
|
||||
attrFilter: '',
|
||||
attrSubmitting: false,
|
||||
attrDeletingUnused: false,
|
||||
attrPage: 1,
|
||||
|
||||
valFilter: '',
|
||||
valSubmitting: false,
|
||||
valDeletingUnused: false,
|
||||
valPage: 1,
|
||||
};
|
||||
|
||||
this._cancelled = false;
|
||||
this.attrListRef = createRef();
|
||||
this.valListRef = createRef();
|
||||
this._attrSortable = null;
|
||||
this._valSortable = null;
|
||||
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handleCreateAttribute = this.handleCreateAttribute.bind(this);
|
||||
this.handleCreateValue = this.handleCreateValue.bind(this);
|
||||
this.handleDeleteUnusedAttributes = this.handleDeleteUnusedAttributes.bind(this);
|
||||
this.handleDeleteUnusedValues = this.handleDeleteUnusedValues.bind(this);
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Lifecycle
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
|
||||
this.unsubAttr = attributeAutomat.subscribe((state) => {
|
||||
if (this._cancelled) return;
|
||||
if (state.status === 'succeeded') {
|
||||
let selectedId = this.state.selectedAttributeId;
|
||||
if (!selectedId || !state.attributes.some((a) => a.id === selectedId)) {
|
||||
selectedId = state.attributes[0]?.id ?? null;
|
||||
}
|
||||
this.setState({
|
||||
attrStatus: 'ready',
|
||||
error: null,
|
||||
attributes: state.attributes,
|
||||
selectedAttributeId: selectedId,
|
||||
});
|
||||
} else if (state.status === 'failed') {
|
||||
this.setState({ attrStatus: 'failed', error: backendErrorFrom(state.error) });
|
||||
}
|
||||
});
|
||||
|
||||
if (attributeAutomat.state.status !== 'succeeded') {
|
||||
void this.load();
|
||||
} else {
|
||||
this._initAttrSortable();
|
||||
this._initValSortable();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (
|
||||
(prevState.attributes !== this.state.attributes ||
|
||||
prevState.attrFilter !== this.state.attrFilter ||
|
||||
prevState.attrPage !== this.state.attrPage ||
|
||||
prevState.attrStatus !== this.state.attrStatus) &&
|
||||
this.attrListRef.current
|
||||
) {
|
||||
if (this.state.attrStatus === 'ready') {
|
||||
this._initAttrSortable();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(prevState.selectedAttributeId !== this.state.selectedAttributeId ||
|
||||
prevState.attributes !== this.state.attributes ||
|
||||
prevState.valFilter !== this.state.valFilter ||
|
||||
prevState.valPage !== this.state.valPage ||
|
||||
prevState.attrStatus !== this.state.attrStatus) &&
|
||||
this.valListRef.current
|
||||
) {
|
||||
if (this.state.attrStatus === 'ready') {
|
||||
this._initValSortable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
this.unsubAttr?.();
|
||||
this._destroySortables();
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Sortable Reorder
|
||||
_initAttrSortable() {
|
||||
this._attrSortable?.destroy();
|
||||
const el = this.attrListRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const isFiltered = Boolean(this.state.attrFilter?.trim());
|
||||
|
||||
this._attrSortable = Sortable.create(el, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
disabled: isFiltered,
|
||||
onEnd: (evt) => {
|
||||
const { oldIndex, newIndex } = evt;
|
||||
if (oldIndex === newIndex) return;
|
||||
|
||||
const offset = ((this.state.attrPage || 1) - 1) * 8;
|
||||
const realOld = offset + oldIndex;
|
||||
const realNew = offset + newIndex;
|
||||
if (realOld === realNew) return;
|
||||
|
||||
this.setState(
|
||||
(prev) => {
|
||||
const next = [...prev.attributes];
|
||||
const [moved] = next.splice(realOld, 1);
|
||||
next.splice(realNew, 0, moved);
|
||||
return { attributes: next };
|
||||
},
|
||||
() => {
|
||||
const ids = this.state.attributes.map((a) => a.id);
|
||||
void this._persistAttrOrder(ids);
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_initValSortable() {
|
||||
this._valSortable?.destroy();
|
||||
const el = this.valListRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const isFiltered = Boolean(this.state.valFilter?.trim());
|
||||
|
||||
this._valSortable = Sortable.create(el, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
disabled: isFiltered,
|
||||
onEnd: (evt) => {
|
||||
const { oldIndex, newIndex } = evt;
|
||||
if (oldIndex === newIndex) return;
|
||||
|
||||
const offset = ((this.state.valPage || 1) - 1) * 8;
|
||||
const realOld = offset + oldIndex;
|
||||
const realNew = offset + newIndex;
|
||||
if (realOld === realNew) return;
|
||||
|
||||
const currentAttr = this.state.attributes.find(
|
||||
(a) => a.id === this.state.selectedAttributeId
|
||||
);
|
||||
if (!currentAttr) return;
|
||||
|
||||
const values = currentAttr.values || [];
|
||||
const next = [...values];
|
||||
const [moved] = next.splice(realOld, 1);
|
||||
next.splice(realNew, 0, moved);
|
||||
|
||||
const updatedAttributes = this.state.attributes.map((a) =>
|
||||
a.id === currentAttr.id ? { ...a, values: next } : a
|
||||
);
|
||||
|
||||
this.setState({ attributes: updatedAttributes }, () => {
|
||||
const ids = next.map((v) => v.id);
|
||||
void this._persistValOrder(currentAttr.id, ids);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_destroySortables() {
|
||||
this._attrSortable?.destroy();
|
||||
this._attrSortable = null;
|
||||
this._valSortable?.destroy();
|
||||
this._valSortable = null;
|
||||
}
|
||||
|
||||
async _persistAttrOrder(ids) {
|
||||
try {
|
||||
await attributeAutomat.actions.reorderAttributes(ids);
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
void attributeAutomat.actions.load();
|
||||
}
|
||||
}
|
||||
|
||||
async _persistValOrder(attributeId, ids) {
|
||||
try {
|
||||
await attributeAutomat.actions.reorderValues(attributeId, ids);
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
void attributeAutomat.actions.load();
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Actions & Handlers
|
||||
async load() {
|
||||
this.setState({
|
||||
attrStatus: attributeAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
|
||||
error: null,
|
||||
});
|
||||
try {
|
||||
await attributeAutomat.actions.load();
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
handleSelectAttribute(attrId) {
|
||||
this.setState({ selectedAttributeId: attrId, valFilter: '', valPage: 1 });
|
||||
}
|
||||
|
||||
async handleCreateAttribute() {
|
||||
const name = this.state.attrFilter.trim();
|
||||
if (!name || this.state.attrSubmitting) return;
|
||||
|
||||
this.setState({ attrSubmitting: true });
|
||||
try {
|
||||
const row = await attributeAutomat.actions.createAttribute({
|
||||
name,
|
||||
exclusive: true,
|
||||
});
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
attrFilter: '',
|
||||
attrSubmitting: false,
|
||||
selectedAttributeId: row?.id ?? this.state.selectedAttributeId,
|
||||
});
|
||||
enqueueSnackbar(`Attribut „${row?.name || name}“ angelegt.`, { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ attrSubmitting: false });
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async handleCreateValue() {
|
||||
const { selectedAttributeId } = this.state;
|
||||
const value = this.state.valFilter.trim();
|
||||
if (!selectedAttributeId || !value || this.state.valSubmitting) return;
|
||||
|
||||
this.setState({ valSubmitting: true });
|
||||
try {
|
||||
await attributeAutomat.actions.createValue(selectedAttributeId, { value });
|
||||
if (this._cancelled) return;
|
||||
this.setState({ valFilter: '', valSubmitting: false });
|
||||
enqueueSnackbar(`Wert „${value}“ angelegt.`, { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ valFilter: false });
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteUnusedAttributes() {
|
||||
const { attributes, attrDeletingUnused } = this.state;
|
||||
if (attrDeletingUnused) return;
|
||||
const unused = attributes.filter((a) => (a.articleCount ?? 0) === 0);
|
||||
if (!unused.length) return;
|
||||
|
||||
if (!window.confirm(`Alle ${unused.length} ungenutzten Auswahl-Merkmale wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ attrDeletingUnused: true });
|
||||
try {
|
||||
await attributeAutomat.actions.deleteAttributes(unused.map((a) => a.id));
|
||||
enqueueSnackbar(`${unused.length} ungenutzte Auswahl-Merkmale gelöscht.`, { variant: 'success' });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
if (!this._cancelled) {
|
||||
this.setState({ attrDeletingUnused: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteUnusedValues() {
|
||||
const { attributes, selectedAttributeId, valDeletingUnused } = this.state;
|
||||
if (valDeletingUnused) return;
|
||||
const selectedAttr = attributes.find((a) => a.id === selectedAttributeId) ?? attributes[0] ?? null;
|
||||
if (!selectedAttr) return;
|
||||
const unused = (selectedAttr.values || []).filter((v) => (v.articleCount ?? 0) === 0);
|
||||
if (!unused.length) return;
|
||||
|
||||
if (!window.confirm(`Alle ${unused.length} ungenutzten Werte wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ valDeletingUnused: true });
|
||||
try {
|
||||
await attributeAutomat.actions.deleteValues(unused.map((v) => v.id));
|
||||
enqueueSnackbar(`${unused.length} ungenutzte Werte gelöscht.`, { variant: 'success' });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
if (!this._cancelled) {
|
||||
this.setState({ valDeletingUnused: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Render
|
||||
render() {
|
||||
const {
|
||||
attrStatus,
|
||||
error,
|
||||
attributes,
|
||||
selectedAttributeId,
|
||||
attrFilter,
|
||||
attrSubmitting,
|
||||
attrDeletingUnused,
|
||||
valFilter,
|
||||
valSubmitting,
|
||||
valDeletingUnused,
|
||||
attrPage,
|
||||
valPage,
|
||||
} = this.state;
|
||||
|
||||
if (attrStatus === 'loading' && !attributes.length) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !attributes.length) {
|
||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||
}
|
||||
|
||||
const selectedAttr = attributes.find((a) => a.id === selectedAttributeId) ?? attributes[0] ?? null;
|
||||
const selectedValues = selectedAttr?.values || [];
|
||||
|
||||
const attrFilterTrimmed = attrFilter.trim();
|
||||
const filteredAttributes = attrFilterTrimmed
|
||||
? attributes.filter((a) => a.name.toLowerCase().includes(attrFilterTrimmed.toLowerCase()))
|
||||
: attributes;
|
||||
|
||||
const valFilterTrimmed = valFilter.trim();
|
||||
const filteredValues = valFilterTrimmed
|
||||
? selectedValues.filter((v) => v.value.toLowerCase().includes(valFilterTrimmed.toLowerCase()))
|
||||
: selectedValues;
|
||||
|
||||
const unusedAttrCount = attributes.filter((a) => (a.articleCount ?? 0) === 0).length;
|
||||
const unusedValCount = (selectedValues || []).filter((v) => (v.articleCount ?? 0) === 0).length;
|
||||
|
||||
return (
|
||||
<PanelTileGrid>
|
||||
{/* Tile 1: Auswahl-Merkmale */}
|
||||
<PanelTile wide={2} minHeight={0} color={BLUE_PANEL}>
|
||||
<Stack spacing={1.5}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||
<StyleIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, noWrap: true }}>
|
||||
Auswahl-Merkmale
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={unusedAttrCount === 0 || attrDeletingUnused || attrStatus !== 'ready'}
|
||||
onClick={this.handleDeleteUnusedAttributes}
|
||||
sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Alle ungenutzen löschen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SingleLineTextEdit
|
||||
sx={{ flex: 1 }}
|
||||
size="small"
|
||||
search
|
||||
clear
|
||||
value={attrFilter}
|
||||
disabled={attrSubmitting}
|
||||
placeholder="Suchen oder anlegen…"
|
||||
onChange={(next) => this.setState({ attrFilter: next })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void this.handleCreateAttribute();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!attrFilterTrimmed || attrSubmitting}
|
||||
onClick={() => void this.handleCreateAttribute()}
|
||||
>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{filteredAttributes.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
{attrFilterTrimmed
|
||||
? `Keine Treffer für „${attrFilterTrimmed}“.`
|
||||
: 'Noch keine Auswahl-Merkmale angelegt.'}
|
||||
</Typography>
|
||||
) : (
|
||||
<FillSpace
|
||||
items={filteredAttributes}
|
||||
totalCount={attributes.length}
|
||||
minPageSize={8}
|
||||
maxPageSize={8}
|
||||
listRef={this.attrListRef}
|
||||
onPageChange={(page) => this.setState({ attrPage: page })}
|
||||
renderItem={(a) => (
|
||||
<ArtikelMerkmalAuswahl
|
||||
key={a.id}
|
||||
attribute={a}
|
||||
isSelected={selectedAttr?.id === a.id}
|
||||
onSelect={() => this.handleSelectAttribute(a.id)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
|
||||
{/* Tile 2: Werte für das gewählte Auswahl-Merkmal */}
|
||||
<PanelTile wide={2} minHeight={0} color={PURPLE_PANEL}>
|
||||
<Stack spacing={1.5}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||
<LabelImportantIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, noWrap: true }}>
|
||||
{selectedAttr ? `Werte für Auswahl-Merkmal „${selectedAttr.name}“` : 'Werte'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={!selectedAttr || unusedValCount === 0 || valDeletingUnused || attrStatus !== 'ready'}
|
||||
onClick={this.handleDeleteUnusedValues}
|
||||
sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Alle ungenutzen löschen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{!selectedAttr ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
Kein Attribut ausgewählt.
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SingleLineTextEdit
|
||||
sx={{ flex: 1 }}
|
||||
size="small"
|
||||
search
|
||||
clear
|
||||
value={valFilter}
|
||||
disabled={valSubmitting}
|
||||
placeholder="Suchen oder anlegen…"
|
||||
onChange={(next) => this.setState({ valFilter: next })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void this.handleCreateValue();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!valFilterTrimmed || valSubmitting}
|
||||
onClick={() => void this.handleCreateValue()}
|
||||
>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{filteredValues.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
{valFilterTrimmed
|
||||
? `Keine Treffer für „${valFilterTrimmed}“.`
|
||||
: 'Noch keine Werte angelegt.'}
|
||||
</Typography>
|
||||
) : (
|
||||
<FillSpace
|
||||
items={filteredValues}
|
||||
totalCount={selectedValues.length}
|
||||
minPageSize={8}
|
||||
maxPageSize={8}
|
||||
listRef={this.valListRef}
|
||||
onPageChange={(page) => this.setState({ valPage: page })}
|
||||
renderItem={(v) => (
|
||||
<ArtikelMerkmalWert
|
||||
key={v.id}
|
||||
value={v}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
);
|
||||
}
|
||||
// #endregion
|
||||
}
|
||||
|
||||
export default AuswahlMerkmaleTab;
|
||||
316
src/components/merkmale/TextMerkmaleTab.jsx
Normal file
316
src/components/merkmale/TextMerkmaleTab.jsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { PureComponent, createRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
import Sortable from 'sortablejs';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import FillSpace from 'dashgui/FillSpace';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import BackendError from '../BackendError';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import propertyAutomat, { selectProperties } from '../../automats/propertyAutomat';
|
||||
import ArtikelMerkmalText from './ArtikelMerkmalText';
|
||||
import { ORANGE_PANEL } from '../../colors';
|
||||
|
||||
/**
|
||||
* Tab 2: Text-Merkmale
|
||||
*/
|
||||
class TextMerkmaleTab extends PureComponent {
|
||||
// #region Constructor & State
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const initialProperties = selectProperties() || [];
|
||||
|
||||
this.state = {
|
||||
propStatus: propertyAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
|
||||
error: propertyAutomat.state.error || null,
|
||||
properties: initialProperties,
|
||||
|
||||
propFilter: '',
|
||||
propSubmitting: false,
|
||||
propDeletingUnused: false,
|
||||
propPage: 1,
|
||||
};
|
||||
|
||||
this._cancelled = false;
|
||||
this.propListRef = createRef();
|
||||
this._propSortable = null;
|
||||
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handleCreateProperty = this.handleCreateProperty.bind(this);
|
||||
this.handleDeleteUnusedProperties = this.handleDeleteUnusedProperties.bind(this);
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Lifecycle
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
console.log('TextMerkmaleTab mounted', this.props);
|
||||
|
||||
this.unsubProp = propertyAutomat.subscribe((state) => {
|
||||
if (this._cancelled) return;
|
||||
if (state.status === 'succeeded') {
|
||||
this.setState({
|
||||
propStatus: 'ready',
|
||||
error: null,
|
||||
properties: state.properties,
|
||||
});
|
||||
} else if (state.status === 'failed') {
|
||||
this.setState({ propStatus: 'failed', error: backendErrorFrom(state.error) });
|
||||
}
|
||||
});
|
||||
|
||||
if (propertyAutomat.state.status !== 'succeeded') {
|
||||
void this.load();
|
||||
} else {
|
||||
this._initPropSortable();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (
|
||||
(prevState.properties !== this.state.properties ||
|
||||
prevState.propFilter !== this.state.propFilter ||
|
||||
prevState.propPage !== this.state.propPage ||
|
||||
prevState.propStatus !== this.state.propStatus) &&
|
||||
this.propListRef.current
|
||||
) {
|
||||
if (this.state.propStatus === 'ready') {
|
||||
this._initPropSortable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
this.unsubProp?.();
|
||||
this._destroySortables();
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Sortable Reorder
|
||||
_initPropSortable() {
|
||||
this._propSortable?.destroy();
|
||||
const el = this.propListRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const isFiltered = Boolean(this.state.propFilter?.trim());
|
||||
|
||||
this._propSortable = Sortable.create(el, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
disabled: isFiltered,
|
||||
onEnd: (evt) => {
|
||||
const { oldIndex, newIndex } = evt;
|
||||
if (oldIndex === newIndex) return;
|
||||
|
||||
const offset = ((this.state.propPage || 1) - 1) * 8;
|
||||
const realOld = offset + oldIndex;
|
||||
const realNew = offset + newIndex;
|
||||
if (realOld === realNew) return;
|
||||
|
||||
this.setState(
|
||||
(prev) => {
|
||||
const next = [...prev.properties];
|
||||
const [moved] = next.splice(realOld, 1);
|
||||
next.splice(realNew, 0, moved);
|
||||
return { properties: next };
|
||||
},
|
||||
() => {
|
||||
const ids = this.state.properties.map((p) => p.id);
|
||||
void this._persistPropOrder(ids);
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_destroySortables() {
|
||||
this._propSortable?.destroy();
|
||||
this._propSortable = null;
|
||||
}
|
||||
|
||||
async _persistPropOrder(ids) {
|
||||
try {
|
||||
await propertyAutomat.actions.reorderProperties(ids);
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
notifyError(err);
|
||||
void propertyAutomat.actions.load();
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Actions & Handlers
|
||||
async load() {
|
||||
this.setState({
|
||||
propStatus: propertyAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
|
||||
error: null,
|
||||
});
|
||||
try {
|
||||
await propertyAutomat.actions.load();
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ error: backendErrorFrom(err) });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async handleCreateProperty() {
|
||||
const name = this.state.propFilter.trim();
|
||||
if (!name || this.state.propSubmitting) return;
|
||||
|
||||
this.setState({ propSubmitting: true });
|
||||
try {
|
||||
const row = await propertyAutomat.actions.createProperty({
|
||||
name,
|
||||
exclusive: true,
|
||||
});
|
||||
if (this._cancelled) return;
|
||||
this.setState({ propFilter: '', propSubmitting: false });
|
||||
enqueueSnackbar(`Merkmal „${row?.name || name}“ angelegt.`, { variant: 'success' });
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
this.setState({ propSubmitting: false });
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteUnusedProperties() {
|
||||
const { properties, propDeletingUnused } = this.state;
|
||||
if (propDeletingUnused) return;
|
||||
const unused = properties.filter((p) => (p.articleCount ?? 0) === 0);
|
||||
if (!unused.length) return;
|
||||
|
||||
if (!window.confirm(`Alle ${unused.length} ungenutzten Text-Merkmale wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ propDeletingUnused: true });
|
||||
try {
|
||||
await propertyAutomat.actions.deleteProperties(unused.map((p) => p.id));
|
||||
enqueueSnackbar(`${unused.length} ungenutzte Text-Merkmale gelöscht.`, { variant: 'success' });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
if (!this._cancelled) {
|
||||
this.setState({ propDeletingUnused: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region Render
|
||||
render() {
|
||||
const {
|
||||
propStatus,
|
||||
error,
|
||||
properties,
|
||||
propFilter,
|
||||
propSubmitting,
|
||||
propDeletingUnused,
|
||||
propPage,
|
||||
} = this.state;
|
||||
|
||||
if (propStatus === 'loading' && !properties.length) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !properties.length) {
|
||||
return <BackendError error={error} onRetry={this.handleRetry} />;
|
||||
}
|
||||
|
||||
const propFilterTrimmed = propFilter.trim();
|
||||
const filteredProperties = propFilterTrimmed
|
||||
? properties.filter((p) => p.name.toLowerCase().includes(propFilterTrimmed.toLowerCase()))
|
||||
: properties;
|
||||
|
||||
const unusedPropCount = properties.filter((p) => (p.articleCount ?? 0) === 0).length;
|
||||
|
||||
return (
|
||||
<PanelTileGrid>
|
||||
{/* Tile 3: Text-Merkmale */}
|
||||
<PanelTile wide={2} minHeight={0} color={ORANGE_PANEL}>
|
||||
<Stack spacing={1.5}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||
<TuneIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, noWrap: true }}>
|
||||
Text-Merkmale
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={unusedPropCount === 0 || propDeletingUnused || propStatus !== 'ready'}
|
||||
onClick={this.handleDeleteUnusedProperties}
|
||||
sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Alle ungenutzen löschen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SingleLineTextEdit
|
||||
sx={{ flex: 1 }}
|
||||
size="small"
|
||||
search
|
||||
clear
|
||||
value={propFilter}
|
||||
disabled={propSubmitting}
|
||||
placeholder="Suchen oder anlegen…"
|
||||
onChange={(next) => this.setState({ propFilter: next })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void this.handleCreateProperty();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!propFilterTrimmed || propSubmitting}
|
||||
onClick={() => void this.handleCreateProperty()}
|
||||
>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<FillSpace
|
||||
items={filteredProperties}
|
||||
totalCount={properties.length}
|
||||
minPageSize={8}
|
||||
maxPageSize={8}
|
||||
listRef={this.propListRef}
|
||||
onPageChange={(page) => this.setState({ propPage: page })}
|
||||
renderItem={(p) => (
|
||||
<ArtikelMerkmalText
|
||||
key={p.id}
|
||||
property={p}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
);
|
||||
}
|
||||
// #endregion
|
||||
}
|
||||
|
||||
export default TextMerkmaleTab;
|
||||
@@ -1,3 +1,5 @@
|
||||
export { default as ArtikelMerkmalAuswahl } from './ArtikelMerkmalAuswahl';
|
||||
export { default as ArtikelMerkmalWert } from './ArtikelMerkmalWert';
|
||||
export { default as ArtikelMerkmalText } from './ArtikelMerkmalText';
|
||||
export { default as AuswahlMerkmaleTab } from './AuswahlMerkmaleTab';
|
||||
export { default as TextMerkmaleTab } from './TextMerkmaleTab';
|
||||
|
||||
Reference in New Issue
Block a user