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

@@ -1,8 +1,14 @@
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../store/categorySlice';
import { parseCategoryTreeResponse } from '../utils/categoryUtils';
import { ApiClient } from './ApiClient';
import artikelAutomat from '../automats/artikelAutomat';
import companyAutomat from '../automats/companyAutomat';
import brandAutomat from '../automats/brandAutomat';
import unitAutomat from '../automats/unitAutomat';
import authAutomat from '../automats/authAutomat';
import categoryAutomat from '../automats/categoryAutomat';
/**
* Stale-while-revalidate cache: readers keep the current promise; update()
@@ -172,20 +178,25 @@ export class StorageManager extends ApiClient {
* @returns {{ categories: object, rootCategoryId: unknown } | null}
*/
getCategoryState() {
const treeData = this.categoryTree;
const companies = this.companies;
const session = this.authMe;
if (treeData == null || companies == null || session == null) return null;
const companyId = session.user?.selectedCompanyId;
if (companyId == null) return null;
const company = companies.find((c) => c.id === companyId);
const company = this.company;
if (company == null) return null;
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
const treeData = this.categoryTree;
if (treeData != null) {
// Legacy: tree was loaded via the old cache mechanism
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
return {
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
};
}
// New path: use categoryAutomat state directly
const catState = categoryAutomat.state;
if (catState.categories == null || catState.treeStatus !== 'succeeded') return null;
return {
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
categories: catState.categories,
rootCategoryId: catState.rootCategoryId,
};
}
@@ -375,13 +386,6 @@ export class StorageManager extends ApiClient {
#initCaches() {
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
this.#articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex());
const articleIndexCache = this.#articleIndexCache;
const brandCache = createCache('GET /brand', () => this.#loadBrands());
const unitCache = createCache('GET /unit', () => this.#loadUnits());
const companyCache = createCache('GET /company', () => this.#loadCompanies());
const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries());
const customergroupCache = createCache('GET /customergroup', () =>
this.#loadCustomergroups(),
@@ -391,6 +395,19 @@ export class StorageManager extends ApiClient {
const quoteCache = createCache('GET /quote', () => this.#loadQuotes());
const orderCache = createCache('GET /order', () => this.#loadOrders());
// articleIndex is auth-gated — create a deferred cache that only loads after auth
this.#articleIndexCache = this.#createDeferredCache(
'GET /articleIndex',
() => this.#loadArticleIndex(),
);
const articleIndexCache = this.#articleIndexCache;
// category tree: deferred, reloaded via categoryAutomat
const categoryTreeCache = this.#createDeferredCache(
'GET /category/tree',
() => this.#loadCategoryTree(),
);
Object.defineProperties(this, {
authMePromise: { get: () => authMeCache.promise },
updateAuthMe: { value: authMeCache.update },
@@ -400,29 +417,68 @@ export class StorageManager extends ApiClient {
articleIndexPromise: { get: () => articleIndexCache.promise },
updateArticleIndex: { value: articleIndexCache.update },
brandPromise: { get: () => brandCache.promise },
updateBrand: { value: brandCache.update },
/** Settled GET /brand list, or undefined. */
brands: { get: () => brandCache.value },
// ── Automat bridges (brand, unit, company, posterminal) ──────────────────
/** Brand list from brandAutomat (may be empty until authed). */
brands: { get: () => brandAutomat.state.brands },
/** Promise that resolves when brands are first loaded. */
brandPromise: {
get: () => brandAutomat.state.status === 'succeeded'
? Promise.resolve(brandAutomat.state.brands)
: new Promise((resolve) => {
const unsub = brandAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.brands); }
});
}),
},
/** Reload brands from API. */
updateBrand: { value: () => brandAutomat.actions.load() },
unitPromise: { get: () => unitCache.promise },
updateUnit: { value: unitCache.update },
/** Settled GET /unit list, or undefined. */
units: { get: () => unitCache.value },
/** Unit list from unitAutomat. */
units: { get: () => unitAutomat.state.units },
/** Promise that resolves when units are first loaded. */
unitPromise: {
get: () => unitAutomat.state.status === 'succeeded'
? Promise.resolve(unitAutomat.state.units)
: new Promise((resolve) => {
const unsub = unitAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.units); }
});
}),
},
/** Reload units from API. */
updateUnit: { value: () => unitAutomat.actions.load() },
companyPromise: { get: () => companyCache.promise },
updateCompany: { value: companyCache.update },
/** Settled GET /company list, or undefined. */
companies: { get: () => companyCache.value },
/**
* Current company object from companyAutomat, or null.
* Previously: companies array from GET /api/company.
* Consumers that used companies.find(c => c.id === companyId) can use .company directly.
*/
company: { get: () => companyAutomat.state.current ?? null },
/** Promise that resolves when company data is loaded. */
companyPromise: {
get: () => companyAutomat.state.status === 'succeeded'
? Promise.resolve(companyAutomat.state.current)
: new Promise((resolve, reject) => {
const unsub = companyAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.current); }
else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); }
});
}),
},
/** Reload company data from API. */
updateCompany: { value: () => companyAutomat.actions.load() },
// Legacy compat: some components call store.companies.find(c => c.id === companyId).
// Return a single-element array so .find() still works.
companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] },
// ── Category tree bridge ─────────────────────────────────────────────────
categoryTreePromise: { get: () => categoryTreeCache.promise },
updateCategoryTree: { value: categoryTreeCache.update },
/** Settled GET /category/tree body, or undefined. */
categoryTree: { get: () => categoryTreeCache.value },
posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update },
// ── Remaining non-auth-gated caches ─────────────────────────────────────
deliveryPromise: { get: () => deliveryCache.promise },
updateDelivery: { value: deliveryCache.update },
/** Settled GET /delivery list, or undefined. */
@@ -456,8 +512,63 @@ export class StorageManager extends ApiClient {
/** Close and reopen GET /api/events (required after selected-company change). */
reconnectEvents: { value: () => this.#reconnectEvents() },
});
// Trigger articleIndex load once auth is established
authAutomat.subscribe((auth) => {
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
if (!articleIndexCache.value) {
void articleIndexCache.update?.().catch(() => {});
}
}
});
}
/**
* Create a cache that does NOT load immediately but waits for an explicit trigger.
* Differs from createCache in that the initial load promise is not started.
*/
#createDeferredCache(label, load) {
let current;
let resolveInitial;
let rejectInitial;
let promise = new Promise((res, rej) => {
resolveInitial = res;
rejectInitial = rej;
});
async function update() {
try {
const data = await load();
current = data;
promise = Promise.resolve(data);
resolveInitial?.(data);
resolveInitial = null;
return data;
} catch (err) {
console.error(`[storageMgr] update ${label} failed`, err);
rejectInitial?.(err);
rejectInitial = null;
throw err;
}
}
function replace(data) {
current = data;
promise = Promise.resolve(data);
resolveInitial?.(data);
resolveInitial = null;
return data;
}
return {
update,
replace,
get promise() { return promise; },
get value() { return current; },
};
}
/**
* GET /api/events — SSE signals for the selected company.
* Stream is bound at connect time; reconnect after company switch.

View File

@@ -0,0 +1,81 @@
import { Automat } from 'react-automat';
import { brand as brandApi } from '../api';
import authAutomat from './authAutomat';
/**
* @typedef {Object} BrandState
* @property {Array<object>} brands
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {BrandState} */
const initialState = {
brands: [],
status: 'idle',
error: null,
};
function sortBrands(brands) {
return [...brands].sort((a, b) =>
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'),
);
}
let brandAutomat;
const actions = {
/** Load all brands for the selected company. */
async load() {
brandAutomat.setState({ status: 'loading', error: null });
try {
const res = await brandApi.list();
const brands = sortBrands(Array.isArray(res.brands) ? res.brands : []);
brandAutomat.setState({ brands, status: 'succeeded' });
return brands;
} catch (err) {
brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Upsert a brand (create or update by name).
* @param {{ name: string, gsprpinfo?: string }} body
*/
async upsert(body) {
const res = await brandApi.upsert(body);
const row = res.brand;
const brands = sortBrands([
...brandAutomat.state.brands.filter((b) => b.id !== row.id),
row,
]);
brandAutomat.setState({ brands });
return row;
},
/** Reset to idle state (called on logout / company switch). */
reset() {
brandAutomat.setState(initialState);
},
};
/**
* Global brand state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<BrandState>}
*/
brandAutomat = new Automat(initialState, actions, { name: 'brand' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void brandAutomat.actions.load();
} else if (auth.status === 'anon') {
brandAutomat.actions.reset();
}
});
export default brandAutomat;

View File

@@ -0,0 +1,490 @@
import { Automat } from 'react-automat';
import { category as categoryApi, article as articleApi } from '../api';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
import {
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
ROOT_CATEGORY_NAME,
} 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 {Array<object>} rootArticles
* @property {number|string|null} currentCategoryId
* @property {Array<object>} articles
* @property {number} articlesTotal
* @property {boolean} renameMode
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
* @property {'idle'|'loading'|'succeeded'|'failed'} articlesStatus
* @property {string|null} error
*/
/** @type {CategoryState} */
const initialState = {
companyId: null,
languageId: null,
categories: null,
rootCategoryId: null,
rootArticles: [],
currentCategoryId: null,
articles: [],
articlesTotal: 0,
renameMode: false,
treeStatus: 'idle',
articlesStatus: 'idle',
error: null,
};
let categoryAutomat;
// ─── Pure helpers ──────────────────────────────────────────────────────────────
function articlesForCategory(state, categoryId) {
if (String(categoryId) === String(state.rootCategoryId)) {
return state.rootArticles;
}
return [];
}
// ─── Actions ───────────────────────────────────────────────────────────────────
const actions = {
/** Load the category tree for the currently selected company. */
async loadTree() {
const company = companyAutomat.state.current;
if (!company) return;
const { id: companyId, rootCategoryId: companyRootId } = company;
if (!companyRootId) return;
categoryAutomat.setState({ treeStatus: 'loading', error: null });
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,
currentCategoryId: parsed.rootCategoryId,
rootArticles: parsed.rootArticles,
articles: parsed.rootArticles,
articlesTotal: parsed.rootArticles.length,
articlesStatus: 'succeeded',
error: null,
});
} catch (err) {
categoryAutomat.setState({
treeStatus: 'failed',
error: err?.message ?? String(err),
});
throw err;
}
},
/**
* Load articles for a non-root category.
* @param {number|string} categoryId
*/
async loadArticles(categoryId) {
categoryAutomat.setState({ articlesStatus: 'loading', error: null });
try {
const res = await categoryApi.getArticles(categoryId);
const articles = Array.isArray(res.articles) ? res.articles : [];
categoryAutomat.setState({
articlesStatus: 'succeeded',
articles,
articlesTotal: articles.length,
});
} catch (err) {
categoryAutomat.setState({
articlesStatus: 'failed',
articles: [],
articlesTotal: 0,
error: err?.message ?? String(err),
});
throw err;
}
},
/** Navigate to a category by id. */
navigateTo(categoryId) {
const { rootCategoryId, rootArticles } = categoryAutomat.state;
const isRoot = String(categoryId) === String(rootCategoryId);
categoryAutomat.setState({
currentCategoryId: categoryId,
error: null,
articles: isRoot ? rootArticles : [],
articlesTotal: isRoot ? rootArticles.length : 0,
articlesStatus: isRoot ? 'succeeded' : 'idle',
});
},
/** Navigate up to the parent category. */
navigateBack() {
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
const current = categories?.[String(currentCategoryId)];
const parentId = current?.parentId;
if (parentId == null || parentId === 0) return;
if (String(parentId) === String(rootCategoryId) || categories?.[String(parentId)]) {
const isRoot = String(parentId) === String(rootCategoryId);
categoryAutomat.setState({
currentCategoryId: parentId,
error: null,
articles: isRoot ? rootArticles : [],
articlesTotal: isRoot ? rootArticles.length : 0,
articlesStatus: isRoot ? 'succeeded' : 'idle',
});
}
},
/** Toggle rename/edit mode for the category grid. */
setRenameMode(enabled) {
categoryAutomat.setState({ renameMode: Boolean(enabled) });
},
// ─── 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;
categoryAutomat.setState({
categories: { ...(categories ?? {}), [String(node.id)]: node },
});
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]));
categoryAutomat.setState({
categories: { ...(categoryAutomat.state.categories ?? {}), ...patch },
});
return created;
},
/**
* Create a single article.
* @param {{ parentId: number|string, name: string }} param0
*/
async createArticle({ parentId, name }) {
const res = await articleApi.create({ parentId, name });
const created = res.article;
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
const patch = {};
if (String(created.parentId) === String(currentCategoryId)) {
patch.articles = [...articles, created];
patch.articlesTotal = patch.articles.length;
patch.articlesStatus = 'succeeded';
}
if (String(created.parentId) === String(rootCategoryId)) {
patch.rootArticles = [...rootArticles, created];
}
if (Object.keys(patch).length) categoryAutomat.setState(patch);
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 res = await articleApi.create({ parentId, name });
if (res.article) created.push(res.article);
}
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
const patch = {};
const forCurrent = created.filter((a) => String(a.parentId) === String(currentCategoryId));
if (forCurrent.length > 0) {
patch.articles = [...articles, ...forCurrent];
patch.articlesTotal = patch.articles.length;
patch.articlesStatus = 'succeeded';
}
const forRoot = created.filter((a) => String(a.parentId) === String(rootCategoryId));
if (forRoot.length > 0) {
patch.rootArticles = [...rootArticles, ...forRoot];
}
if (Object.keys(patch).length) categoryAutomat.setState(patch);
return created;
},
/**
* Rename a category.
* @param {{ categoryId: number|string, name: string }} param0
*/
async renameCategory({ categoryId, name }) {
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, rootArticles } = categoryAutomat.state;
categoryAutomat.setState({
categories: categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null,
rootArticles: rootArticles.map((row) =>
row?.categoryId != null && String(row.categoryId) === key
? { ...row, name: updated.name }
: row,
),
});
return updated;
},
/**
* Reorder sibling categories.
* @param {{ categoryIds: number[] }} param0
*/
async reorderCategories({ categoryIds }) {
const { currentCategoryId, categories } = categoryAutomat.state;
const res = await categoryApi.reorder(currentCategoryId, categoryIds);
const ordered = res.categoryIds;
const parentKey = String(currentCategoryId);
const idToSort = new Map(ordered.map((id, index) => [String(id), index]));
const patched = { ...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 });
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);
categoryAutomat.setState({
categories: {
...categories,
[key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort },
},
});
return moved;
},
/**
* Delete a category by id.
* @param {number|string} categoryId
*/
async deleteCategory(categoryId) {
await categoryApi.delete(categoryId);
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
if (!categories) return;
const next = { ...categories };
delete next[String(categoryId)];
const patch = { categories: next };
if (String(currentCategoryId) === String(categoryId)) {
patch.currentCategoryId = rootCategoryId;
patch.articles = rootArticles;
patch.articlesTotal = rootArticles.length;
patch.articlesStatus = 'succeeded';
}
categoryAutomat.setState(patch);
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);
categoryAutomat.setState({
categories: { ...categories, [key]: { ...categories[key], pos: updated.pos } },
});
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);
categoryAutomat.setState({
categories: { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash } },
});
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);
categoryAutomat.setState({
categories: {
...categories,
[key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null },
},
});
return res.category;
},
// ─── Local state patches ───────────────────────────────────────────────────
/** Remove an article from both article lists (used after article deletion). */
removeArticleBySku(sku) {
categoryAutomat.setState({
articles: categoryAutomat.state.articles.filter((r) => r.sku !== sku),
articlesTotal: categoryAutomat.state.articles.filter((r) => r.sku !== sku).length,
rootArticles: categoryAutomat.state.rootArticles.filter((r) => r.sku !== sku),
});
},
/**
* Patch article fields and category membership in local lists.
* @param {{ sku: string, categoryIds?: number[], [key: string]: any }} payload
*/
patchArticleInLists({ sku, categoryIds, ...fields }) {
if (sku == null) return;
const { articles, rootArticles, rootCategoryId, currentCategoryId } = categoryAutomat.state;
const patchList = (list) =>
list.map((row) => (row.sku !== sku ? row : { ...row, ...fields }));
let nextArticles = patchList(articles);
let nextRoot = patchList(rootArticles);
let nextArticlesTotal = nextArticles.length;
if (categoryIds !== undefined) {
const newIds = categoryIds ?? [];
const articleObj = nextArticles.find((r) => r.sku === sku) ?? nextRoot.find((r) => r.sku === sku);
if (rootCategoryId != null) {
const inRoot = newIds.some((id) => String(id) === String(rootCategoryId));
const wasInRoot = nextRoot.some((r) => r.sku === sku);
if (!inRoot) nextRoot = nextRoot.filter((r) => r.sku !== sku);
else if (!wasInRoot && articleObj) nextRoot = [...nextRoot, articleObj];
}
if (currentCategoryId != null) {
const inCurrent = newIds.some((id) => String(id) === String(currentCategoryId));
const wasInCurrent = nextArticles.some((r) => r.sku === sku);
if (!inCurrent) { nextArticles = nextArticles.filter((r) => r.sku !== sku); }
else if (!wasInCurrent && articleObj) { nextArticles = [...nextArticles, articleObj]; }
nextArticlesTotal = nextArticles.length;
}
}
categoryAutomat.setState({
articles: nextArticles,
articlesTotal: nextArticlesTotal,
rootArticles: nextRoot,
});
},
/**
* 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);
categoryAutomat.setState({
categories: {
...categories,
[String(categoryId)]: { ...cat, posterminalIds: next },
},
});
},
/** Reset state to idle (logout / company switch). */
reset() {
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' });
// 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) {
_lastCompanyId = id;
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,
} from '../utils/categoryUtils';
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';
export default categoryAutomat;

View File

@@ -0,0 +1,96 @@
import { Automat } from 'react-automat';
import { company as companyApi } from '../api';
import authAutomat from './authAutomat';
import { normalizeCompany } from '../utils/companyForm';
/**
* @typedef {Object} CompanyState
* @property {object|null} current Normalized company object for the selected company.
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {CompanyState} */
const initialState = {
current: null,
status: 'idle',
error: null,
};
let companyAutomat;
const actions = {
/**
* Load the company data for the currently selected company.
* @returns {Promise<object|null>}
*/
async load() {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) return null;
companyAutomat.setState({ status: 'loading', error: null });
try {
const data = await companyApi.getById(companyId);
const normalized = normalizeCompany(data.company);
companyAutomat.setState({ current: normalized, status: 'succeeded' });
return normalized;
} catch (err) {
companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Update company details and refresh cached data.
* @param {object} payload
*/
async update(payload) {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
companyAutomat.setState({ status: 'loading', error: null });
try {
await companyApi.update(companyId, payload);
const data = await companyApi.getById(companyId);
const normalized = normalizeCompany(data.company, payload);
companyAutomat.setState({ current: normalized, status: 'succeeded' });
return normalized;
} catch (err) {
companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/** Reset to idle (called on logout / company switch). */
reset() {
companyAutomat.setState(initialState);
},
};
/**
* Global company state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<CompanyState>}
*/
companyAutomat = new Automat(initialState, actions, { name: 'company' });
// Subscribe to auth changes — load when authed, reset when logged out / anon
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void companyAutomat.actions.load();
} else if (auth.status === 'anon') {
companyAutomat.actions.reset();
}
});
// Selectors (plain functions for use outside React)
export const selectCurrentCompany = () => companyAutomat.state.current;
export const selectCompanyId = () => companyAutomat.state.current?.id ?? null;
export const selectCompanyStatus = () => companyAutomat.state.status;
export const selectCompanyName = () => companyAutomat.state.current?.name;
export const selectCompanyIso639 = () => companyAutomat.state.current?.iso639;
export const selectCompanyTaxrate = () => companyAutomat.state.current?.taxrate ?? null;
export const selectCompanyReducedTaxrate = () => companyAutomat.state.current?.reducedtaxrate ?? null;
export const selectCompanyRootCategoryId = () => companyAutomat.state.current?.rootCategoryId ?? null;
export default companyAutomat;

View File

@@ -1,11 +1,16 @@
export * from './deliveryEditorAutomat';
export { default as authAutomat } from './authAutomat';
export * from './authAutomat';
export { default as navigationAutomat } from './navigationAutomat';
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
export { default as userSettingsAutomat } from './userSettingsAutomat';
export { setViewerPosition } from './userSettingsAutomat';
export { default as supplierAutomat } from './supplierAutomat';
export { default as artikelAutomat } from './artikelAutomat';
export {
getArticle,
@@ -13,3 +18,37 @@ export {
selectArticle,
createArticleSelector,
} from './artikelAutomat';
export { default as companyAutomat } from './companyAutomat';
export {
selectCurrentCompany,
selectCompanyId,
selectCompanyStatus,
selectCompanyName,
selectCompanyIso639,
selectCompanyTaxrate,
selectCompanyReducedTaxrate,
selectCompanyRootCategoryId,
} from './companyAutomat';
export { default as brandAutomat } from './brandAutomat';
export { default as unitAutomat } from './unitAutomat';
export { default as posterminalAutomat } from './posterminalAutomat';
export {
selectPosterminals,
selectPosterminalStatus,
selectHasPosterminals,
} from './posterminalAutomat';
export { default as categoryAutomat } from './categoryAutomat';
export {
ROOT_CATEGORY_NAME,
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
categoryDisplayName,
categorySlug,
normalizeCategoryMap,
} from './categoryAutomat';

View File

@@ -0,0 +1,142 @@
import { Automat } from 'react-automat';
import { posterminal as posterminalApi } from '../api';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
/**
* @typedef {Object} PosterminalState
* @property {Array<object>} terminals
* @property {number|string|null} companyId
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {PosterminalState} */
const initialState = {
terminals: [],
companyId: null,
status: 'idle',
error: null,
};
let posterminalAutomat;
const actions = {
/** Load all posterminals for the selected company. */
async load() {
const companyId = companyAutomat.state.current?.id;
if (!companyId) return [];
posterminalAutomat.setState({ status: 'loading', error: null });
try {
const res = await posterminalApi.list();
const terminals = Array.isArray(res.posterminals) ? res.posterminals : [];
posterminalAutomat.setState({ terminals, companyId, status: 'succeeded' });
return terminals;
} catch (err) {
posterminalAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Create a new posterminal.
* @param {{ name: string }} param0
*/
async create({ name }) {
const res = await posterminalApi.create({ name });
const terminal = res.posterminal;
posterminalAutomat.setState({
terminals: [...posterminalAutomat.state.terminals, terminal],
});
return terminal;
},
/**
* Delete a posterminal.
* @param {number} terminalId
*/
async delete(terminalId) {
await posterminalApi.delete(terminalId);
posterminalAutomat.setState({
terminals: posterminalAutomat.state.terminals.filter((t) => t.id !== terminalId),
});
},
/**
* Enable all categories on a terminal.
* @param {{ terminalId: number }} param0
*/
async enableAll({ terminalId }) {
const res = await posterminalApi.enableAllCategories(terminalId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Disable all categories on a terminal.
* @param {{ terminalId: number }} param0
*/
async disableAll({ terminalId }) {
const res = await posterminalApi.disableAllCategories(terminalId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Enable a specific category on a terminal.
* @param {{ terminalId: number, categoryId: number }} param0
*/
async enableCategory({ terminalId, categoryId }) {
const res = await posterminalApi.enableCategory(terminalId, categoryId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Disable a specific category on a terminal.
* @param {{ terminalId: number, categoryId: number }} param0
*/
async disableCategory({ terminalId, categoryId }) {
const res = await posterminalApi.disableCategory(terminalId, categoryId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/** Reset to idle state (called on logout / company switch). */
reset() {
posterminalAutomat.setState(initialState);
},
};
function patchTerminalCategories(terminalId, categoryIds) {
posterminalAutomat.setState({
terminals: posterminalAutomat.state.terminals.map((t) =>
t.id === terminalId ? { ...t, categoryIds } : t,
),
});
}
/**
* Global posterminal state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<PosterminalState>}
*/
posterminalAutomat = new Automat(initialState, actions, { name: 'posterminal' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void posterminalAutomat.actions.load();
} else if (auth.status === 'anon') {
posterminalAutomat.actions.reset();
}
});
// Selectors
export const selectPosterminals = () => posterminalAutomat.state.terminals;
export const selectPosterminalStatus = () => posterminalAutomat.state.status;
export const selectHasPosterminals = () => posterminalAutomat.state.terminals.length > 0;
export default posterminalAutomat;

View File

@@ -0,0 +1,94 @@
import { Automat } from 'react-automat';
import { unit as unitApi } from '../api';
import authAutomat from './authAutomat';
/**
* @typedef {Object} UnitState
* @property {Array<object>} units
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {UnitState} */
const initialState = {
units: [],
status: 'idle',
error: null,
};
function sortUnits(units) {
return [...units].sort((a, b) => {
const left = a.symbol ?? a.si ?? '';
const right = b.symbol ?? b.si ?? '';
return String(left).localeCompare(String(right), 'de');
});
}
let unitAutomat;
const actions = {
/** Load all units for the selected company. */
async load() {
unitAutomat.setState({ status: 'loading', error: null });
try {
const res = await unitApi.list();
const units = sortUnits(Array.isArray(res.units) ? res.units : []);
unitAutomat.setState({ units, status: 'succeeded' });
return units;
} catch (err) {
unitAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Upsert (create or update) a unit.
* @param {object} body
*/
async upsert(body) {
const res = await unitApi.upsert(body);
const row = res.unit;
const units = sortUnits([
...unitAutomat.state.units.filter((u) => u.si !== row.si),
row,
]);
unitAutomat.setState({ units });
return units;
},
/**
* Delete a unit by SI symbol.
* @param {string} si
*/
async delete(si) {
await unitApi.delete(si);
unitAutomat.setState({
units: unitAutomat.state.units.filter((u) => u.si !== si),
});
},
/** Reset to idle state (called on logout / company switch). */
reset() {
unitAutomat.setState(initialState);
},
};
/**
* Global unit state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<UnitState>}
*/
unitAutomat = new Automat(initialState, actions, { name: 'unit' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void unitAutomat.actions.load();
} else if (auth.status === 'anon') {
unitAutomat.actions.reset();
}
});
export default unitAutomat;

View File

@@ -1,6 +1,5 @@
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import Box from '@mui/material/Box';
import Popover from '@mui/material/Popover';
import MenuList from '@mui/material/MenuList';
@@ -14,13 +13,7 @@ import { enqueueSnackbar } from 'notistack';
import PageLayout from './PageLayout';
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
import { isKategorienPath } from '../pages/artikel/categoryRoute';
import {
selectRenameMode,
setRenameMode,
selectCategoryMap,
selectCategoryRootId,
loadCategoryTree,
} from '../store/categorySlice';
import categoryAutomat from '../automats/categoryAutomat';
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
import { article, category } from '../api';
import { importArtikelCsv } from '../utils/importArtikel';
@@ -37,15 +30,31 @@ function triggerDownload(blob, filename) {
URL.revokeObjectURL(url);
}
function selectCatSlice(s) {
return {
renameMode: s.renameMode,
categories: s.categories,
rootCategoryId: s.rootCategoryId,
};
}
export default function SectionLayout({ sectionPath }) {
const { pathname } = useLocation();
const dispatch = useDispatch();
const renameMode = useSelector(selectRenameMode);
const categories = useSelector(selectCategoryMap);
const rootCategoryId = useSelector(selectCategoryRootId);
const importInputRef = useRef(null);
const [extrasAnchor, setExtrasAnchor] = useState(null);
const [importing, setImporting] = useState(false);
const [catState, setCatState] = useState(() => selectCatSlice(categoryAutomat.state));
useEffect(() => {
// Automat supports callback subscriptions: (state, partial) => void
const unsub = categoryAutomat.subscribe((state) => {
setCatState(selectCatSlice(state));
}, selectCatSlice);
return unsub;
}, []);
const { renameMode, categories, rootCategoryId } = catState;
const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
const subNav = SUB_NAV[sectionPath];
const subSegment = resolveActiveSub(pathname, sectionPath);
@@ -109,7 +118,7 @@ export default function SectionLayout({ sectionPath }) {
},
});
if (result.ok > 0 && rootCategoryId != null) {
await dispatch(loadCategoryTree()).unwrap();
await categoryAutomat.actions.loadTree();
}
if (result.ok > 0) {
enqueueSnackbar(
@@ -136,7 +145,7 @@ export default function SectionLayout({ sectionPath }) {
<ToggleElevatedPill
label="Edit"
checked={renameMode}
onToggle={(checked) => dispatch(setRenameMode(checked))}
onToggle={(checked) => categoryAutomat.actions.setRenameMode(checked)}
/>
<ActionElevatedPill
label="Extras"

View File

@@ -14,7 +14,7 @@ import CheckIcon from '@mui/icons-material/Check';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import Box from '@mui/material/Box';
import { getCategoryChildren, categoryDisplayName } from '../../store/categorySlice';
import { getCategoryChildren, categoryDisplayName } from '../../utils/categoryUtils';
function CategoryNode({
categories,
categoryId,

View File

@@ -1,10 +1,6 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import { ActionTile } from '../tiles';
import {
createArticles as createArticlesThunk,
createArticle as createArticleThunk,
} from '../../store/categorySlice';
import categoryAutomat from '../../automats/categoryAutomat';
import ArticleNameDialog from './ArticleNameDialog';
class NewArticleTile extends Component {
@@ -28,15 +24,16 @@ class NewArticleTile extends Component {
const { parentId } = this.props;
const list = (names ?? []).filter(Boolean);
if (list.length <= 1) {
const article = await this.props
.createArticle({ parentId, name: list[0] ?? '' })
.unwrap();
const article = await categoryAutomat.actions.createArticle({
parentId,
name: list[0] ?? '',
});
if (article) {
this.props.onOpenArticle?.(article);
}
return;
}
const created = await this.props.createArticles({ parentId, names: list }).unwrap();
const created = await categoryAutomat.actions.createArticles({ parentId, names: list });
if (created?.[0]) {
this.props.onOpenArticle?.(created[0]);
}
@@ -62,9 +59,4 @@ class NewArticleTile extends Component {
}
}
const mapDispatchToProps = {
createArticle: createArticleThunk,
createArticles: createArticlesThunk,
};
export default connect(null, mapDispatchToProps)(NewArticleTile);
export default NewArticleTile;

View File

@@ -2,7 +2,7 @@ import { Component, PureComponent } from 'react';
import { EditablePanelTile } from 'dashgui/panels';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { getCategoryBreadcrumbs } from '../../../store/categorySlice';
import { getCategoryBreadcrumbs } from '../../../utils/categoryUtils';
import { Box, Chip, IconButton } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import CategoryPickerDialog from '../CategoryPickerDialog';

View File

@@ -169,12 +169,7 @@ function priceDraftValid(draft) {
function companyFromStore(store) {
if (!store) return null;
const companies = store.companies;
const session = store.authMe;
if (companies == null || session == null) return null;
const companyId = session.user?.selectedCompanyId;
if (companyId == null) return null;
return companies.find((c) => c.id === companyId) ?? null;
return store.company ?? null;
}
class PriceAmountField extends Component {
@@ -395,18 +390,12 @@ export default class ArticleViewPrice extends PureComponent {
async load() {
const store = this.context;
try {
const [unitList, companies, session] = await Promise.all([
const [unitList, company] = await Promise.all([
store.unitPromise,
store.companyPromise,
store.authMePromise,
]);
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
const current = requireDefined(
companies.find((c) => c.id === companyId),
'company',
);
if (!this._cancelled) {
this.setState({ units: unitList, company: current });
this.setState({ units: unitList, company: company ?? null });
}
} catch (err) {
if (!this._cancelled) notifyError(err);

View File

@@ -1,10 +1,6 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import { ActionTile } from '../tiles';
import {
createCategories as createCategoriesThunk,
createCategory as createCategoryThunk,
} from '../../store/categorySlice';
import categoryAutomat from '../../automats/categoryAutomat';
import CategoryNameDialog from './CategoryNameDialog';
class NewCategoryTile extends Component {
@@ -28,10 +24,10 @@ class NewCategoryTile extends Component {
const { parentId } = this.props;
const list = (names ?? []).filter(Boolean);
if (list.length <= 1) {
await this.props.createCategory({ parentId, name: list[0] ?? '' }).unwrap();
await categoryAutomat.actions.createCategory({ parentId, name: list[0] ?? '' });
return;
}
await this.props.createCategories({ parentId, names: list }).unwrap();
await categoryAutomat.actions.createCategories({ parentId, names: list });
}
render() {
@@ -55,9 +51,4 @@ class NewCategoryTile extends Component {
}
}
const mapDispatchToProps = {
createCategory: createCategoryThunk,
createCategories: createCategoriesThunk,
};
export default connect(null, mapDispatchToProps)(NewCategoryTile);
export default NewCategoryTile;

View File

@@ -8,8 +8,43 @@ import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import SyncIcon from '@mui/icons-material/Sync';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
import { generateTextThumbnail } from '../../utils/textThumbnail';
export class Attachment extends PureComponent {
state = {
generatedThumbnail: null,
};
componentDidMount() {
this.ensureThumbnail();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment) {
this.ensureThumbnail();
}
}
async ensureThumbnail() {
const { attachment } = this.props;
if (!attachment || attachment.thumbnail) return;
const isCsv = attachment.type === 'text/csv' || attachment.name?.toLowerCase().endsWith('.csv');
const isText = attachment.type?.startsWith('text/') || attachment.name?.toLowerCase().endsWith('.txt') || isCsv;
if (!isText) return;
const source = attachment.text || attachment.file;
if (!source) return;
try {
const thumb = await generateTextThumbnail(source);
if (thumb && this.props.attachment === attachment) {
this.setState({ generatedThumbnail: thumb });
}
} catch (err) {
console.error('Fehler beim Generieren des Text-Thumbnails:', err);
}
}
handleRescan = (e) => {
e?.stopPropagation?.();
const { attachment, onRescan } = this.props;
@@ -56,7 +91,7 @@ export class Attachment extends PureComponent {
render() {
const { attachment, disabled, sx } = this.props;
const thumbnail = attachment?.thumbnail;
const thumbnail = attachment?.thumbnail || this.state.generatedThumbnail;
const name = attachment?.name || 'Dokument';
const size = attachment?.size;
const isPdf = attachment?.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf');

View File

@@ -341,10 +341,18 @@ export function collectFilesForUpload(attachments = [], { onlyNew = false } = {}
}
}
let thumbavif = null;
if (attachment.thumbnail) {
thumbavif = attachment.thumbnail.includes(',')
? attachment.thumbnail.split(',')[1]
: attachment.thumbnail;
}
files.push({
file: fileToUpload,
name: attachment.name || fileToUpload.name,
mimetype: attachment.type || fileToUpload.type,
thumbavif,
});
}
return files;
@@ -389,6 +397,10 @@ export async function buildManifestBody(
const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate });
if (filesToUpload.length > 0) {
const formData = new FormData();
body.filesMeta = filesToUpload.map((item) => ({
name: item.name,
thumbavif: item.thumbavif ?? null,
}));
formData.append('data', JSON.stringify(body));
for (const item of filesToUpload) {
formData.append('files', item.file, item.name);

View File

@@ -3,12 +3,10 @@ import { lazy, Suspense } from 'react';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { Provider } from 'react-redux';
import { SnackbarProvider } from 'notistack';
import AuthBootstrap from './components/AuthBootstrap';
import { StorageManagerProvider } from './api/storageManagerContext';
import App from './App';
import { store } from './store';
import { getActiveTheme } from './theme';
import './automats/supplierAutomat';
import './index.css';
@@ -34,31 +32,29 @@ if (import.meta.env.DEV) {
}
createRoot(document.getElementById('root')).render(
<Provider store={store}>
<ThemeProvider theme={getActiveTheme()}>
<CssBaseline />
<SnackbarProvider
maxSnack={3}
autoHideDuration={2000}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<StorageManagerProvider>
<AuthBootstrap>
{/* Flex row so the dev inspector docks beside the app instead of
overlaying it; the app content shrinks while the panel is open. */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: '100vh' }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<App />
</Box>
{AutomatInspector ? (
<Suspense fallback={null}>
<AutomatInspector />
</Suspense>
) : null}
<ThemeProvider theme={getActiveTheme()}>
<CssBaseline />
<SnackbarProvider
maxSnack={3}
autoHideDuration={2000}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<StorageManagerProvider>
<AuthBootstrap>
{/* Flex row so the dev inspector docks beside the app instead of
overlaying it; the app content shrinks while the panel is open. */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: '100vh' }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<App />
</Box>
</AuthBootstrap>
</StorageManagerProvider>
</SnackbarProvider>
</ThemeProvider>
</Provider>,
{AutomatInspector ? (
<Suspense fallback={null}>
<AutomatInspector />
</Suspense>
) : null}
</Box>
</AuthBootstrap>
</StorageManagerProvider>
</SnackbarProvider>
</ThemeProvider>,
);

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,
});

View File

@@ -1,94 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { brand as brandApi } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireObject } from '../utils/apiShape';
import { catchBackendError } from '../utils/backendError';
function sortBrands(brands) {
return [...brands].sort((a, b) =>
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'),
);
}
export const loadBrands = createAsyncThunk('brand/load', async (_, { rejectWithValue }) => {
try {
const res = await brandApi.list();
requireObject(res, 'GET /api/brand');
return requireArray(res.brands, 'brands');
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
});
export const upsertBrand = createAsyncThunk(
'brand/upsert',
async (body, { rejectWithValue }) => {
try {
const res = await brandApi.upsert(body);
requireObject(res, 'PUT /api/brand');
requireObject(res.brand, 'brand');
return res.brand;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
brands: [],
status: 'idle',
error: null,
};
const brandSlice = createSlice({
name: 'brand',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loadBrands.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadBrands.fulfilled, (state, action) => {
state.status = 'succeeded';
state.brands = sortBrands(action.payload);
})
.addCase(loadBrands.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(upsertBrand.fulfilled, (state, action) => {
const row = action.payload;
state.brands = sortBrands([
...state.brands.filter((b) => b.id !== row.id),
row,
]);
})
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
export const selectBrands = (state) => state.brand.brands;
export const selectBrandsStatus = (state) => state.brand.status;
export const selectBrandsError = (state) => state.brand.error;
export function brandLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadBrands());
}
},
});
}
export default brandSlice.reducer;

View File

@@ -1,696 +0,0 @@
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 { AUTH_LOGGED_OUT } from '../automats/authAutomat';
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);
}
},
);
/**
* Create several categories under the same parent in one go (bulk add).
* Runs the single-create API sequentially so every category is created
* even if a later one fails; the first error is reported.
* Returns the array of created (normalized) categories.
*/
export const createCategories = createAsyncThunk(
'category/createCategories',
async ({ parentId, names }, { rejectWithValue }) => {
const list = (names ?? [])
.map((n) => String(n ?? '').trim())
.filter(Boolean);
if (list.length === 0) {
return rejectWithValue({ message: 'Keine Namen übergeben.' });
}
const created = [];
try {
for (const name of list) {
const data = await category.create({ parentId, name });
requireObject(data.category, 'POST /api/category.category');
created.push(normalizeCategoryNode(data.category));
}
return created;
} catch (err) {
const normalized = catchBackendError(rejectWithValue)(err);
// Attach already-created categories so the UI can still update optimistically.
return normalized && created.length > 0
? rejectWithValue({ ...normalized, created })
: normalized;
}
},
);
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);
}
},
);
/**
* Create several articles under the same parent in one go (bulk add).
* Runs the single-create API sequentially so every article is created
* even if a later one fails; the first error is reported.
* Returns the array of created articles.
*/
export const createArticles = createAsyncThunk(
'category/createArticles',
async ({ parentId, names }, { rejectWithValue }) => {
const list = (names ?? [])
.map((n) => String(n ?? '').trim())
.filter(Boolean);
if (list.length === 0) {
return rejectWithValue({ message: 'Keine Namen übergeben.' });
}
const created = [];
try {
for (const name of list) {
const data = await article.create({ parentId, name });
requireObject(data.article, 'POST /api/article.article');
requireDefined(data.article.id, 'article.id');
created.push(data.article);
}
return created;
} catch (err) {
const normalized = catchBackendError(rejectWithValue)(err);
// Attach already-created articles so the UI can still update optimistically.
return normalized && created.length > 0
? rejectWithValue({ ...normalized, created })
: normalized;
}
},
);
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(createCategories.fulfilled, (state, action) => {
const createdList = action.payload;
if (!Array.isArray(createdList)) return;
if (!state.categories) state.categories = {};
for (const item of createdList) {
if (item == null || item.id == null) continue;
state.categories[String(item.id)] = item;
}
})
.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(createArticles.fulfilled, (state, action) => {
const createdList = action.payload;
if (!Array.isArray(createdList)) return;
const added = createdList.filter((item) => item != null);
if (added.length === 0) return;
if (added.some((item) => String(item.parentId) === String(state.currentCategoryId))) {
state.articles = [
...state.articles,
...added.filter((item) =>
String(item.parentId) === String(state.currentCategoryId),
),
];
state.articlesTotal = state.articles.length;
state.articlesStatus = 'succeeded';
}
const rootAdded = added.filter((item) =>
String(item.parentId) === String(state.rootCategoryId),
);
if (rootAdded.length > 0) {
state.rootArticles = [...state.rootArticles, ...rootAdded];
}
})
.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(AUTH_LOGGED_OUT, () => 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;

View File

@@ -1,98 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { company } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { normalizeCompany } from '../utils/companyForm';
export const loadCurrentCompany = createAsyncThunk(
'company/loadCurrent',
async () => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) return null;
const data = await company.getById(companyId);
return normalizeCompany(data.company);
},
);
export const updateCompany = createAsyncThunk(
'company/update',
async (payload) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
await company.update(companyId, payload);
const data = await company.getById(companyId);
return normalizeCompany(data.company, payload);
},
);
const initialState = {
current: null,
status: 'idle',
error: null,
};
const companySlice = createSlice({
name: 'company',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loadCurrentCompany.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
state.status = 'succeeded';
state.current = action.payload;
})
.addCase(loadCurrentCompany.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(updateCompany.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(updateCompany.fulfilled, (state, action) => {
state.status = 'succeeded';
state.current = action.payload;
})
.addCase(updateCompany.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(AUTH_LOGGED_OUT, (state) => {
state.current = null;
state.status = 'idle';
state.error = null;
});
},
});
export const selectCurrentCompany = (state) => state.company.current;
export const selectCompanyId = (state) => state.company.current?.id ?? null;
export const selectCompanyStatus = (state) => state.company.status;
export const selectCompanyError = (state) => state.company.error;
export const selectCompanyName = (state) => state.company.current?.name;
export const selectCompanyIso639 = (state) => state.company.current?.iso639;
export const selectCompanyTaxrate = (state) => state.company.current?.taxrate ?? null;
export const selectCompanyReducedTaxrate = (state) => state.company.current?.reducedtaxrate ?? null;
export const selectCompanyRootCategoryId = (state) => state.company.current?.rootCategoryId ?? null;
export function companyLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadCurrentCompany());
}
},
});
}
export default companySlice.reducer;

View File

@@ -1,55 +0,0 @@
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import { setAuthEventDispatcher } from '../automats/authAutomat';
import brandReducer, { brandLoadListener } from './brandSlice';
import categoryReducer from './categorySlice';
import companyReducer, { companyLoadListener } from './companySlice';
import posterminalReducer, { posterminalLoadListener } from './posterminalSlice';
import unitReducer, { unitLoadListener } from './unitSlice';
const listenerMiddleware = createListenerMiddleware();
companyLoadListener(listenerMiddleware);
unitLoadListener(listenerMiddleware);
brandLoadListener(listenerMiddleware);
posterminalLoadListener(listenerMiddleware);
function reduxDevLogger() {
return (storeApi) => (next) => (action) => {
if (
!import.meta.env.DEV ||
(!action.type?.startsWith('auth/') &&
!action.type?.startsWith('company/') &&
!action.type?.startsWith('category/') &&
!action.type?.startsWith('brand/') &&
!action.type?.startsWith('posterminal/') &&
!action.type?.startsWith('unit/'))
) {
return next(action);
}
const result = next(action);
const after = storeApi.getState();
console.log('[redux]', action.type, {
payload: action.payload,
company: after.company.current,
brands: after.brand.brands.length,
posterminals: after.posterminal.terminals.length,
units: after.unit.units.length,
});
return result;
};
}
export const store = configureStore({
reducer: {
company: companyReducer,
category: categoryReducer,
brand: brandReducer,
posterminal: posterminalReducer,
unit: unitReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(listenerMiddleware.middleware)
.concat(import.meta.env.DEV ? reduxDevLogger() : []),
});
setAuthEventDispatcher(store.dispatch);

View File

@@ -1,267 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { posterminal } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import { loadCurrentCompany } from './companySlice';
/**
* Load all posterminals for the selected company.
* GET /api/posterminal → { posterminals: [...] }
*/
export const loadPosterminals = createAsyncThunk(
'posterminal/load',
async (_, { rejectWithValue, getState }) => {
try {
const companyId = getState().company.current?.id;
requireDefined(companyId, 'company.id');
const data = await posterminal.list();
requireObject(data, 'GET /api/posterminal');
const list = requireArray(data.posterminals, 'posterminals');
return { companyId, terminals: list };
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Create a new posterminal.
* PUT /api/posterminal → { posterminal: { id, name, token, pairingpin } }
*/
export const createPosterminal = createAsyncThunk(
'posterminal/create',
async ({ name }, { rejectWithValue }) => {
try {
const data = await posterminal.create({ name });
requireObject(data, 'PUT /api/posterminal');
requireObject(data.posterminal, 'posterminal');
requireDefined(data.posterminal.id, 'posterminal.id');
return data.posterminal;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Delete a posterminal.
* DELETE /api/posterminal/:id → 204
*/
export const deletePosterminal = createAsyncThunk(
'posterminal/delete',
async ({ terminalId }, { rejectWithValue }) => {
try {
await posterminal.delete(terminalId);
return terminalId;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Enable all categories on a terminal.
* PUT /api/posterminal/:id/all → { id, categoryIds }
*/
export const enableAllTerminalCategories = createAsyncThunk(
'posterminal/enableAll',
async ({ terminalId }, { rejectWithValue }) => {
try {
const data = await posterminal.enableAllCategories(terminalId);
requireObject(data, 'PUT /api/posterminal/:id/all');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Disable all categories on a terminal.
* DELETE /api/posterminal/:id/all → { id, categoryIds }
*/
export const disableAllTerminalCategories = createAsyncThunk(
'posterminal/disableAll',
async ({ terminalId }, { rejectWithValue }) => {
try {
const data = await posterminal.disableAllCategories(terminalId);
requireObject(data, 'DELETE /api/posterminal/:id/all');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Enable a specific category on a terminal.
* PUT /api/posterminal/:id/category/:categoryId → { id, categoryIds }
*/
export const enableTerminalCategory = createAsyncThunk(
'posterminal/enableCategory',
async ({ terminalId, categoryId }, { rejectWithValue }) => {
try {
const data = await posterminal.enableCategory(terminalId, categoryId);
requireObject(data, 'PUT /api/posterminal/:id/category/:categoryId');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Disable a specific category on a terminal.
* DELETE /api/posterminal/:id/category/:categoryId → { id, categoryIds }
*/
export const disableTerminalCategory = createAsyncThunk(
'posterminal/disableCategory',
async ({ terminalId, categoryId }, { rejectWithValue }) => {
try {
const data = await posterminal.disableCategory(terminalId, categoryId);
requireObject(data, 'DELETE /api/posterminal/:id/category/:categoryId');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
companyId: null,
terminals: [],
status: 'idle',
error: null,
};
const posterminalSlice = createSlice({
name: 'posterminal',
initialState,
reducers: {
resetPosterminalState() {
return initialState;
},
},
extraReducers: (builder) => {
builder
// Load
.addCase(loadPosterminals.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadPosterminals.fulfilled, (state, action) => {
state.status = 'succeeded';
state.companyId = action.payload.companyId;
state.terminals = action.payload.terminals;
})
.addCase(loadPosterminals.rejected, (state, action) => {
state.status = 'failed';
state.error = resolveBackendError(action);
})
// Create
.addCase(createPosterminal.pending, (state) => {
state.error = null;
})
.addCase(createPosterminal.fulfilled, (state, action) => {
state.terminals.push(action.payload);
})
.addCase(createPosterminal.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Delete
.addCase(deletePosterminal.fulfilled, (state, action) => {
state.terminals = state.terminals.filter(
(t) => t.id !== action.payload,
);
})
.addCase(deletePosterminal.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Enable all categories
.addCase(enableAllTerminalCategories.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(enableAllTerminalCategories.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Disable all categories
.addCase(disableAllTerminalCategories.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(disableAllTerminalCategories.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Enable single category
.addCase(enableTerminalCategory.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(enableTerminalCategory.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Disable single category
.addCase(disableTerminalCategory.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(disableTerminalCategory.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Reset on company change
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
return { ...initialState, companyId: nextCompanyId, status: 'idle' };
}
})
// Reset on logout
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
export const { resetPosterminalState } = posterminalSlice.actions;
/**
* Auto-load posterminals when the session/company is established.
* Mirrors brandLoadListener / unitLoadListener pattern.
*/
export function posterminalLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
console.log('[posterminalLoadListener]', action.type, { companyId });
if (companyId) {
api.dispatch(loadPosterminals());
}
},
});
}
// Selectors
export const selectPosterminals = (state) => state.posterminal.terminals;
export const selectPosterminalStatus = (state) => state.posterminal.status;
export const selectPosterminalError = (state) => state.posterminal.error;
export const selectHasPosterminals = (state) => state.posterminal.terminals.length > 0;
export default posterminalSlice.reducer;

View File

@@ -1,92 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { unit as unitApi } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireObject } from '../utils/apiShape';
function sortUnits(units) {
return [...units].sort((a, b) => {
const left = a.symbol ?? a.si ?? '';
const right = b.symbol ?? b.si ?? '';
return String(left).localeCompare(String(right), 'de');
});
}
export const loadUnits = createAsyncThunk('unit/load', async () => {
const res = await unitApi.list();
requireObject(res, 'GET /api/unit');
return requireArray(res.units, 'units');
});
export const upsertUnit = createAsyncThunk('unit/upsert', async (body) => {
const res = await unitApi.upsert(body);
requireObject(res, 'PUT /api/unit');
requireObject(res.unit, 'unit');
return res.unit;
});
export const deleteUnit = createAsyncThunk('unit/delete', async (si) => {
await unitApi.delete(si);
return si;
});
const initialState = {
units: [],
status: 'idle',
error: null,
};
const unitSlice = createSlice({
name: 'unit',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loadUnits.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadUnits.fulfilled, (state, action) => {
state.status = 'succeeded';
state.units = sortUnits(action.payload);
})
.addCase(loadUnits.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(upsertUnit.fulfilled, (state, action) => {
const row = action.payload;
state.units = sortUnits([
...state.units.filter((u) => u.si !== row.si),
row,
]);
})
.addCase(deleteUnit.fulfilled, (state, action) => {
state.units = state.units.filter((u) => u.si !== action.payload);
})
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
export const selectUnits = (state) => state.unit.units;
export const selectUnitsStatus = (state) => state.unit.status;
export const selectUnitsError = (state) => state.unit.error;
export function unitLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadUnits());
}
},
});
}
export default unitSlice.reducer;

111
src/utils/categoryUtils.js Normal file
View File

@@ -0,0 +1,111 @@
/**
* Pure category tree helper functions.
* These operate on plain category maps and have no side-effects.
*/
export const ROOT_CATEGORY_NAME = 'Home';
/**
* @param {object} item
* @returns {object}
*/
export function normalizeCategoryNode(item) {
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 : [],
};
}
/**
* Parse the raw GET /api/category/tree response body into normalized state.
* @param {object} data
* @param {number|string|null} companyRootCategoryId
*/
export function parseCategoryTreeResponse(data, companyRootCategoryId) {
const list = Array.isArray(data.tree) ? data.tree : [];
const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId;
const categories = Object.fromEntries(
list.map((item) => [String(item.id), normalizeCategoryNode(item)]),
);
return {
rootCategoryId,
categories,
rootArticles: Array.isArray(data.articles) ? data.articles : [],
languageId: data.languageId,
};
}
/**
* Display name for a category node (root → "Home").
*/
export function categoryDisplayName(name, categoryId, rootCategoryId) {
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return name?.trim() || '';
}
/**
* URL slug for a category node (root → "Home").
*/
export function categorySlug(node, rootCategoryId) {
if (rootCategoryId != null && String(node.id) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return node.slug || null;
}
/**
* Direct children of `parentId`, sorted by `sort` then `id`.
*/
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);
}
/**
* Breadcrumb trail from root to `currentCategoryId`.
*/
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;
}
/**
* Map of category.id → category node from a raw tree response.
* @param {object} data
* @param {number|string|null} companyRootCategoryId
*/
export function normalizeCategoryMap(data, companyRootCategoryId) {
return parseCategoryTreeResponse(data, companyRootCategoryId).categories;
}

144
src/utils/textThumbnail.js Normal file
View File

@@ -0,0 +1,144 @@
let encodeAvif = null;
async function getAvifEncoder() {
if (!encodeAvif) {
const { default: encode } = await import('@jsquash/avif/encode');
encodeAvif = encode;
}
return encodeAvif;
}
/**
* Converts an ArrayBuffer to a base64 string safely without stack overflow.
* @param {ArrayBuffer} buffer
* @returns {string}
*/
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
const chunkSize = 8192;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + chunkSize),
);
}
return btoa(binary);
}
/**
* Renders text onto a 2D canvas styled like a document page thumbnail.
* @param {string} text
* @param {number} [width=200]
* @param {number} [height=280]
* @returns {HTMLCanvasElement}
*/
export function renderTextToCanvas(text = '', width = 200, height = 280) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return canvas;
// Background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
// Subtle border
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
// Top header accent line
ctx.fillStyle = '#cbd5e1';
ctx.fillRect(10, 8, 30, 2);
// Render text lines
ctx.fillStyle = '#334155';
ctx.font = '8px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
const paddingX = 10;
const startY = 22;
const lineHeight = 11;
const maxLines = Math.floor((height - startY - 8) / lineHeight);
const maxWidth = width - paddingX * 2;
const rawLines = String(text || '').split(/\r?\n/);
const formattedLines = [];
for (const line of rawLines) {
if (formattedLines.length >= maxLines) break;
const clean = line.replace(/\t/g, ' ');
if (!clean.trim()) {
formattedLines.push('');
continue;
}
// Wrap or clip long lines
let current = '';
for (let i = 0; i < clean.length; i++) {
const candidate = current + clean[i];
if (ctx.measureText(candidate).width > maxWidth) {
formattedLines.push(current);
current = clean[i];
if (formattedLines.length >= maxLines) break;
} else {
current = candidate;
}
}
if (current && formattedLines.length < maxLines) {
formattedLines.push(current);
}
}
for (let idx = 0; idx < formattedLines.length; idx++) {
const y = startY + idx * lineHeight;
ctx.fillText(formattedLines[idx], paddingX, y);
}
return canvas;
}
/**
* Generates an AVIF thumbnail data URL for text content.
* @param {string|File|Blob} textSource
* @param {{ width?: number, height?: number, quality?: number }} [options]
* @returns {Promise<string|null>} Base64 Data URL (data:image/avif;base64,... or data:image/png;base64,...)
*/
export async function generateTextThumbnail(textSource, { width = 200, height = 280, quality = 70 } = {}) {
let text = '';
if (textSource instanceof Blob) {
text = await textSource.text();
} else if (typeof textSource === 'string') {
text = textSource;
} else if (textSource != null) {
text = String(textSource);
}
if (typeof document === 'undefined') {
return null;
}
const canvas = renderTextToCanvas(text, width, height);
const ctx = canvas.getContext('2d');
if (!ctx) return null;
try {
const imageData = ctx.getImageData(0, 0, width, height);
const encoder = await getAvifEncoder();
const avifBuffer = await encoder(imageData, {
quality,
speed: 9,
});
const base64 = arrayBufferToBase64(avifBuffer);
return `data:image/avif;base64,${base64}`;
} catch (err) {
console.warn('generateTextThumbnail: @jsquash/avif encode failed, falling back to PNG:', err);
try {
return canvas.toDataURL('image/png');
} catch {
return null;
}
}
}