Files
dashfront/src/automats/categoryAutomat.js
2026-10-24 06:43:56 +02:00

530 lines
18 KiB
JavaScript

/**
* @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 {
attachCategoryTree,
categoryViews,
loadAllCategoryViews,
disposeAllCategoryViewAutomats,
refreshCategoryViewChildren,
appendArticlesToCategoryViews,
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<string,object>|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<string,object>, 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<CategoryState>} 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);
},
/** 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<CategoryState>}
*/
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;
companyAutomat.subscribe((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();
}
});
// Reset tree on logout
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
// 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,
} from './categoryViewAutomat';
export default categoryAutomat;