This commit is contained in:
seb
2026-09-22 07:20:31 +02:00
parent 536e7b1e2a
commit 5e13b8f9cd
32 changed files with 2485 additions and 1908 deletions

View File

@@ -3,7 +3,7 @@ import {
categorySlug,
getCategoryBreadcrumbs,
getCategoryChildren,
} from '../../store/categorySlice';
} from '../../utils/categoryUtils';
export { ROOT_CATEGORY_NAME, categorySlug };

View File

@@ -1,5 +1,4 @@
import { Component, createRef } from 'react';
import { connect } from 'react-redux';
import { Component, PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import TextField from '@mui/material/TextField';
@@ -19,41 +18,49 @@ import {
import { TILE_SIZE } from 'dashgui/tiles';
import withCategoryRoute from './withCategoryRoute';
import { artikelDetailPath, articleSku } from './articleRoute';
import { selectCompanyId } from '../../store/companySlice';
import { selectPosterminals, loadPosterminals } from '../../store/posterminalSlice';
import {
buildCategoryPath,
defaultCategoryPath,
resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME,
} from './categoryRoute';
import {
categoryDisplayName,
deleteCategory,
moveCategory,
loadCategoryArticles,
loadCategoryTree,
navigateToCategory,
renameCategory,
reorderCategories,
selectCanNavigateBack,
selectCategoryArticles,
selectCategoryArticlesStatus,
selectCategoryBreadcrumbs,
selectCategoryMap,
selectCategoryError,
selectCategoryTreeStatus,
selectChildCategories,
selectRenameMode,
patchCategoryPosterminalIds,
uploadCategoryImage,
deleteCategoryImage,
} from '../../store/categorySlice';
import {
enableTerminalCategory,
disableTerminalCategory,
} from '../../store/posterminalSlice';
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';
// ─── Helper selectors for this page ────────────────────────────────────────────
function selectFromCategory(s) {
const { categories, rootCategoryId, currentCategoryId, articles, articlesTotal, renameMode, treeStatus, articlesStatus, error } = s;
const breadcrumbs = getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId);
const childCategories = getCategoryChildren(categories, currentCategoryId);
const canGoBack =
currentCategoryId != null &&
rootCategoryId != null &&
String(currentCategoryId) !== String(rootCategoryId) &&
categories?.[String(currentCategoryId)]?.parentId != null &&
categories[String(currentCategoryId)].parentId !== 0;
return {
categories,
rootCategoryId,
treeStatus,
articlesStatus,
error,
currentCategoryId,
breadcrumbs,
childCategories,
articles,
articlesTotal,
canGoBack,
renameMode,
};
}
function selectTerminals(s) {
return { terminals: s.terminals };
}
const gridSx = {
display: 'grid',
@@ -61,10 +68,15 @@ const gridSx = {
gap: 2,
};
class ArtikelKategorienPage extends Component {
class ArtikelKategorienPage extends PureComponent {
constructor(props) {
super(props);
this.state = { drafts: {} };
this.state = {
drafts: {},
...selectFromCategory(categoryAutomat.state),
...selectTerminals(posterminalAutomat.state),
companyId: companyAutomat.state.current?.id ?? null,
};
this.gridRef = createRef();
this.sortableModule = null;
this.droppableModule = null;
@@ -74,6 +86,11 @@ class ArtikelKategorienPage extends Component {
componentDidMount() {
setLastArticleSku(null);
this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory);
this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals);
this._unsubCompany = companyAutomat.subscribe(this, (s) => ({
companyId: s.current?.id ?? null,
}));
this.ensureTreeLoaded();
this.ensurePosterminalsLoaded();
this.syncCategoryFromUrl();
@@ -82,37 +99,36 @@ class ArtikelKategorienPage extends Component {
this.syncDroppable();
}
componentDidUpdate(prevProps) {
if (this.props.companyId !== prevProps.companyId) {
componentDidUpdate(prevState) {
// Note: PureComponent — comparisons use this.state not this.props
if (this.state.companyId !== prevState.companyId) {
this.ensureTreeLoaded();
}
if (
this.props.treeStatus === 'succeeded' &&
prevProps.treeStatus !== 'succeeded'
) {
if (this.state.treeStatus === 'succeeded' && prevState.treeStatus !== 'succeeded') {
this.syncCategoryFromUrl();
this.ensureArticlesLoaded();
}
if (
this.props.treeStatus === 'succeeded' &&
this.props.categorySlugPath !== prevProps.categorySlugPath
this.state.treeStatus === 'succeeded' &&
this.props.categorySlugPath !== (this._prevSlugPath ?? this.props.categorySlugPath)
) {
this.syncCategoryFromUrl();
}
this._prevSlugPath = this.props.categorySlugPath;
if (this.props.currentCategoryId !== prevProps.currentCategoryId) {
if (this.state.currentCategoryId !== prevState.currentCategoryId) {
this.ensureArticlesLoaded();
}
if (this.props.renameMode !== prevProps.renameMode) {
if (this.state.renameMode !== prevState.renameMode) {
this.syncSortable();
this.syncDroppable();
}
}
componentWillUnmount() {
this._unsubCat?.();
this._unsubTerm?.();
this._unsubCompany?.();
this.sortableModule?.destroy();
this.droppableModule?.destroy();
}
@@ -122,19 +138,21 @@ class ArtikelKategorienPage extends Component {
if (!el) return;
if (!this.sortableModule) {
this.sortableModule = new CategorySortable(el, {
onReorder: this.props.reorderCategories,
onReorder: ({ categoryIds }) =>
categoryAutomat.actions.reorderCategories({ categoryIds }),
});
}
if (!this.droppableModule) {
this.droppableModule = new CategoryDroppable(el, {
onMove: this.props.moveCategory,
onMove: ({ categoryId, parentId }) =>
categoryAutomat.actions.moveCategory({ categoryId, parentId }),
});
}
};
syncSortable = () => {
this.#ensureModules();
if (this.props.renameMode) {
if (this.state.renameMode) {
this.sortableModule?.destroy();
this.sortableModule?.activate();
} else {
@@ -144,7 +162,7 @@ class ArtikelKategorienPage extends Component {
syncDroppable = () => {
this.#ensureModules();
if (this.props.renameMode) {
if (this.state.renameMode) {
this.droppableModule?.destroy();
this.droppableModule?.activate();
} else {
@@ -157,33 +175,32 @@ class ArtikelKategorienPage extends Component {
};
ensureTreeLoaded = () => {
const { companyId, treeStatus } = this.props;
const { companyId, treeStatus } = this.state;
if (!companyId || treeStatus !== 'idle') return;
this.props.loadCategoryTree();
void categoryAutomat.actions.loadTree();
};
ensurePosterminalsLoaded = () => {
const { companyId } = this.props;
const { companyId } = this.state;
if (!companyId) return;
this.props.loadPosterminals();
void posterminalAutomat.actions.load();
};
ensureArticlesLoaded = () => {
const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.props;
const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.state;
if (treeStatus !== 'succeeded' || !currentCategoryId || articlesStatus !== 'idle') return;
if (String(currentCategoryId) === String(rootCategoryId)) return;
this.props.loadCategoryArticles(currentCategoryId);
void categoryAutomat.actions.loadArticles(currentCategoryId);
};
syncCategoryFromUrl = () => {
const {
categories,
rootCategoryId,
categorySlugPath,
currentCategoryId,
treeStatus,
navigate,
} = this.props;
} = this.state;
const { categorySlugPath, navigate } = this.props;
if (treeStatus !== 'succeeded' || !categories || rootCategoryId == null) return;
@@ -206,13 +223,13 @@ class ArtikelKategorienPage extends Component {
}
if (resolvedId !== currentCategoryId) {
this.props.navigateToCategory(resolvedId);
categoryAutomat.actions.navigateTo(resolvedId);
}
};
navigateToCategoryById = (categoryId) => {
const { categories, rootCategoryId, navigate } = this.props;
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
const { categories, rootCategoryId } = this.state;
this.props.navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
};
handleDraftChange(categoryId, value) {
@@ -228,10 +245,7 @@ class ArtikelKategorienPage extends Component {
return;
}
try {
await this.props.renameCategory({
categoryId,
name: trimmed,
}).unwrap();
await categoryAutomat.actions.renameCategory({ categoryId, name: trimmed });
enqueueSnackbar('Kategorie umbenannt.', { variant: 'success' });
} catch (err) {
notifyError(err);
@@ -240,7 +254,7 @@ class ArtikelKategorienPage extends Component {
handleDeleteCategory = async (category) => {
try {
await this.props.deleteCategory(category.id).unwrap();
await categoryAutomat.actions.deleteCategory(category.id);
enqueueSnackbar('Kategorie gelöscht.', { variant: 'success' });
} catch (err) {
notifyError(err);
@@ -248,18 +262,18 @@ class ArtikelKategorienPage extends Component {
};
handleRetryTree = () => {
if (this.props.companyId) this.props.loadCategoryTree();
if (this.state.companyId) void categoryAutomat.actions.loadTree();
};
handleRetryArticles = () => {
const { currentCategoryId } = this.props;
const { currentCategoryId } = this.state;
if (currentCategoryId) {
this.props.loadCategoryArticles(currentCategoryId);
void categoryAutomat.actions.loadArticles(currentCategoryId);
}
};
handleNavigateBack = () => {
const { breadcrumbs } = this.props;
const { breadcrumbs } = this.state;
if (breadcrumbs.length < 2) return;
this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
};
@@ -283,7 +297,8 @@ class ArtikelKategorienPage extends Component {
articles,
renameMode,
terminals,
} = this.props;
drafts,
} = this.state;
if (!companyId) {
return (
@@ -347,8 +362,8 @@ class ArtikelKategorienPage extends Component {
{childCategories.map((item, index) => {
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
const draftName =
this.state.drafts[item.id] !== undefined
? this.state.drafts[item.id]
drafts[item.id] !== undefined
? drafts[item.id]
: label;
const editProps = renameMode
? {
@@ -359,13 +374,12 @@ class ArtikelKategorienPage extends Component {
terminals,
categoryPosterminalIds: item.posterminalIds || [],
onTerminalToggle: (terminalId, enabled) => {
const thunk = enabled
? this.props.enableTerminalCategory
: this.props.disableTerminalCategory;
void thunk({ terminalId, categoryId: item.id })
.unwrap()
const action = enabled
? posterminalAutomat.actions.enableCategory
: posterminalAutomat.actions.disableCategory;
void action({ terminalId, categoryId: item.id })
.then(() => {
this.props.patchCategoryPosterminalIds({
categoryAutomat.actions.patchCategoryPosterminalIds({
categoryId: item.id,
terminalId,
enabled,
@@ -382,10 +396,9 @@ class ArtikelKategorienPage extends Component {
const file = e.target.files?.[0];
if (!file) return;
try {
// Prepare image (resize and encode to AVIF)
const { prepareArticlePhoto } = await import('../../utils/prepareArticlePhoto');
const avifFile = await prepareArticlePhoto(file);
await this.props.uploadCategoryImage({
await categoryAutomat.actions.uploadCategoryImage({
categoryId: item.id,
file: avifFile,
});
@@ -396,7 +409,7 @@ class ArtikelKategorienPage extends Component {
input.click();
},
onPhotoDelete: () => {
void this.props.deleteCategoryImage(item.id)
void categoryAutomat.actions.deleteCategoryImage(item.id)
.catch(notifyError);
},
nameInput: (
@@ -478,36 +491,4 @@ class ArtikelKategorienPage extends Component {
}
}
const mapStateToProps = (state) => ({
companyId: selectCompanyId(state),
categories: selectCategoryMap(state),
rootCategoryId: state.category.rootCategoryId,
treeStatus: selectCategoryTreeStatus(state),
articlesStatus: selectCategoryArticlesStatus(state),
error: selectCategoryError(state),
currentCategoryId: state.category.currentCategoryId,
breadcrumbs: selectCategoryBreadcrumbs(state),
childCategories: selectChildCategories(state),
articles: selectCategoryArticles(state),
canGoBack: selectCanNavigateBack(state),
renameMode: selectRenameMode(state),
terminals: selectPosterminals(state),
});
const mapDispatchToProps = {
loadCategoryTree,
loadCategoryArticles,
loadPosterminals,
navigateToCategory,
renameCategory,
deleteCategory,
moveCategory,
reorderCategories,
enableTerminalCategory,
disableTerminalCategory,
patchCategoryPosterminalIds,
uploadCategoryImage,
deleteCategoryImage,
};
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));
export default withCategoryRoute(ArtikelKategorienPage);

View File

@@ -31,6 +31,7 @@ import { requireArray, requireDefined, requireObject } from '../../utils/apiShap
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
import { generateTextThumbnail } from '../../utils/textThumbnail';
import { extractPdfText } from '../../utils/pdfText';
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
@@ -477,12 +478,19 @@ class LieferungNeuPage extends PureComponent {
const text = await extractPdfText(file, { layout: true });
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
let txtThumbnail = null;
try {
txtThumbnail = await generateTextThumbnail(text);
} catch (thumbErr) {
console.error('Fehler beim Erstellen des Text-Thumbnails:', thumbErr);
}
const txtAttachment = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile,
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
thumbnail: txtThumbnail,
text,
parentPdfId: pdfId,
uploadedAt: new Date().toISOString(),
@@ -499,6 +507,12 @@ class LieferungNeuPage extends PureComponent {
} catch (err) {
console.error('Fehler beim Lesen der Datei:', err);
}
let thumbnail = null;
try {
thumbnail = await generateTextThumbnail(text || file);
} catch (thumbErr) {
console.error('Fehler beim Erstellen des Text-Thumbnails:', thumbErr);
}
const attachmentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const textAttachment = {
id: attachmentId,
@@ -506,6 +520,7 @@ class LieferungNeuPage extends PureComponent {
name: file.name,
size: file.size,
type: isCsv ? 'text/csv' : 'text/plain',
thumbnail,
text,
uploadedAt: new Date().toISOString(),
};
@@ -537,6 +552,13 @@ class LieferungNeuPage extends PureComponent {
const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
let txtThumbnail = null;
try {
txtThumbnail = await generateTextThumbnail(text);
} catch (thumbErr) {
console.error('Fehler beim Aktualisieren des Text-Thumbnails:', thumbErr);
}
const existingTxt = this.state.attachments?.find(
(a) => a.parentPdfId === attachment.id || a.name === txtName,
);
@@ -546,6 +568,7 @@ class LieferungNeuPage extends PureComponent {
file: txtFile,
size: txtFile.size,
text,
thumbnail: txtThumbnail || existingTxt.thumbnail,
isDirty: true,
});
} else {
@@ -555,6 +578,7 @@ class LieferungNeuPage extends PureComponent {
name: txtFile.name,
size: txtFile.size,
type: 'text/plain',
thumbnail: txtThumbnail,
text,
parentPdfId: attachment.id,
uploadedAt: new Date().toISOString(),
@@ -996,7 +1020,7 @@ class LieferungNeuPage extends PureComponent {
);
}
handleSaveTextEditor(newText) {
async handleSaveTextEditor(newText) {
const attachment = this.state.textEditorAttachment;
if (!attachment) return;
@@ -1004,11 +1028,19 @@ class LieferungNeuPage extends PureComponent {
type: 'text/plain;charset=utf-8',
});
let thumbnail = attachment.thumbnail;
try {
thumbnail = await generateTextThumbnail(newText);
} catch (err) {
console.error('Fehler beim Aktualisieren des Text-Thumbnails:', err);
}
this.automat.actions.updateAttachment({
id: attachment.id,
file: newFile,
size: newFile.size,
text: newText,
thumbnail,
isDirty: true,
});