import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { category, article } from '../api'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { catchBackendError, resolveBackendError } from '../utils/backendError'; import { logout } from './authSlice'; import { loadCurrentCompany } from './companySlice'; export const ROOT_CATEGORY_NAME = 'Home'; export function normalizeCategoryNode(item) { requireObject(item, 'category'); requireDefined(item.id, 'category.id'); return { ...item, id: item.id, parentId: item.parentId, name: item.name == null ? null : String(item.name).trim() || null, posterminalIds: Array.isArray(item.posterminalIds) ? item.posterminalIds : [], }; } /** Backend: { languageId, tree: [...], articles: [] } — rootCategoryId aus Company */ export function parseCategoryTreeResponse(data, companyRootCategoryId) { requireObject(data, 'category/tree'); const list = requireArray(data.tree, 'category/tree.tree'); requireArray(data.articles, 'category/tree.articles'); const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId; requireDefined(rootCategoryId, 'rootCategoryId'); const categories = Object.fromEntries( list.map((item) => [String(item.id), normalizeCategoryNode(item)]), ); return { rootCategoryId, categories, rootArticles: data.articles, languageId: data.languageId, }; } export function categoryDisplayName(name, categoryId, rootCategoryId) { if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) { return ROOT_CATEGORY_NAME; } return name?.trim() || ''; } export function categorySlug(node, rootCategoryId) { if (rootCategoryId != null && String(node.id) === String(rootCategoryId)) { return ROOT_CATEGORY_NAME; } return node.slug || null; } export function normalizeCategoryMap(data, companyRootCategoryId) { return parseCategoryTreeResponse(data, companyRootCategoryId).categories; } export function getCategoryChildren(categories, parentId) { if (!categories) return []; const parentKey = String(parentId); return Object.values(categories) .filter((item) => item.parentId != null && String(item.parentId) === parentKey) .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id); } export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId) { if (!categories || currentCategoryId == null) return []; const crumbs = []; const seen = new Set(); let id = currentCategoryId; while (id != null && !seen.has(String(id))) { seen.add(String(id)); const node = categories[String(id)]; if (!node) break; crumbs.unshift({ id: node.id, name: categoryDisplayName(node.name, node.id, rootCategoryId), slug: categorySlug(node, rootCategoryId), }); const parentId = node.parentId; if (parentId == null || parentId === 0) break; if (String(parentId) === String(id)) break; id = parentId; } return crumbs; } export const loadCategoryTree = createAsyncThunk( 'category/loadTree', async (_, { rejectWithValue, getState }) => { try { const companyId = getState().company.current?.id; const companyRootId = getState().company.current?.rootCategoryId; requireDefined(companyRootId, 'company.rootCategoryId'); const data = await category.getTree(); const parsed = parseCategoryTreeResponse(data, companyRootId); return { companyId, ...parsed, }; } catch (err) { return catchBackendError(rejectWithValue)(err); } }, ); export const loadCategoryArticles = createAsyncThunk( 'category/loadArticles', async (categoryId, { rejectWithValue }) => { try { const data = await category.getArticles(categoryId); requireObject(data, `category/${categoryId}/articles`); const articles = requireArray(data.articles, `category/${categoryId}/articles.articles`); return { categoryId, articles }; } catch (err) { return catchBackendError(rejectWithValue)(err); } }, ); export const createCategory = createAsyncThunk( 'category/create', async ({ parentId, name }, { rejectWithValue }) => { try { const data = await category.create({ parentId, name }); requireObject(data.category, 'POST /api/category.category'); return normalizeCategoryNode(data.category); } catch (err) { return catchBackendError(rejectWithValue)(err); } }, ); export const createArticle = createAsyncThunk( 'category/createArticle', async ({ parentId, name }, { rejectWithValue }) => { try { const data = await article.create({ parentId, name }); requireObject(data.article, 'POST /api/article.article'); requireDefined(data.article.id, 'article.id'); return data.article; } catch (err) { return catchBackendError(rejectWithValue)(err); } }, ); export const renameCategory = createAsyncThunk( 'category/rename', async ({ categoryId, name }, { rejectWithValue }) => { try { const trimmed = String(name ?? '').trim(); if (!trimmed) throw new Error('Name required'); const data = await category.updateName(categoryId, trimmed); requireObject(data.category, 'PATCH /api/category/:id.category'); return normalizeCategoryNode(data.category); } catch (err) { return catchBackendError(rejectWithValue)(err); } }, ); /** * Reorder sibling categories (new order of children under the current parent). * `PUT /api/category/order` → 200 { categoryIds: number[] } */ export const reorderCategories = createAsyncThunk( 'category/reorder', async ({ categoryIds }, { rejectWithValue, getState }) => { try { const ids = requireArray(categoryIds, 'categoryIds'); const parentId = getState().category.currentCategoryId; requireDefined(parentId, 'currentCategoryId'); const data = await category.reorder(parentId, ids); requireObject(data, 'PUT /api/category/order'); requireArray(data.categoryIds, 'categoryIds'); return { parentId, categoryIds: requireArray(data.categoryIds, 'categoryIds') }; } catch (err) { return catchBackendError(rejectWithValue)(err); } }, ); /** * Move a category under a new parent (drop into subcategory). * Backend: PATCH /api/category/:id Body: { parentId } → { category }. * (Vorschlag — Route fehlt noch in dashback; siehe TODO.md.) */ export const moveCategory = createAsyncThunk( 'category/move', async ({ categoryId, parentId }, { rejectWithValue }) => { try { const data = await category.move(categoryId, parentId); requireObject(data.category, 'PATCH /api/category/:id.category'); return normalizeCategoryNode(data.category); } catch (err) { return catchBackendError(rejectWithValue)(err); } }, ); export const deleteCategory = createAsyncThunk( 'category/delete', async (categoryId, { rejectWithValue }) => { try { await category.delete(categoryId); return categoryId; } catch (err) { return catchBackendError(rejectWithValue)(err); } }, ); /** * Toggle the POS (Point of Sale) flag on a category. * Backend: PATCH /api/category/:id Body: { pos: boolean } → { category }. * PROPOSED — backend needs to implement this field. */ export const toggleCategoryPos = createAsyncThunk( 'category/togglePos', async ({ categoryId, pos }, { rejectWithValue }) => { try { const data = await category.updatePos(categoryId, pos); requireObject(data.category, 'PATCH /api/category/:id.category (pos)'); return normalizeCategoryNode(data.category); } catch (err) { return catchBackendError(rejectWithValue)(err); } }, ); /** * POST /api/category/:id/image → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } } */ export const uploadCategoryImage = createAsyncThunk( 'category/uploadImage', async ({ categoryId, file }, { rejectWithValue }) => { try { const data = await category.uploadImage(categoryId, file); requireObject(data.category, 'POST /api/category/:id/image'); return data.category; } catch (err) { return catchBackendError(rejectWithValue)(err); } }, ); /** * DELETE /api/category/:id/image → 200 { category: { id, thumbW: null, thumbH: null, thumbHash: null } } */ export const deleteCategoryImage = createAsyncThunk( 'category/deleteImage', async (categoryId, { rejectWithValue }) => { try { const data = await category.deleteImage(categoryId); requireObject(data.category, 'DELETE /api/category/:id/image'); return data.category; } catch (err) { return catchBackendError(rejectWithValue)(err); } }, ); const initialState = { companyId: null, languageId: null, categories: null, rootCategoryId: null, rootArticles: [], currentCategoryId: null, articles: [], articlesTotal: 0, renameMode: false, treeStatus: 'idle', articlesStatus: 'idle', error: null, }; const categorySlice = createSlice({ name: 'category', initialState, reducers: { navigateToCategory(state, action) { state.currentCategoryId = action.payload; state.error = null; if (String(action.payload) === String(state.rootCategoryId)) { state.articles = state.rootArticles; state.articlesTotal = state.rootArticles.length; state.articlesStatus = 'succeeded'; } else { state.articles = []; state.articlesTotal = 0; state.articlesStatus = 'idle'; } }, navigateBack(state) { const current = state.categories?.[String(state.currentCategoryId)]; const parentId = current?.parentId; if (parentId == null || parentId === 0) return; if (String(parentId) === String(state.rootCategoryId) || state.categories?.[String(parentId)]) { state.currentCategoryId = parentId; state.error = null; if (String(parentId) === String(state.rootCategoryId)) { state.articles = state.rootArticles; state.articlesTotal = state.rootArticles.length; state.articlesStatus = 'succeeded'; } else { state.articles = []; state.articlesTotal = 0; state.articlesStatus = 'idle'; } } }, resetCategoryState() { return initialState; }, patchCategoryPosterminalIds(state, action) { const { categoryId, terminalId, enabled } = action.payload; const cat = state.categories?.[String(categoryId)]; if (!cat) return; if (!Array.isArray(cat.posterminalIds)) cat.posterminalIds = []; if (enabled && !cat.posterminalIds.includes(terminalId)) { cat.posterminalIds.push(terminalId); } else if (!enabled) { cat.posterminalIds = cat.posterminalIds.filter((id) => id !== terminalId); } }, setRenameMode(state, action) { state.renameMode = Boolean(action.payload); }, removeArticleBySku(state, action) { const sku = action.payload; state.articles = state.articles.filter((row) => row.sku !== sku); state.articlesTotal = state.articles.length; state.rootArticles = state.rootArticles.filter((row) => row.sku !== sku); }, /** Keep category tile list in sync with article detail (e.g. after photo upload or category change). */ patchArticleInLists(state, action) { const { sku, categoryIds, ...fields } = action.payload; if (sku == null) return; // Merge fields into existing article in both lists const patchList = (list) => list.map((row) => { if (row.sku !== sku) return row; return { ...row, ...fields }; }); state.articles = patchList(state.articles); state.rootArticles = patchList(state.rootArticles); if (categoryIds === undefined) return; const newCategoryIds = categoryIds ?? []; // Helper: find the article object in either list const findArticle = (list) => list.find((row) => row.sku === sku); const articleObj = findArticle(state.articles) || findArticle(state.rootArticles); if (!articleObj) return; // Handle root category membership if (state.rootCategoryId != null) { const inRootCategory = newCategoryIds.some( (id) => String(id) === String(state.rootCategoryId), ); const alreadyInRoot = state.rootArticles.some((row) => row.sku === sku); if (!inRootCategory) { state.rootArticles = state.rootArticles.filter((row) => row.sku !== sku); } else if (!alreadyInRoot) { state.rootArticles = [...state.rootArticles, articleObj]; } } // Handle current category membership if (state.currentCategoryId != null) { const inCurrentCategory = newCategoryIds.some( (id) => String(id) === String(state.currentCategoryId), ); const alreadyInCurrent = state.articles.some((row) => row.sku === sku); if (!inCurrentCategory) { state.articles = state.articles.filter((row) => row.sku !== sku); state.articlesTotal = state.articles.length; } else if (!alreadyInCurrent) { state.articles = [...state.articles, articleObj]; state.articlesTotal = state.articles.length; } } }, }, extraReducers: (builder) => { builder .addCase(loadCategoryTree.pending, (state) => { state.treeStatus = 'loading'; state.error = null; }) .addCase(loadCategoryTree.fulfilled, (state, action) => { state.treeStatus = 'succeeded'; state.companyId = action.payload.companyId; state.languageId = action.payload.languageId; state.categories = action.payload.categories; state.rootCategoryId = action.payload.rootCategoryId; state.currentCategoryId = action.payload.rootCategoryId; state.rootArticles = action.payload.rootArticles; state.articles = state.rootArticles; state.articlesTotal = state.rootArticles.length; state.articlesStatus = 'succeeded'; }) .addCase(loadCategoryTree.rejected, (state, action) => { state.treeStatus = 'failed'; state.error = resolveBackendError(action); }) .addCase(loadCategoryArticles.pending, (state) => { state.articlesStatus = 'loading'; state.error = null; }) .addCase(loadCategoryArticles.fulfilled, (state, action) => { state.articlesStatus = 'succeeded'; state.articles = action.payload.articles; state.articlesTotal = action.payload.articles.length; }) .addCase(loadCategoryArticles.rejected, (state, action) => { state.articlesStatus = 'failed'; state.articles = []; state.articlesTotal = 0; state.error = resolveBackendError(action); }) .addCase(createCategory.fulfilled, (state, action) => { if (!state.categories) state.categories = {}; state.categories[String(action.payload.id)] = action.payload; }) .addCase(renameCategory.fulfilled, (state, action) => { const { id, name } = action.payload; if (id == null || name == null) return; const key = String(id); const existing = state.categories?.[key]; if (existing) { state.categories[key] = { ...existing, name }; } else if (state.categories) { state.categories[key] = action.payload; } state.rootArticles = state.rootArticles.map((row) => row?.categoryId != null && String(row.categoryId) === key ? { ...row, name } : row, ); }) .addCase(reorderCategories.fulfilled, (state, action) => { const { parentId, categoryIds } = action.payload; if (!state.categories) return; const parentKey = String(parentId); const idToSort = new Map( categoryIds.map((id, index) => [String(id), index]), ); for (const node of Object.values(state.categories)) { if ( node.parentId != null && String(node.parentId) === parentKey && idToSort.has(String(node.id)) ) { state.categories[String(node.id)] = { ...node, sort: idToSort.get(String(node.id)), }; } } }) .addCase(moveCategory.fulfilled, (state, action) => { const moved = action.payload; if (moved == null || state.categories == null) return; const existing = state.categories[String(moved.id)] ?? {}; // Der Move-Response enthält nur { id, parentId, sort } — keinen Namen. // parentId/sort werden übernommen, der bestehende Name bleibt erhalten // (das Backend hat ihn beim Move nicht verändert). state.categories[String(moved.id)] = { ...existing, parentId: moved.parentId, sort: moved.sort, }; }) .addCase(createArticle.fulfilled, (state, action) => { const created = action.payload; if (created == null) return; if (String(created.parentId) === String(state.currentCategoryId)) { state.articles = [...state.articles, created]; state.articlesTotal = state.articles.length; state.articlesStatus = 'succeeded'; } if (String(created.parentId) === String(state.rootCategoryId)) { state.rootArticles = [...state.rootArticles, created]; } }) .addCase(deleteCategory.fulfilled, (state, action) => { if (!state.categories) return; delete state.categories[String(action.payload)]; if (String(state.currentCategoryId) === String(action.payload)) { state.currentCategoryId = state.rootCategoryId; state.articles = state.rootArticles; state.articlesTotal = state.rootArticles.length; state.articlesStatus = 'succeeded'; } }) .addCase(toggleCategoryPos.fulfilled, (state, action) => { const updated = action.payload; if (updated == null || state.categories == null) return; const key = String(updated.id); const existing = state.categories[key]; if (existing) { state.categories[key] = { ...existing, pos: updated.pos }; } }) .addCase(uploadCategoryImage.fulfilled, (state, action) => { const { id, fullHash, thumbW, thumbH, thumbHash } = action.payload; if (id == null || state.categories == null) return; const key = String(id); const existing = state.categories[key]; if (existing) { state.categories[key] = { ...existing, fullHash, thumbW, thumbH, thumbHash }; } }) .addCase(deleteCategoryImage.fulfilled, (state, action) => { const { id } = action.payload; if (id == null || state.categories == null) return; const key = String(id); const existing = state.categories[key]; if (existing) { state.categories[key] = { ...existing, fullHash: null, thumbW: null, thumbH: null, thumbHash: null, }; } }) .addCase(loadCurrentCompany.fulfilled, (state, action) => { const nextCompanyId = action.payload?.id ?? null; if (nextCompanyId != null && nextCompanyId !== state.companyId) { return { ...initialState, companyId: nextCompanyId, }; } }) .addCase(logout.fulfilled, () => initialState); }, }); export const { navigateToCategory, navigateBack, resetCategoryState, setRenameMode, removeArticleBySku, patchArticleInLists, patchCategoryPosterminalIds, } = categorySlice.actions; export const selectRenameMode = (state) => state.category.renameMode; export const selectCategoryMap = (state) => state.category.categories; export const selectCurrentCategoryId = (state) => state.category.currentCategoryId; export const selectCategoryRootId = (state) => state.category.rootCategoryId; export const selectCategoryTreeStatus = (state) => state.category.treeStatus; export const selectCategoryArticles = (state) => state.category.articles; export const selectCategoryArticlesStatus = (state) => state.category.articlesStatus; export const selectCategoryError = (state) => state.category.error; export const selectCurrentCategory = (state) => { const id = state.category.currentCategoryId; if (id == null) return null; return state.category.categories?.[String(id)] ?? null; }; export const selectChildCategories = (state) => getCategoryChildren(state.category.categories, state.category.currentCategoryId); export const selectCategoryBreadcrumbs = (state) => getCategoryBreadcrumbs( state.category.categories, state.category.currentCategoryId, state.category.rootCategoryId, ); export const selectCanNavigateBack = (state) => { const { currentCategoryId, rootCategoryId, categories } = state.category; if (currentCategoryId == null || rootCategoryId == null) return false; if (String(currentCategoryId) === String(rootCategoryId)) return false; const parentId = categories?.[String(currentCategoryId)]?.parentId; return parentId != null && parentId !== 0; }; export default categorySlice.reducer;