no redux
This commit is contained in:
81
src/automats/brandAutomat.js
Normal file
81
src/automats/brandAutomat.js
Normal 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;
|
||||
490
src/automats/categoryAutomat.js
Normal file
490
src/automats/categoryAutomat.js
Normal 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;
|
||||
96
src/automats/companyAutomat.js
Normal file
96
src/automats/companyAutomat.js
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
142
src/automats/posterminalAutomat.js
Normal file
142
src/automats/posterminalAutomat.js
Normal 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;
|
||||
94
src/automats/unitAutomat.js
Normal file
94
src/automats/unitAutomat.js
Normal 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;
|
||||
Reference in New Issue
Block a user