317 lines
9.5 KiB
JavaScript
317 lines
9.5 KiB
JavaScript
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;
|