This commit is contained in:
seb
2026-10-04 22:47:26 +02:00
parent 8b401fa50b
commit a90069d340
44 changed files with 994 additions and 268 deletions

View File

@@ -1,3 +1,16 @@
/**
* @file articleDetailAutomat.js
* @description SKU-keyed automats for article detail tabs: article history
* (`articleHistoryAutomat`) and assigned suppliers (`articleSupplierAutomat`).
*
* Upstream Triggers:
* - `sseAutomat`: Calls `articleHistoryAutomat.reload(sku)` and
* `articleSupplierAutomat.reload(sku)` via `invalidateArticle(sku)` when an
* `article-<sku>` SSE event is received.
* - UI / Router: Triggers keyed `.load(sku)` or `.key(sku)` when viewing
* history or supplier details of a specific article.
*/
import { KeyedAutomat } from 'react-automat';
import { article as articleApi } from '../api';
import { requireArray, requireObject } from '../utils/apiShape';

View File

@@ -1,3 +1,14 @@
/**
* @file articleHistoryAutomat.js
* @description Virtualized grid automat (`articleHistoryGridAutomat`) for
* infinite-scrolling article history via `react-virtual-aggridautomat`.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when
* status reaches `'authed'` with a `selectedCompanyId`.
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
*/
import { createGridAutomat } from 'react-virtual-aggridautomat';
import { article } from '../api';
import authAutomat from './authAutomat';

View File

@@ -1,3 +1,20 @@
/**
* @file articleIndexAutomat.js
* @description Cached company article search index (`GET /api/articleIndex`).
* Provides fast client-side searching, manifest pickers, and optimistic updates.
* Built via `createListAutomat`.
*
* Upstream Triggers:
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
* a `selectedCompanyId`, and `reset()` on `'anon'`.
* - `sseAutomat`: Triggers `reload()` upon receiving an `'articles'` SSE event.
* - `artikelAutomat`: Triggers `setArticleIndexName(sku, name)` on optimistic
* article renames, or `reset()` on mutation failure rollback.
*
* Downstream Triggers:
* - `articleSearchAutomat`: Reads and searches records via `actions.ensureLoaded()`.
*/
import { articleIndex as articleIndexApi } from '../api';
import { createListAutomat } from './createListAutomat';
@@ -8,7 +25,7 @@ import { createListAutomat } from './createListAutomat';
* @property {string} name Article name.
* @property {Array<string>} eans Article EANs.
* @property {string|null} categories Comma-joined category ids.
* @property {boolean} isFather Whether the article has child variants.
* @property {boolean} isParent Whether the article has child variants.
*/
/**

View File

@@ -1,3 +1,14 @@
/**
* @file articleSearchAutomat.js
* @description In-memory article search state for the `/artikel` overview and
* search pages, querying against the cached `articleIndexAutomat`.
*
* Upstream Triggers:
* - UI / Search input: Components call `actions.search(query)` or `actions.reset()`.
* - Reads data from `articleIndexAutomat`: Ensures the cached index is loaded
* before filtering.
*/
import { Automat } from 'react-automat';
import articleIndexAutomat from './articleIndexAutomat';

View File

@@ -1,3 +1,21 @@
/**
* @file artikelAutomat.js
* @description In-memory reactive cache and mutator for full single-article
* detail payloads (`GET /api/article/:sku`), with inflight request deduplication.
*
* Upstream Triggers:
* - `sseAutomat`: Calls `reloadArticle(sku)` via `invalidateArticle(sku)` when an
* `article-<sku>` SSE mutation event arrives.
* - `authAutomat`: Subscribes to auth state changes — calls `actions.clear()` when
* status transitions to `'anon'` (logout).
* - UI / Routes: Calls `loadArticle(sku)` or mutations (`setName`, `setEans`,
* `setBrand`, `setCategories`, `setPrices`, `setWeight`, etc.).
*
* Downstream Triggers:
* - `articleIndexAutomat`: Calls `setArticleIndexName(sku, name)` on optimistic
* article renames, and `articleIndexAutomat.actions.reset()` on mutation failure rollback.
*/
import { Automat } from 'react-automat';
import { article as articleApi } from '../api';
import { requireDefined, requireObject } from '../utils/apiShape';

View File

@@ -1,3 +1,16 @@
/**
* @file attributeAutomat.js
* @description Manages catalog product attributes and their discrete values
* (e.g. Color, Size) for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
* - `sseAutomat`: Calls `actions.load()` upon receiving an `'attributename'` SSE event.
* - UI / Dialogs: Mutating actions (`createAttribute`, `updateAttribute`,
* `deleteAttribute`, `addValue`, `deleteValue`, `reorderValues`).
*/
import { Automat } from 'react-automat';
import { attributename as attributenameApi, attributevalue as attributevalueApi } from '../api';
import authAutomat from './authAutomat';

View File

@@ -1,3 +1,26 @@
/**
* @file authAutomat.js
* @description Central authentication and multi-tenant session manager.
* Tracks current user, Google OAuth client ID, and active company selection.
*
* Upstream Triggers:
* - `AuthBootstrap.jsx`: Calls `bootstrapSession(session)` on initial app load.
* - `LoginPage.jsx`: Calls `loginWithGoogle(credential)` or `loginWithCredentials(body)`.
* - `UserMenu.jsx`: Calls `switchCompany(companyId)` or `logout()`.
*
* Downstream Triggers:
* Subscribed to by almost all company-scoped automats, orchestrating their loading
* when authenticated (`authed` + `selectedCompanyId`) and reset on logout (`anon`):
* - `companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`
* - `attributeAutomat`, `propertyAutomat`, `supplierAutomat`, `warehouseAutomat`
* - `sseAutomat`: Disconnects/reconnects SSE stream to `/api/events` per company
* - `chatAutomat`: Reconnects AI chat SSE stream and reloads conversations
* - `createListAutomat` instances (`articleIndexAutomat`, `deliveryAutomat`,
* `quoteAutomat`, `orderAutomat`, `entityAutomat`, `customergroupAutomat`, `numberAutomat`)
* - Grid history automats (`articleHistoryGridAutomat`, `globalHistoryGridAutomat`)
* - `artikelAutomat`: Clears cached articles on logout
*/
import { Automat } from 'react-automat';
import { auth, company } from '../api';

View File

@@ -1,3 +1,13 @@
/**
* @file brandAutomat.js
* @description Manages catalog brands and manufacturers for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
* - UI / Settings: Mutating actions (`upsert`, `remove`, `addImage`, `deleteImage`).
*/
import { Automat } from 'react-automat';
import { brand as brandApi } from '../api';
import authAutomat from './authAutomat';

View File

@@ -1,14 +1,46 @@
import { Automat, KeyedAutomat } from 'react-automat';
/**
* @file categoryAutomat.js
* @description Owns the hierarchical category tree (id -> node map), the root
* category id, breadcrumbs helpers, and all category/article mutations.
*
* Per-category article views (`categoryViews`) live in `categoryViewAutomat.js`
* and attach to this tree automat via `attachCategoryTree()`, keeping the
* dependency one-directional (views -> tree).
*
* Upstream Triggers:
* - `companyAutomat`: Subscribes to company changes — triggers `actions.loadTree()`
* whenever `company.current.id` or `rootCategoryId` changes/becomes available;
* resets when company is unset.
* - `authAutomat`: Subscribes to auth state changes — triggers `actions.reset()` on `'anon'`.
* - `sseAutomat`: Calls `categoryAutomat.actions.loadTree()` and
* `invalidateAllCategoryViews()` upon receiving a `'category'` SSE event.
* - UI / Routes: Category tree operations (`createCategory`, `createArticle`,
* `deleteCategory`, `moveCategory`, `renameCategory`, `reorderCategories`, etc.).
*
* Downstream Triggers:
* - `categoryViewAutomat`: `loadTree()` calls `loadAllCategoryViews()` once
* `treeStatus === 'succeeded'`, so every category's articles are already cached
* when a route mounts. Mutations patch the loaded views through the same module.
*/
import { Automat } from 'react-automat';
import { category as categoryApi, article as articleApi } from '../api';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
import {
attachCategoryTree,
categoryViews,
loadAllCategoryViews,
disposeAllCategoryViewAutomats,
refreshCategoryViewChildren,
appendArticlesToCategoryViews,
removeArticleBySku,
waitForCategoryTree,
} from './categoryViewAutomat';
import {
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryChildren,
cleanCategorySlugPath,
resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME,
} from '../utils/categoryUtils';
/**
@@ -20,6 +52,7 @@ import {
* @property {boolean} renameMode
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
* @property {string|null} error
* @property {boolean} viewsLoading Whether the post-tree view preload is running.
*/
/** @type {CategoryState} */
@@ -31,120 +64,12 @@ const initialState = {
renameMode: false,
treeStatus: 'idle',
error: null,
viewsLoading: false,
};
let categoryAutomat;
let _treeLoadingPromise = null;
function waitForCategoryTree() {
if (categoryAutomat.state.treeStatus === 'succeeded') {
return Promise.resolve(categoryAutomat.state);
}
return new Promise((resolve, reject) => {
let unsubCat = null;
let unsubAuth = null;
const cleanup = () => {
unsubCat?.();
unsubAuth?.();
};
unsubCat = categoryAutomat.subscribe((state) => {
if (state.treeStatus === 'succeeded') {
cleanup();
resolve(state);
} else if (state.treeStatus === 'failed') {
cleanup();
reject(new Error(state.error || 'Fehler beim Laden des Kategoriebaums'));
}
});
unsubAuth = authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
cleanup();
reject(new Error('Nicht authentifiziert.'));
}
});
});
}
/**
* KeyedAutomat holding per-route category views indexed by slug path.
* For each slug path, loader resolves the categoryId from the category tree,
* fetches articles in that category, and provides childCategories (children of the category).
*/
export const categoryViews = new KeyedAutomat(
null,
{},
{
name: 'category-views',
loader: async (slugPath) => {
const slug = cleanCategorySlugPath(slugPath);
// Ensure tree is loaded before resolving slug -> categoryId
if (categoryAutomat.state.treeStatus !== 'succeeded') {
if (categoryAutomat.state.treeStatus === 'idle') {
void categoryAutomat.actions.loadTree();
}
await waitForCategoryTree();
}
const { categories, rootCategoryId } = categoryAutomat.state;
const categoryId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug);
if (categoryId == null) {
throw new Error(`Kategorie für Pfad „${slug || ROOT_CATEGORY_NAME}“ nicht gefunden.`);
}
const res = await categoryApi.getArticles(categoryId);
const articles = Array.isArray(res.articles) ? res.articles : [];
const childCategories = getCategoryChildren(categories, categoryId);
return {
slug,
categoryId,
articles,
childCategories,
};
},
}
);
/**
* Updates childCategories on all loaded category view handles when categories tree changes.
*/
function refreshLoadedCategoryViewChildren(categories) {
if (!categories) return;
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && item.data.categoryId != null) {
const childCategories = getCategoryChildren(categories, item.data.categoryId);
categoryViews.key(slug).set({
...item.data,
childCategories,
});
}
}
}
/**
* Get or create a per-route category view automat for the given slug and categoryId.
* (Backward compatibility helper)
*/
export function getOrCreateCategoryAutomat({ slugPath }) {
const slug = cleanCategorySlugPath(slugPath);
return categoryViews.key(slug);
}
/**
* Invalidate all active category view automats by triggering background reload.
*/
export function invalidateAllCategoryViews() {
return categoryViews.setDirty(undefined, false, true);
}
/**
* Dispose all category view automats and clear registries (e.g. on logout or company switch).
*/
export function disposeAllCategoryViewAutomats() {
categoryViews.clear();
}
/**
* Returns the parsed category map and root id, or `null` while the tree is
* still loading. Replaces the former `StorageManager.getCategoryState()`.
@@ -194,11 +119,25 @@ const actions = {
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
error: null,
viewsLoading: true,
});
refreshLoadedCategoryViewChildren(parsed.categories);
refreshCategoryViewChildren(parsed.categories);
// The tree is the source of truth for every category view, so warm all
// of them now: routes mount from cache instead of waiting on a fetch.
// A failing category must not fail the whole tree load.
const { failed } = await loadAllCategoryViews();
if (failed.length) {
console.warn(
`${failed.length} Kategorie(n) konnten nicht vorab geladen werden.`,
failed,
);
}
categoryAutomat.setState({ viewsLoading: false });
} catch (err) {
categoryAutomat.setState({
treeStatus: 'failed',
viewsLoading: false,
error: err?.message ?? String(err),
});
throw err;
@@ -215,6 +154,15 @@ const actions = {
categoryAutomat.setState({ renameMode: Boolean(enabled) });
},
/**
* (Re-)loads every category view of the current tree on demand.
*
* @returns {Promise<{ loaded: number, failed: Array<{slug: string, error: any}> }>} Load summary.
*/
async loadAllCategoryViews() {
return loadAllCategoryViews();
},
// ─── Category mutations ────────────────────────────────────────────────────
/**
@@ -227,7 +175,7 @@ const actions = {
const { categories } = categoryAutomat.state;
const updated = { ...(categories ?? {}), [String(node.id)]: node };
categoryAutomat.setState({ categories: updated });
refreshLoadedCategoryViewChildren(updated);
refreshCategoryViewChildren(updated);
return node;
},
@@ -246,7 +194,7 @@ const actions = {
const patch = Object.fromEntries(created.map((n) => [String(n.id), n]));
const updated = { ...(categoryAutomat.state.categories ?? {}), ...patch };
categoryAutomat.setState({ categories: updated });
refreshLoadedCategoryViewChildren(updated);
refreshCategoryViewChildren(updated);
return created;
},
@@ -257,16 +205,7 @@ const actions = {
async createArticle({ parentId, name }) {
const res = await articleApi.create({ parentId, name });
const created = res.article;
if (created && parentId != null) {
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && String(item.data.categoryId) === String(parentId)) {
categoryViews.key(slug).set({
...item.data,
articles: [...(item.data.articles || []), created],
});
}
}
}
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
return created;
},
@@ -282,16 +221,7 @@ const actions = {
const res = await articleApi.create({ parentId, name });
if (res.article) created.push(res.article);
}
if (created.length > 0 && parentId != null) {
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && String(item.data.categoryId) === String(parentId)) {
categoryViews.key(slug).set({
...item.data,
articles: [...(item.data.articles || []), ...created],
});
}
}
}
appendArticlesToCategoryViews({ categoryId: parentId, articles: created });
return created;
},
@@ -313,7 +243,7 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
return updated;
},
@@ -334,7 +264,7 @@ const actions = {
}
}
categoryAutomat.setState({ categories: patched });
refreshLoadedCategoryViewChildren(patched);
refreshCategoryViewChildren(patched);
return ordered;
},
@@ -355,7 +285,7 @@ const actions = {
categoryAutomat.setState({
categories: updated,
});
refreshLoadedCategoryViewChildren(updated);
refreshCategoryViewChildren(updated);
return moved;
},
@@ -370,7 +300,7 @@ const actions = {
const next = { ...categories };
delete next[String(categoryId)];
categoryAutomat.setState({ categories: next });
refreshLoadedCategoryViewChildren(next);
refreshCategoryViewChildren(next);
return categoryId;
},
@@ -388,7 +318,7 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
return updated;
},
@@ -406,7 +336,7 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
return res.category;
},
@@ -427,7 +357,7 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
return res.category;
},
@@ -435,15 +365,7 @@ const actions = {
/** Remove an article from all live category view lists (used after article deletion). */
removeArticleBySku(sku) {
if (!sku) return;
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data?.articles?.some((r) => r.sku === sku)) {
categoryViews.key(slug).set({
...item.data,
articles: item.data.articles.filter((r) => r.sku !== sku),
});
}
}
removeArticleBySku(sku);
},
/**
@@ -465,12 +387,13 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
},
/** Reset state to idle (logout / company switch). */
reset() {
disposeAllCategoryViewAutomats();
_treeLoadingPromise = null;
categoryAutomat.setState(initialState);
},
};
@@ -483,6 +406,9 @@ const actions = {
*/
categoryAutomat = new Automat(initialState, actions, { name: 'category' });
// Let the per-category views resolve their slug paths against this tree.
attachCategoryTree(categoryAutomat);
// Keyed view accessors on categoryAutomat:
categoryAutomat.key = (slugPath) => categoryViews.key(cleanCategorySlugPath(slugPath));
categoryAutomat.read = (slugPath) => categoryViews.read(cleanCategorySlugPath(slugPath));
@@ -528,4 +454,12 @@ export {
} from '../utils/categoryUtils';
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';
// Re-export category view automats and actions
export {
categoryViews,
getOrCreateCategoryAutomat,
disposeAllCategoryViewAutomats,
invalidateAllCategoryViews,
} from './categoryViewAutomat';
export default categoryAutomat;

View File

@@ -0,0 +1,344 @@
/**
* @file categoryViewAutomat.js
* @description Owns the per-category view automats (`categoryViews`), a
* `KeyedAutomat` keyed by category slug path. Each entry holds one category's
* articles (`GET /api/category/:id/articles`) plus its child categories, and is
* resolved from the category slug path against the loaded category tree.
*
* This file holds ONLY the keyed per-category views. The category tree itself
* (`categories` map, `rootCategoryId`, category/article mutations) lives in
* `categoryAutomat.js`; this module receives it through `attachCategoryTree()`
* so the dependency stays one-directional (views → tree).
*
* Upstream Triggers:
* - `categoryAutomat`: The tree automat attaches itself via `attachCategoryTree()`
* and calls `loadAllCategoryViews()` once `treeStatus === 'succeeded'`,
* `reloadLoadedCategoryViews()` when the tree changes, and
* `disposeAllCategoryViewAutomats()` on `reset()` (logout / company switch).
* - `sseAutomat`: Calls `invalidateAllCategoryViews()` on a `'category'` event and
* on an `'articles'` event for the open views.
* - UI / Routes: `categoryAutomat.key(slugPath)` /
* `categoryAutomat.read(slugPath)` / `categoryAutomat.load(slugPath)` facades.
*
* Downstream Triggers:
* - Routes / components: `src/routes/artikel/kategorien.jsx` (grid + drag & drop),
* `src/components/category/CategoryTree.jsx`.
*/
import { KeyedAutomat } from 'react-automat';
import { category as categoryApi } from '../api';
import authAutomat from './authAutomat';
import {
getCategoryBreadcrumbs,
getCategoryChildren,
cleanCategorySlugPath,
resolveCategoryIdFromSlugPath,
} from '../utils/categoryUtils';
/**
* @typedef {Object} CategoryViewData
* @property {string} slug Cleaned slug path this view was keyed by.
* @property {number|string} categoryId Resolved category id.
* @property {Array<object>} articles Articles in that category.
* @property {Array<object>} childCategories Direct child category nodes.
*/
/**
* The tree automat, injected by `categoryAutomat.js` at module-evaluation time.
* @type {import('react-automat').Automat<object>|null}
*/
let treeAutomat = null;
/**
* Injects the category tree automat this module resolves slug paths against.
*
* @param {import('react-automat').Automat<object>} automat Tree automat.
* @returns {void}
*/
export function attachCategoryTree(automat) {
treeAutomat = automat;
}
/**
* Returns the attached tree automat.
*
* @returns {import('react-automat').Automat<object>} Tree automat.
* @throws {Error} When the tree automat has not been attached yet.
*/
function tree() {
if (!treeAutomat) {
throw new Error('categoryAutomat has not attached the category tree yet.');
}
return treeAutomat;
}
/**
* Resolves once the attached category tree has loaded successfully.
*
* Rejects when the tree load fails or the session becomes anonymous, so callers
* waiting on a view never hang forever.
*
* @returns {Promise<object>} Tree state with `treeStatus === 'succeeded'`.
*/
export function waitForCategoryTree() {
const current = tree().state;
if (current.treeStatus === 'succeeded') return Promise.resolve(current);
return new Promise((resolve, reject) => {
let unsubTree = null;
let unsubAuth = null;
const cleanup = () => {
unsubTree?.();
unsubAuth?.();
unsubTree = null;
unsubAuth = null;
};
unsubTree = tree().subscribe((state) => {
if (state.treeStatus === 'succeeded') {
cleanup();
resolve(state);
} else if (state.treeStatus === 'failed') {
cleanup();
reject(new Error(state.error || 'Fehler beim Laden des Kategoriebaums'));
}
});
unsubAuth = authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
cleanup();
reject(new Error('Nicht authentifiziert.'));
}
});
});
}
/**
* Per-route category view stores, keyed by cleaned category slug path.
*
* @type {KeyedAutomat<CategoryViewData>}
*/
export const categoryViews = new KeyedAutomat(
null,
{},
{
name: 'category-views',
loader: async (slugPath) => {
const slug = cleanCategorySlugPath(slugPath);
// The tree must be loaded before a slug path can be resolved to an id.
const treeState = tree().state;
if (treeState.treeStatus !== 'succeeded') {
if (treeState.treeStatus === 'idle') {
void tree().actions.loadTree();
}
await waitForCategoryTree();
}
const { categories, rootCategoryId } = tree().state;
const categoryId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug);
if (categoryId == null) {
throw new Error(`Kategorie für Pfad „${slug}“ nicht gefunden.`);
}
const res = await categoryApi.getArticles(categoryId);
const articles = Array.isArray(res.articles) ? res.articles : [];
const childCategories = getCategoryChildren(categories, categoryId);
return { slug, categoryId, articles, childCategories };
},
},
);
/**
* Runs `worker` over `items` with a bounded number of concurrent calls.
* Failures are captured per item instead of rejecting the whole batch.
*
* @template T, R
* @param {Array<T>} items Work items.
* @param {number} limit Maximum concurrent workers.
* @param {(item: T, index: number) => Promise<R>} worker Async work per item.
* @returns {Promise<Array<{ok: true, value: R}|{ok: false, error: any}>>} Ordered results.
*/
async function mapWithConcurrency(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
const runners = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
while (cursor < items.length) {
const index = cursor++;
try {
results[index] = { ok: true, value: await worker(items[index], index) };
} catch (error) {
results[index] = { ok: false, error };
}
}
});
await Promise.all(runners);
return results;
}
/**
* Builds the slug path for a category from its breadcrumb trail.
*
* @param {Record<string,object>} categories id → node map.
* @param {number|string} rootCategoryId Root category id.
* @param {number|string} categoryId Category id.
* @returns {string} Slug path usable as a `categoryViews` key.
*/
export function categoryViewSlugPath(categories, rootCategoryId, categoryId) {
return getCategoryBreadcrumbs(categories, categoryId, rootCategoryId)
.map((crumb) => crumb.slug)
.filter(Boolean)
.join('/');
}
/**
* Collects the data of every currently loaded category view.
*
* Used by `articleIndexAutomat` to derive the search index from the view cache
* without issuing additional requests.
*
* @returns {Array<CategoryViewData>} Loaded view payloads.
*/
export function collectLoadedCategoryViewData() {
const out = [];
for (const item of Object.values(categoryViews.state)) {
if (item?.loaded && item.data) out.push(item.data);
}
return out;
}
/**
* Loads a view for **every** category in the loaded tree.
*
* Called by `categoryAutomat` once the tree finished loading, so navigating to
* any category (and the derived article index) resolves from cache instead of
* waiting for a round-trip. Already-loaded views are skipped.
*
* @param {{ concurrency?: number }} [options] Load tuning.
* @returns {Promise<{ loaded: number, failed: Array<{slug: string, error: any}> }>} Load summary.
*/
export async function loadAllCategoryViews({ concurrency = 6 } = {}) {
const treeState = tree().state;
if (treeState.treeStatus !== 'succeeded') {
return { loaded: 0, failed: [] };
}
const { categories, rootCategoryId } = treeState;
const slugs = [];
const seen = new Set();
for (const node of Object.values(categories ?? {})) {
const slug = categoryViewSlugPath(categories, rootCategoryId, node.id);
if (!slug || seen.has(slug)) continue;
seen.add(slug);
slugs.push(slug);
}
const results = await mapWithConcurrency(slugs, concurrency, (slug) =>
// Force only when not already cached; `load()` resolves immediately otherwise.
categoryViews.load(slug),
);
const failed = [];
let loaded = 0;
results.forEach((result, index) => {
if (result.ok) loaded += 1;
else failed.push({ slug: slugs[index], error: result.error });
});
return { loaded, failed };
}
/**
* Re-fetches only the views that are already cached, keeping the set of cached
* categories small. Used for SSE-driven refreshes.
*
* @returns {Promise<void>} Resolves once all cached views settled.
*/
export async function reloadLoadedCategoryViews() {
await categoryViews.setDirty(undefined, false, true);
}
/**
* Background-invalidates all loaded category views.
*
* @returns {Promise<unknown[]>} Per-view reload promises.
*/
export function invalidateAllCategoryViews() {
return categoryViews.setDirty(undefined, false, true);
}
/**
* Disposes every category view (logout / company switch).
*
* @returns {void}
*/
export function disposeAllCategoryViewAutomats() {
categoryViews.clear();
}
/**
* Get or create the view handle for a slug path (backward compatible helper).
*
* @param {{ slugPath: string }} param0
* @returns {import('react-automat').KeyHandle<CategoryViewData>} Keyed view handle.
*/
export function getOrCreateCategoryAutomat({ slugPath }) {
return categoryViews.key(cleanCategorySlugPath(slugPath));
}
/**
* Re-derives `childCategories` for every loaded view after the tree changed.
*
* @param {Record<string,object>|null} categories Fresh id → node map.
* @returns {void}
*/
export function refreshCategoryViewChildren(categories) {
if (!categories) return;
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && item.data.categoryId != null) {
categoryViews.key(slug).set({
...item.data,
childCategories: getCategoryChildren(categories, item.data.categoryId),
});
}
}
}
/**
* Appends newly created articles to every loaded view of `categoryId`.
*
* @param {{ categoryId: number|string, articles: Array<object> }} param0
* @returns {void}
*/
export function appendArticlesToCategoryViews({ categoryId, articles }) {
if (!articles?.length || categoryId == null) return;
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && String(item.data.categoryId) === String(categoryId)) {
categoryViews.key(slug).set({
...item.data,
articles: [...(item.data.articles || []), ...articles],
});
}
}
}
/**
* Removes a deleted article from every loaded view that contains it.
*
* @param {string} sku Article SKU.
* @returns {void}
*/
export function removeArticleBySku(sku) {
if (!sku) return;
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data?.articles?.some((r) => r.sku === sku)) {
categoryViews.key(slug).set({
...item.data,
articles: item.data.articles.filter((r) => r.sku !== sku),
});
}
}
}
export default categoryViews;

View File

@@ -1,3 +1,18 @@
/**
* @file chatAutomat.js
* @description State container for the AI assistant chat drawer, conversation
* list, message history, live SSE streaming tokens, and interactive tool calls.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — reconnects SSE stream and
* reloads conversations when `'authed'` with a `selectedCompanyId`; calls
* `actions.reset()` on `'anon'` or when company is unselected.
* - Assistant SSE stream (`/api/modelconversation/events`): Emits streaming
* deltas, state changes (`idle` | `busy`), and tool calls (`ask_user`).
* - UI / Drawer: Actions (`toggleChat`, `selectConversation`, `sendMessage`,
* `answerAskUser`, `newConversation`).
*/
import { Automat } from 'react-automat';
import { modelconversation as modelconversationApi } from '../api';
import authAutomat from './authAutomat';

View File

@@ -1,3 +1,20 @@
/**
* @file companyAutomat.js
* @description Manages configuration details of the currently selected company
* (tax rates, currency, language, legal details, and root category ID).
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — triggers `actions.load()`
* when status is `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
* - UI / Settings: Calls `update(payload)` or manual `load()`.
*
* Downstream Triggers:
* - `categoryAutomat`: Subscribes to `companyAutomat` — triggers `actions.loadTree()`
* whenever `company.current.id` or `rootCategoryId` changes/becomes available;
* resets when company is unset.
* - `posterminalAutomat`: Reads `companyAutomat.state.current?.id` as a fallback company ID.
*/
import { Automat } from 'react-automat';
import { company as companyApi } from '../api';
import authAutomat from './authAutomat';

View File

@@ -1,3 +1,17 @@
/**
* @file createListAutomat.js
* @description Factory function for company-scoped list automats with
* deduplicated `load()`, silent `reload()`, and automatic session synchronization.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes each created list automat to auth state — calls
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
*
* Used by:
* - `articleIndexAutomat`, `deliveryAutomat`, `quoteAutomat`, `orderAutomat`,
* `entityAutomat`, `customergroupAutomat`, `numberAutomat`.
*/
import { Automat } from 'react-automat';
import authAutomat from './authAutomat';

View File

@@ -1,3 +1,14 @@
/**
* @file customergroupAutomat.js
* @description Manages cached customer groups (`GET /api/customergroup`) for
* the active company. Built via `createListAutomat`.
*
* Upstream Triggers:
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
* a `selectedCompanyId`, and `reset()` on `'anon'`.
* - UI / Routes: Manual calls to `load()` or `reload()`.
*/
import { customergroup as customergroupApi } from '../api';
import { createListAutomat } from './createListAutomat';

View File

@@ -1,3 +1,14 @@
/**
* @file deliveryAutomat.js
* @description Manages cached inbound goods receipts / deliveries (`GET /api/delivery`)
* for the active company. Built via `createListAutomat`.
*
* Upstream Triggers:
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
* a `selectedCompanyId`, and `reset()` on `'anon'`.
* - UI / Routes: Manual calls to `load()` or `reload()`.
*/
import { delivery as deliveryApi } from '../api';
import { createListAutomat } from './createListAutomat';

View File

@@ -1,3 +1,17 @@
/**
* @file deliveryEditorAutomat.js
* @description State engine for inbound goods receipt creation and invoice
* reconciliation (lineitems, attachments, PDF/receipt OCR, and supplier article matching).
*
* Upstream Triggers:
* - UI / Delivery Editor: User actions in `/lieferung/neu` or `/lieferung/:id`
* (`addLine`, `updateLine`, `removeLine`, `fetchSupplierArticles`,
* `addAttachment`, `resetDelivery`, etc.).
*
* Downstream Triggers:
* - Navigation badges in `src/nav.js` subscribe to draft line counts.
*/
import { Automat } from 'react-automat';
import { article as articleApi, supplier as supplierApi } from '../api';

View File

@@ -1,3 +1,14 @@
/**
* @file entityAutomat.js
* @description Manages cached customer and contact entities (`GET /api/entity`)
* for the active company. Built via `createListAutomat`.
*
* Upstream Triggers:
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
* a `selectedCompanyId`, and `reset()` on `'anon'`.
* - UI / Routes: Manual calls to `load()` or `reload()`.
*/
import { entity as entityApi } from '../api';
import { createListAutomat } from './createListAutomat';

View File

@@ -1,3 +1,14 @@
/**
* @file globalHistoryAutomat.js
* @description Virtualized grid automat (`globalHistoryGridAutomat`) for
* infinite-scrolling global audit/change history via `react-virtual-aggridautomat`.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when
* status reaches `'authed'` with a `selectedCompanyId`.
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
*/
import { createGridAutomat } from 'react-virtual-aggridautomat';
import { globalHistory } from '../api';
import authAutomat from './authAutomat';

View File

@@ -1,3 +1,13 @@
/**
* @file helpAutomat.js
* @description Manages global interactive help badges and active popper state.
* Persisted in IndexedDB across browser reloads.
*
* Upstream Triggers:
* - UI / User interaction: Calls to `toggle()`, `setEnabled()`, `openPopper()`,
* and `closePopper()`.
*/
import { Automat } from 'react-automat';
/**

View File

@@ -1,3 +1,9 @@
/**
* @file index.js
* @description Central barrel exporting all domain, configuration, and list
* automats, along with their action creators and reactive selectors.
*/
export * from './deliveryEditorAutomat';
export { default as authAutomat } from './authAutomat';
@@ -84,6 +90,7 @@ export {
categoryDisplayName,
categorySlug,
normalizeCategoryMap,
categoryViews,
getOrCreateCategoryAutomat,
disposeAllCategoryViewAutomats,
invalidateAllCategoryViews,
@@ -106,4 +113,81 @@ export {
selectHasPendingAsk,
} from './chatAutomat';
/**
* ============================================================================
* Overview of Automats in Dashfront
* ============================================================================
*
* Automats are lightweight, reactive, out-of-tree state containers powered by
* `react-automat`. They manage all client-side state without React context,
* hooks, or HOCs, enabling pure, zero-flash rendering in `React.PureComponent`
* via constructor reads and fine-grained subscriptions.
*
* 1. Multi-Tenant Session & Security
* ----------------------------------------------------------------------------
* - `authAutomat`: Tracks authenticated session (`user`, `googleClientId`,
* `selectedCompanyId`, and auth status `'loading' | 'anon' | 'authed'`).
* Coordinates session bootstrap, company switching, and acts as the root
* lifecycle trigger that loads or resets all company-scoped automats.
*
* 2. Real-Time SSE Hub
* ----------------------------------------------------------------------------
* - `sseAutomat`: Manages the single persistent Server-Sent Events connection
* (`GET /api/events`). Automatically reconnects when the active company changes
* and terminates on logout. Dispatches incoming server mutation signals into
* targeted automats (`artikelAutomat`, `articleDetailAutomat`, `categoryAutomat`,
* `articleIndexAutomat`, `warehouseAutomat`, `supplierAutomat`, `attributeAutomat`,
* `propertyAutomat`).
*
* 3. Domain & Entity Caches
* ----------------------------------------------------------------------------
* - `artikelAutomat`: In-memory cache for full article records (`GET /api/article/:sku`)
* with inflight request deduplication, optimistic renames, and reactive selectors.
* - `articleDetailAutomat`: Keyed automats (`articleHistoryAutomat`, `articleSupplierAutomat`)
* providing per-SKU supplier catalog bindings and change history.
* - `deliveryEditorAutomat`: Interactive draft state machine for incoming goods receipts
* and invoice reconciliation (line items, supplier article auto-matching, PDF/receipt OCR).
*
* 4. Company Configuration & Catalogs
* ----------------------------------------------------------------------------
* - `companyAutomat`: Active company settings (tax rates, ISO language, root category ID).
* - `categoryAutomat`: Hierarchical category tree (ID -> node map), mutations (create,
* rename, move, reorder, delete), and breadcrumb resolution.
* - `categoryViewAutomat`: Keyed view container (`categoryViews`) holding articles and child
* categories per slug path, auto-preloaded when category tree finishes loading.
* - `brandAutomat`: Catalog manufacturer and brand records.
* - `unitAutomat`: Allowed units of measure (Stück, kg, Liter, etc.).
* - `posterminalAutomat`: Point-of-sale hardware terminals and enabled category filters.
* - `attributeAutomat`: Product attribute definitions and discrete values (e.g. Size, Color).
* - `propertyAutomat`: Custom product property and metadata names.
* - `supplierAutomat`: Directory of vendor entities and supplier article mappings.
* - `warehouseAutomat`: Warehouse locations and stock storage areas.
*
* 5. Company-Scoped List Automats (`createListAutomat`)
* ----------------------------------------------------------------------------
* Built with the shared list factory with deduplicated `load()`, silent `reload()`,
* and automatic synchronization with `authAutomat`:
* - `articleIndexAutomat`: Fast client-side search index (`GET /api/articleIndex`).
* - `deliveryAutomat`: Delivery receipt list (`GET /api/delivery`).
* - `quoteAutomat`: Sales quotes / Angebote (`GET /api/quote`).
* - `orderAutomat`: Sales orders / Bestellungen (`GET /api/order`).
* - `entityAutomat`: Customers and business partners (`GET /api/entity`).
* - `customergroupAutomat`: Customer discount groups (`GET /api/customergroup`).
* - `numberAutomat`: Document numbering sequences (`GET /api/number`).
*
* 6. Search & Infinite-Scroll Grid Automats
* ----------------------------------------------------------------------------
* - `articleSearchAutomat`: Client-side search engine querying `articleIndexAutomat`.
* - `articleHistoryGridAutomat`: Virtualized infinite-scroll grid for article history.
* - `globalHistoryGridAutomat`: Virtualized infinite-scroll grid for global audit logs.
*
* 7. UI State & Utilities
* ----------------------------------------------------------------------------
* - `chatAutomat`: AI assistant chat drawer state, conversations, and live SSE streaming tokens.
* - `navigationAutomat`: Persists last visited delivery document path and article SKU.
* - `userSettingsAutomat`: User layout preferences (viewer position, drawer widths in IndexedDB).
* - `helpAutomat`: Interactive help badge states and popper positioning (in IndexedDB).
* ============================================================================
*/

View File

@@ -1,3 +1,13 @@
/**
* @file navigationAutomat.js
* @description Stores the user's most recently visited delivery document path
* and article SKU to preserve navigation context across primary section switches.
*
* Upstream Triggers:
* - UI / Routes: Set by article and delivery views/routes via `setLastDeliveryPath(path)`,
* `setLastArticleSku(sku)`, and `clearNavigation()`.
*/
import { Automat } from 'react-automat';
/**

View File

@@ -1,3 +1,14 @@
/**
* @file numberAutomat.js
* @description Manages cached document numbering sequences (`GET /api/number`)
* (e.g. delivery, entity) for the active company. Built via `createListAutomat`.
*
* Upstream Triggers:
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
* a `selectedCompanyId`, and `reset()` on `'anon'`.
* - UI / Routes: Manual calls to `load()` or `reload()`.
*/
import { number as numberApi } from '../api';
import { createListAutomat } from './createListAutomat';

View File

@@ -1,3 +1,14 @@
/**
* @file orderAutomat.js
* @description Manages cached company sales orders (`GET /api/order`).
* Built via `createListAutomat`.
*
* Upstream Triggers:
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
* a `selectedCompanyId`, and `reset()` on `'anon'`.
* - UI / Routes: Manual calls to `load()` or `reload()`.
*/
import { order as orderApi } from '../api';
import { createListAutomat } from './createListAutomat';

View File

@@ -1,3 +1,15 @@
/**
* @file posterminalAutomat.js
* @description Manages registered Point-of-Sale (POS) hardware terminals and
* their enabled category mappings for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
* - `companyAutomat`: Reads `companyAutomat.state.current?.id` as fallback when loading.
* - UI / Settings: POS terminal actions (`create`, `delete`, `toggleCategory`, `setCategoryEnabled`).
*/
import { Automat } from 'react-automat';
import { posterminal as posterminalApi } from '../api';
import authAutomat from './authAutomat';

View File

@@ -1,3 +1,16 @@
/**
* @file propertyAutomat.js
* @description Manages catalog product property names (custom metadata definitions)
* for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
* - `sseAutomat`: Calls `actions.load()` upon receiving a `'propertyname'` SSE event.
* - UI / Settings: Mutating actions (`createProperty`, `updateProperty`,
* `deleteProperty`, `reorderProperties`).
*/
import { Automat } from 'react-automat';
import { propertyname as propertynameApi } from '../api';
import authAutomat from './authAutomat';

View File

@@ -1,3 +1,14 @@
/**
* @file quoteAutomat.js
* @description Manages cached sales quotes (Angebote) (`GET /api/quote`)
* for the active company. Built via `createListAutomat`.
*
* Upstream Triggers:
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
* a `selectedCompanyId`, and `reset()` on `'anon'`.
* - UI / Routes: Manual calls to `load()` or `reload()`.
*/
import { quote as quoteApi } from '../api';
import { createListAutomat } from './createListAutomat';

View File

@@ -1,3 +1,26 @@
/**
* @file sseAutomat.js
* @description Central Server-Sent Events hub managing the single persistent
* `GET /api/events` connection and fanning mutation signals into domain automats.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — reconnects stream when
* `selectedCompanyId` changes/becomes available; disconnects on `'anon'` (logout).
*
* Downstream Triggers:
* Dispatches real-time SSE mutation events into specific automats:
* - `artikelAutomat`: Re-fetches article via `reloadArticle(sku)` on `article-<sku>` signals.
* - `articleDetailAutomat` (`articleHistoryAutomat`, `articleSupplierAutomat`):
* Reloads SKU-keyed views on `article-<sku>` signals.
* - `categoryAutomat`: Triggers `actions.loadTree()` and `invalidateAllCategoryViews()`
* on `'category'` SSE events.
* - `articleIndexAutomat`: Triggers `actions.reload()` on `'articles'` SSE events.
* - `warehouseAutomat`: Triggers `actions.reload()` on `'warehouse'` SSE events.
* - `supplierAutomat`: Triggers `actions.reload()` on `'supplier'` SSE events.
* - `attributeAutomat`: Triggers `actions.load()` on `'attributename'` SSE events.
* - `propertyAutomat`: Triggers `actions.load()` on `'propertyname'` SSE events.
*/
import { Automat } from 'react-automat';
import authAutomat from './authAutomat';
import artikelAutomat from './artikelAutomat';

View File

@@ -1,3 +1,14 @@
/**
* @file supplierAutomat.js
* @description Manages cached supplier list (`GET /api/supplier`) for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'supplier'` SSE event.
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setSuppliers()`.
*/
import { Automat } from 'react-automat';
import { supplier as supplierApi } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';

View File

@@ -1,3 +1,14 @@
/**
* @file unitAutomat.js
* @description Manages allowable units of measure (Stück, kg, Liter, etc.)
* for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
* - UI / Settings: Mutating actions (`upsert`, `delete`).
*/
import { Automat } from 'react-automat';
import { unit as unitApi } from '../api';
import authAutomat from './authAutomat';

View File

@@ -1,3 +1,13 @@
/**
* @file userSettingsAutomat.js
* @description Manages client-side user UI layout preferences (attachment viewer
* position, chat drawer width). Persisted in IndexedDB.
*
* Upstream Triggers:
* - UI / User layout preferences: Calls to `setViewerPosition(position)` and
* `setChatDrawerWidth(width)`.
*/
import { Automat } from 'react-automat';
/**

View File

@@ -1,3 +1,14 @@
/**
* @file warehouseAutomat.js
* @description Manages cached warehouse locations (`GET /api/warehouse`) for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'warehouse'` SSE event.
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setWarehouses()`.
*/
import { Automat } from 'react-automat';
import { warehouse as warehouseApi } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';