/** * @file categoryAutomat.js * @description Owns the hierarchical category tree (id -> node map), the root * category id, breadcrumbs helpers, and all category/article mutations. * * Per-category article views (`categoryViews`) live in `categoryViewAutomat.js` * and attach to this tree automat via `attachCategoryTree()`, keeping the * dependency one-directional (views -> tree). * * Upstream Triggers: * - `companyAutomat`: Subscribes to company changes — triggers `actions.loadTree()` * whenever `company.current.id` or `rootCategoryId` changes/becomes available; * resets when company is unset. * - `authAutomat`: Subscribes to auth state changes — triggers `actions.reset()` on `'anon'`. * - `sseAutomat`: Calls `categoryAutomat.actions.loadTree()` and * `invalidateAllCategoryViews()` upon receiving a `'category'` SSE event. * - UI / Routes: Category tree operations (`createCategory`, `createArticle`, * `deleteCategory`, `moveCategory`, `renameCategory`, `reorderCategories`, etc.). * * Downstream Triggers: * - `categoryViewAutomat`: `loadTree()` calls `loadAllCategoryViews()` once * `treeStatus === 'succeeded'`, so every category's articles are already cached * when a route mounts. Mutations patch the loaded views through the same module. * - `articleIndexAutomat`: Derives its client-side search index from the union of * the loaded category views, so the index costs no requests of its own and * `GET /api/articleIndex` is no longer used. */ import { Automat } from 'react-automat'; import { category as categoryApi, article as articleApi } from '../api'; import authAutomat from './authAutomat'; import companyAutomat from './companyAutomat'; import sseAutomat from './sseAutomat'; import { attachCategoryTree, categoryViews, loadAllCategoryViews, disposeAllCategoryViewAutomats, refreshCategoryViewChildren, appendArticlesToCategoryViews, updateArticleInCategoryViews, removeArticleBySku, waitForCategoryTree, } from './categoryViewAutomat'; import { parseCategoryTreeResponse, normalizeCategoryNode, cleanCategorySlugPath, getCategoryChildren, getCategoryDescendantIds, } from '../utils/categoryUtils'; /** * @typedef {Object} CategoryState * @property {number|string|null} companyId * @property {string|null} languageId * @property {Record|null} categories id → node map * @property {number|string|null} rootCategoryId * @property {number} articleCount Total parent articles in tree * @property {boolean} renameMode * @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus * @property {string|null} error * @property {boolean} viewsLoading Whether the post-tree view preload is running. */ /** @type {CategoryState} */ const initialState = { companyId: null, languageId: null, categories: null, rootCategoryId: null, articleCount: 0, renameMode: false, treeStatus: 'idle', error: null, viewsLoading: false, }; let categoryAutomat; let _treeLoadingPromise = null; /** * Returns the parsed category map and root id, or `null` while the tree is * still loading. Replaces the former `StorageManager.getCategoryState()`. * * @returns {{ categories: Record, rootCategoryId: unknown }|null} */ export function getCategoryState() { const state = categoryAutomat.state; if (state.categories == null || state.treeStatus !== 'succeeded') return null; return { categories: state.categories, rootCategoryId: state.rootCategoryId }; } export const selectCategoryTreeArticleCount = () => categoryAutomat.state.articleCount; /** * Resolves once the category tree has loaded. * * @returns {Promise} State with `treeStatus === 'succeeded'`. */ export function whenCategoryTreeReady() { return waitForCategoryTree(); } // ─── Actions ─────────────────────────────────────────────────────────────────── const actions = { /** Load the category tree for the currently selected company. */ async loadTree() { let company = companyAutomat.state.current; if (!company && companyAutomat.state.status === 'idle') { await companyAutomat.actions.load(); company = companyAutomat.state.current; } if (!company) return; const { id: companyId, rootCategoryId: companyRootId } = company; if (!companyRootId) return; if (_treeLoadingPromise) return _treeLoadingPromise; categoryAutomat.setState({ treeStatus: 'loading', error: null }); _treeLoadingPromise = (async () => { try { const data = await categoryApi.getTree(); const parsed = parseCategoryTreeResponse(data, companyRootId); categoryAutomat.setState({ treeStatus: 'succeeded', companyId, languageId: parsed.languageId, categories: parsed.categories, rootCategoryId: parsed.rootCategoryId, articleCount: parsed.articleCount, error: null, viewsLoading: true, }); refreshCategoryViewChildren(parsed.categories); // The tree is the source of truth for every category view, so warm all // of them now: routes mount from cache instead of waiting on a fetch. // A failing category must not fail the whole tree load. const { failed } = await loadAllCategoryViews(); if (failed.length) { console.warn( `${failed.length} Kategorie(n) konnten nicht vorab geladen werden.`, failed, ); } categoryAutomat.setState({ viewsLoading: false }); } catch (err) { categoryAutomat.setState({ treeStatus: 'failed', viewsLoading: false, error: err?.message ?? String(err), }); throw err; } })().finally(() => { _treeLoadingPromise = null; }); return _treeLoadingPromise; }, /** Toggle rename/edit mode for the category grid. */ setRenameMode(enabled) { categoryAutomat.setState({ renameMode: Boolean(enabled) }); }, /** * (Re-)loads every category view of the current tree on demand. * * @returns {Promise<{ loaded: number, failed: Array<{slug: string, error: any}> }>} Load summary. */ async loadAllCategoryViews() { return loadAllCategoryViews(); }, // ─── Category mutations ──────────────────────────────────────────────────── /** * Create a single category. * @param {{ parentId: number|string, name: string }} param0 */ async createCategory({ parentId, name }) { const res = await categoryApi.create({ parentId, name }); const node = normalizeCategoryNode(res.category); const { categories } = categoryAutomat.state; const updated = { ...(categories ?? {}), [String(node.id)]: node }; categoryAutomat.setState({ categories: updated }); refreshCategoryViewChildren(updated); return node; }, /** * Create multiple categories under the same parent. * @param {{ parentId: number|string, names: string[] }} param0 */ async createCategories({ parentId, names }) { const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean); if (list.length === 0) throw new Error('Keine Namen übergeben.'); const created = []; for (const name of list) { const res = await categoryApi.create({ parentId, name }); created.push(normalizeCategoryNode(res.category)); } const patch = Object.fromEntries(created.map((n) => [String(n.id), n])); const updated = { ...(categoryAutomat.state.categories ?? {}), ...patch }; categoryAutomat.setState({ categories: updated }); refreshCategoryViewChildren(updated); return created; }, /** * Create a single article. * @param {{ parentId: number|string, name: string }} param0 */ async createArticle({ parentId, name }) { const { sku } = await articleApi.create({ categoryId: parentId, name }); const { article: created } = await articleApi.getBySku(sku); if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] }); return created; }, /** * Create multiple articles under the same parent. * @param {{ parentId: number|string, names: string[] }} param0 */ async createArticles({ parentId, names }) { const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean); if (list.length === 0) throw new Error('Keine Namen übergeben.'); const created = []; for (const name of list) { const { sku } = await articleApi.create({ categoryId: parentId, name }); const { article: created1 } = await articleApi.getBySku(sku); if (created1) created.push(created1); } appendArticlesToCategoryViews({ categoryId: parentId, articles: created }); return created; }, /** * Rename a category. * @param {{ categoryId: number|string, name: string }} param0 */ async renameCategory({ categoryId, name }) { if (String(categoryId) === String(categoryAutomat.state.rootCategoryId)) { throw new Error('Home-Kategorie kann nicht umbenannt werden.'); } const trimmed = String(name ?? '').trim(); if (!trimmed) throw new Error('Name required'); const res = await categoryApi.updateName(categoryId, trimmed); const updated = normalizeCategoryNode(res.category); const key = String(updated.id); const { categories } = categoryAutomat.state; const nextCategories = categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null; categoryAutomat.setState({ categories: nextCategories, }); refreshCategoryViewChildren(nextCategories); return updated; }, /** * Reorder sibling categories. * @param {{ parentId?: number|string, categoryIds: number[] }} param0 */ async reorderCategories({ parentId, categoryIds }) { const targetParentId = parentId != null ? parentId : categoryAutomat.state.rootCategoryId; const res = await categoryApi.reorder(targetParentId, categoryIds); const ordered = res.categoryIds; const parentKey = String(targetParentId); const idToSort = new Map(ordered.map((id, index) => [String(id), index])); const patched = { ...categoryAutomat.state.categories }; for (const node of Object.values(patched)) { if (node.parentId != null && String(node.parentId) === parentKey && idToSort.has(String(node.id))) { patched[String(node.id)] = { ...node, sort: idToSort.get(String(node.id)) }; } } categoryAutomat.setState({ categories: patched }); refreshCategoryViewChildren(patched); return ordered; }, /** * Move a category under a new parent. * @param {{ categoryId: number|string, parentId: number|string }} param0 */ async moveCategory({ categoryId, parentId }) { const res = await categoryApi.move(categoryId, parentId); const moved = res.category; const { categories } = categoryAutomat.state; if (!categories || !moved) return; const key = String(moved.id); const updated = { ...categories, [key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort }, }; categoryAutomat.setState({ categories: updated, }); refreshCategoryViewChildren(updated); return moved; }, /** * Delete a category by id. * @param {number|string} categoryId */ async deleteCategory(categoryId) { await categoryApi.delete(categoryId); const { categories } = categoryAutomat.state; if (!categories) return; const next = { ...categories }; delete next[String(categoryId)]; categoryAutomat.setState({ categories: next }); refreshCategoryViewChildren(next); return categoryId; }, /** * Delete a category and all its descendants recursively. * Calls DELETE /api/category/:id?recursive=true. * * @param {number|string} categoryId */ async deleteCategoryRecursive(categoryId) { const { categories, rootCategoryId } = categoryAutomat.state; if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) { throw new Error('Root-Kategorie kann nicht gelöscht werden'); } const removedIds = getCategoryDescendantIds(categories, categoryId); await categoryApi.delete(categoryId, { recursive: true }); if (categories) { const next = { ...categories }; for (const id of removedIds) { delete next[String(id)]; } categoryAutomat.setState({ categories: next }); refreshCategoryViewChildren(next); } return categoryId; }, /** * Toggle the POS flag on a category. * @param {{ categoryId: number|string, pos: boolean }} param0 */ async toggleCategoryPos({ categoryId, pos }) { const res = await categoryApi.updatePos(categoryId, pos); const updated = normalizeCategoryNode(res.category); const { categories } = categoryAutomat.state; if (!categories) return; const key = String(updated.id); const nextCategories = { ...categories, [key]: { ...categories[key], pos: updated.pos } }; categoryAutomat.setState({ categories: nextCategories, }); refreshCategoryViewChildren(nextCategories); return updated; }, /** * Upload an image for a category. * @param {{ categoryId: number|string, file: Blob }} param0 */ async uploadCategoryImage({ categoryId, file }) { const res = await categoryApi.uploadImage(categoryId, file); const { id, fullHash, thumbW, thumbH, thumbHash } = res.category; const { categories } = categoryAutomat.state; if (!categories) return res.category; const key = String(id); const nextCategories = { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash } }; categoryAutomat.setState({ categories: nextCategories, }); refreshCategoryViewChildren(nextCategories); return res.category; }, /** * Delete the image for a category. * @param {number|string} categoryId */ async deleteCategoryImage(categoryId) { const res = await categoryApi.deleteImage(categoryId); const { id } = res.category; const { categories } = categoryAutomat.state; if (!categories) return res.category; const key = String(id); const nextCategories = { ...categories, [key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null }, }; categoryAutomat.setState({ categories: nextCategories, }); refreshCategoryViewChildren(nextCategories); return res.category; }, /** * Generate a mosaic image for a category from its articles and subcategories. * @param {{ categoryId: number|string, size?: number }} param0 */ async generateCategoryImage({ categoryId, size } = {}) { const res = await categoryApi.generateImage(categoryId, { size }); const { id, fullHash, thumbW, thumbH, thumbHash } = res.category; const { categories } = categoryAutomat.state; if (!categories) return res.category; const key = String(id); const nextCategories = { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash }, }; categoryAutomat.setState({ categories: nextCategories, }); refreshCategoryViewChildren(nextCategories); return res.category; }, // ─── Local state patches ─────────────────────────────────────────────────── /** Remove an article from all live category view lists (used after article deletion). */ removeArticleBySku(sku) { removeArticleBySku(sku); }, /** * Patch posterminalIds on a category node. * @param {{ categoryId: number|string, terminalId: number, enabled: boolean }} param0 */ patchCategoryPosterminalIds({ categoryId, terminalId, enabled }) { const { categories } = categoryAutomat.state; const cat = categories?.[String(categoryId)]; if (!cat) return; const current = Array.isArray(cat.posterminalIds) ? cat.posterminalIds : []; const next = enabled ? current.includes(terminalId) ? current : [...current, terminalId] : current.filter((id) => id !== terminalId); const nextCategories = { ...categories, [String(categoryId)]: { ...cat, posterminalIds: next }, }; categoryAutomat.setState({ categories: nextCategories, }); refreshCategoryViewChildren(nextCategories); }, /** Invalidate all loaded category views. */ invalidateAllCategoryViews() { invalidateAllCategoryViews(); }, /** Reset state to idle (logout / company switch). */ reset() { disposeAllCategoryViewAutomats(); _treeLoadingPromise = null; categoryAutomat.setState(initialState); }, }; /** * Global category tree state container. * Loads automatically when companyAutomat has a company with a rootCategoryId. * * @type {Automat} */ categoryAutomat = new Automat(initialState, actions, { name: 'category' }); // Let the per-category views resolve their slug paths against this tree. attachCategoryTree(categoryAutomat); // Keyed view accessors on categoryAutomat: categoryAutomat.key = (slugPath) => categoryViews.key(cleanCategorySlugPath(slugPath)); categoryAutomat.read = (slugPath) => categoryViews.read(cleanCategorySlugPath(slugPath)); categoryAutomat.load = (slugPath, options) => categoryViews.load(cleanCategorySlugPath(slugPath), options); categoryAutomat.views = categoryViews; // Load tree whenever the company data changes (or first becomes available) let _lastCompanyId = null; function syncCategoryWithCompany(company) { const id = company?.current?.id ?? null; const rootCategoryId = company?.current?.rootCategoryId ?? null; if (id && rootCategoryId && id !== _lastCompanyId) { const isCompanySwitch = _lastCompanyId !== null; _lastCompanyId = id; if (isCompanySwitch) { disposeAllCategoryViewAutomats(); } void categoryAutomat.actions.loadTree(); } if (!id && _lastCompanyId) { _lastCompanyId = null; categoryAutomat.actions.reset(); } } companyAutomat.subscribe(syncCategoryWithCompany); syncCategoryWithCompany(companyAutomat.state); // Reset tree on logout authAutomat.subscribe((auth) => { if (auth.status === 'anon') { _lastCompanyId = null; categoryAutomat.actions.reset(); } }); // Upstream Trigger: sseAutomat if (sseAutomat?.actions?.on) { sseAutomat.actions.on('category', () => { void categoryAutomat.actions.loadTree().catch(() => {}); invalidateAllCategoryViews(); }); } // Re-export pure helpers for consumers that previously imported from categorySlice export { ROOT_CATEGORY_NAME, parseCategoryTreeResponse, normalizeCategoryNode, getCategoryBreadcrumbs, getCategoryChildren, cleanCategorySlugPath, resolveCategoryIdFromSlugPath, } from '../utils/categoryUtils'; export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils'; // Re-export category view automats and actions export { categoryViews, getOrCreateCategoryAutomat, invalidateAllCategoryViews, disposeAllCategoryViewAutomats, loadAllCategoryViews, collectLoadedCategoryViewData, updateArticleInCategoryViews, } from './categoryViewAutomat'; export default categoryAutomat;