This commit is contained in:
seb
2026-09-22 13:45:22 +02:00
parent 3ae840b2fe
commit 49057dc0d6
11 changed files with 1233 additions and 421 deletions

View File

@@ -1,113 +0,0 @@
import { Droppable } from '@shopify/draggable';
import { notifyError } from '../../utils/notify';
/**
* Encapsulates @shopify/draggable Droppable logic for moving a category
* into another category (tree drag-and-drop).
*
* Usage:
* this.droppable = new CategoryDroppable(containerEl, { onMove });
* this.droppable.activate();
* this.droppable.destroy();
*/
export default class CategoryDroppable {
#instance = null;
#container = null;
#onMove = null;
#pendingDropId = null;
#dropTargetEl = null;
constructor(container, { onMove }) {
this.#container = container;
this.#onMove = onMove;
}
activate() {
const el = this.#container;
if (!el || this.#instance) return;
this.#pendingDropId = null;
this.#instance = new Droppable(el, {
draggable: '.sortable-item',
dropzone: '.sortable-item',
handle: '.droppable-handle',
delay: 0,
mirror: { constrainDimensions: true },
classes: { 'droppable:active': '', 'droppable:occupied': '' },
});
this.#instance.on('drag:start', this.#handleTreeDragStart);
this.#instance.on('drag:over', this.#handleDropOver);
this.#instance.on('drag:out', this.#handleDropOut);
this.#instance.on('droppable:dropped', this.#handleDropped);
this.#instance.on('droppable:returned', this.#handleDroppedReturned);
this.#instance.on('drag:stop', this.#handleDragStop);
}
destroy() {
if (this.#instance) {
this.#instance.destroy();
this.#instance = null;
}
this.#pendingDropId = null;
this.#clearDropTarget();
document.body.classList.remove('tree-dragging');
}
// --- private ---
#handleTreeDragStart = () => {
document.body.classList.add('tree-dragging');
};
#handleDropped = (event) => {
event.cancel();
};
#handleDropOver = (event) => {
const tile =
event?.over && typeof event.over.closest === 'function'
? event.over.closest('.sortable-item')
: null;
if (!tile) return;
const sourceId = Number(event?.source?.dataset?.id);
const targetId = Number(tile.dataset.id);
if (sourceId && targetId === sourceId) {
this.#pendingDropId = null;
return;
}
this.#clearDropTarget();
this.#pendingDropId = targetId;
this.#dropTargetEl = tile;
tile.classList.add('tree-drop-target');
};
#handleDropOut = () => {
this.#pendingDropId = null;
this.#clearDropTarget();
};
#handleDroppedReturned = (event) => {
this.#pendingDropId = null;
this.#clearDropTarget();
event.cancel();
};
#handleDragStop = (event) => {
document.body.classList.remove('tree-dragging');
const categoryId = Number(event?.source?.dataset?.id);
const targetId = this.#pendingDropId;
this.#pendingDropId = null;
this.#clearDropTarget();
if (!categoryId || !targetId || categoryId === targetId) return;
void this.#onMove({ categoryId, parentId: targetId })
.unwrap()
.catch(notifyError);
};
#clearDropTarget = () => {
if (this.#dropTargetEl) {
this.#dropTargetEl.classList.remove('tree-drop-target');
this.#dropTargetEl = null;
}
};
}

View File

@@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../../components/BackendError';
import CategorySortable from './CategorySortable';
import CategoryDroppable from './CategoryDroppable';
import CategoryPickerDialog from '../../components/article/CategoryPickerDialog';
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
import { NewArticleTile } from '../../components/article';
import { notifyError } from '../../utils/notify';
@@ -28,7 +28,12 @@ import categoryAutomat from '../../automats/categoryAutomat';
import posterminalAutomat from '../../automats/posterminalAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { setLastArticleSku } from '../../automats';
import { categoryDisplayName, getCategoryBreadcrumbs, getCategoryChildren } from '../../utils/categoryUtils';
import {
categoryDisplayName,
getCategoryBreadcrumbs,
getCategoryChildren,
getCategoryDescendantIds,
} from '../../utils/categoryUtils';
// ─── Helper selectors for this page ────────────────────────────────────────────
function selectFromCategory(s) {
@@ -76,10 +81,10 @@ class ArtikelKategorienPage extends PureComponent {
...selectFromCategory(categoryAutomat.state),
...selectTerminals(posterminalAutomat.state),
companyId: companyAutomat.state.current?.id ?? null,
moveCategory: null,
};
this.gridRef = createRef();
this.sortableModule = null;
this.droppableModule = null;
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
this.handleDraftChange = this.handleDraftChange.bind(this);
}
@@ -96,7 +101,6 @@ class ArtikelKategorienPage extends PureComponent {
this.syncCategoryFromUrl();
this.ensureArticlesLoaded();
this.syncSortable();
this.syncDroppable();
}
componentDidUpdate(prevState) {
@@ -121,7 +125,6 @@ class ArtikelKategorienPage extends PureComponent {
}
if (this.state.renameMode !== prevState.renameMode) {
this.syncSortable();
this.syncDroppable();
}
}
@@ -130,7 +133,6 @@ class ArtikelKategorienPage extends PureComponent {
this._unsubTerm?.();
this._unsubCompany?.();
this.sortableModule?.destroy();
this.droppableModule?.destroy();
}
#ensureModules = () => {
@@ -142,12 +144,6 @@ class ArtikelKategorienPage extends PureComponent {
categoryAutomat.actions.reorderCategories({ categoryIds }),
});
}
if (!this.droppableModule) {
this.droppableModule = new CategoryDroppable(el, {
onMove: ({ categoryId, parentId }) =>
categoryAutomat.actions.moveCategory({ categoryId, parentId }),
});
}
};
syncSortable = () => {
@@ -160,20 +156,6 @@ class ArtikelKategorienPage extends PureComponent {
}
};
syncDroppable = () => {
this.#ensureModules();
if (this.state.renameMode) {
this.droppableModule?.destroy();
this.droppableModule?.activate();
} else {
this.droppableModule?.destroy();
}
};
handleMoveCategory = (category, direction) => {
this.sortableModule?.handleMoveCategory(category.id, direction);
};
ensureTreeLoaded = () => {
const { companyId, treeStatus } = this.state;
if (!companyId || treeStatus !== 'idle') return;
@@ -252,10 +234,28 @@ class ArtikelKategorienPage extends PureComponent {
}
};
handleDeleteCategory = async (category) => {
handleOpenMoveCategory = (category) => {
this.setState({ moveCategory: category });
};
handleCloseMoveCategory = () => {
this.setState({ moveCategory: null });
};
handleConfirmMoveCategory = async (targetParentId) => {
const { moveCategory } = this.state;
if (!moveCategory || targetParentId == null) return;
if (String(targetParentId) === String(moveCategory.parentId)) {
this.setState({ moveCategory: null });
return;
}
try {
await categoryAutomat.actions.deleteCategory(category.id);
enqueueSnackbar('Kategorie gelöscht.', { variant: 'success' });
await categoryAutomat.actions.moveCategory({
categoryId: moveCategory.id,
parentId: targetParentId,
});
enqueueSnackbar(`Kategorie „${moveCategory.name}“ verschoben.`, { variant: 'success' });
this.setState({ moveCategory: null });
} catch (err) {
notifyError(err);
}
@@ -286,6 +286,7 @@ class ArtikelKategorienPage extends PureComponent {
render() {
const {
companyId,
categories,
rootCategoryId,
treeStatus,
articlesStatus,
@@ -298,6 +299,7 @@ class ArtikelKategorienPage extends PureComponent {
renameMode,
terminals,
drafts,
moveCategory,
} = this.state;
if (!companyId) {
@@ -387,6 +389,7 @@ class ArtikelKategorienPage extends PureComponent {
})
.catch(notifyError);
},
onPlacementClick: () => this.handleOpenMoveCategory(item),
onOpen: () => this.navigateToCategoryById(item.id),
onPhotoUpload: () => {
const input = document.createElement('input');
@@ -439,13 +442,6 @@ class ArtikelKategorienPage extends PureComponent {
}}
/>
),
editBand: {
onMoveLeft: () => this.handleMoveCategory(item, -1),
onMoveRight: () => this.handleMoveCategory(item, 1),
onDelete: () => this.handleDeleteCategory(item),
canLeft: index > 0,
canRight: index < childCategories.length - 1,
},
}
: {
label,
@@ -462,13 +458,14 @@ class ArtikelKategorienPage extends PureComponent {
);
})}
{!renameMode && articles.map((item) => (
{!renameMode && articles.map((item, index) => (
<ArticleTile
key={item.id}
label={item.name != null ? item.name.trim() : undefined}
article={item}
disabled={!articleSku(item)}
onClick={() => this.handleArticleClick(item)}
sx={index === 0 ? { gridColumnStart: 1 } : undefined}
/>
))}
@@ -477,6 +474,7 @@ class ArtikelKategorienPage extends PureComponent {
<NewArticleTile
parentId={currentCategoryId}
onOpenArticle={this.handleArticleClick}
sx={{ gridColumnStart: 1 }}
/>
)}
</Box>
@@ -486,6 +484,21 @@ class ArtikelKategorienPage extends PureComponent {
<CircularProgress size={28} />
</Box>
)}
{moveCategory && (
<CategoryPickerDialog
open={Boolean(moveCategory)}
onClose={this.handleCloseMoveCategory}
categories={categories}
rootCategoryId={rootCategoryId}
title={`„${categoryDisplayName(moveCategory.name, moveCategory.id, rootCategoryId)}“ verschieben nach…`}
confirmLabel="Hierher verschieben"
mode="single"
selectedId={moveCategory.parentId}
excludeIds={getCategoryDescendantIds(categories, moveCategory.id)}
onSelectCategory={this.handleConfirmMoveCategory}
/>
)}
</>
);
}