599 lines
21 KiB
JavaScript
599 lines
21 KiB
JavaScript
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;
|