Files
dashfront/src/components/merkmale/TextMerkmaleTab.jsx
2026-10-12 01:51:06 +02:00

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;