From a90069d34036656abdb0d264d2f17e60f91ea357 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 4 Oct 2026 22:47:26 +0200 Subject: [PATCH] u --- .agents/skills/backend/SKILL.md | 8 +- AGENTS.md | 2 +- docs/article-types-matrix.md | 12 +- src/api/index.js | 10 +- src/automats/articleDetailAutomat.js | 13 + src/automats/articleHistoryAutomat.js | 11 + src/automats/articleIndexAutomat.js | 19 +- src/automats/articleSearchAutomat.js | 11 + src/automats/artikelAutomat.js | 18 + src/automats/attributeAutomat.js | 13 + src/automats/authAutomat.js | 23 ++ src/automats/brandAutomat.js | 10 + src/automats/categoryAutomat.js | 240 +++++------- src/automats/categoryViewAutomat.js | 344 ++++++++++++++++++ src/automats/chatAutomat.js | 15 + src/automats/companyAutomat.js | 17 + src/automats/createListAutomat.js | 14 + src/automats/customergroupAutomat.js | 11 + src/automats/deliveryAutomat.js | 11 + src/automats/deliveryEditorAutomat.js | 14 + src/automats/entityAutomat.js | 11 + src/automats/globalHistoryAutomat.js | 11 + src/automats/helpAutomat.js | 10 + src/automats/index.js | 84 +++++ src/automats/navigationAutomat.js | 10 + src/automats/numberAutomat.js | 11 + src/automats/orderAutomat.js | 11 + src/automats/posterminalAutomat.js | 12 + src/automats/propertyAutomat.js | 13 + src/automats/quoteAutomat.js | 11 + src/automats/sseAutomat.js | 23 ++ src/automats/supplierAutomat.js | 11 + src/automats/unitAutomat.js | 11 + src/automats/userSettingsAutomat.js | 10 + src/automats/warehouseAutomat.js | 11 + src/components/article/ArticleTileTab.jsx | 4 +- src/components/article/articleRoute.js | 2 +- .../article/boxes/ArticleViewLists.jsx | 134 +++---- .../article/boxes/ArticleViewPrice.jsx | 2 +- .../article/boxes/ArticleViewStock.jsx | 12 +- .../article/tabs/ArticleAttributesTab.jsx | 32 +- .../article/tabs/ArticleSupplierTab.jsx | 2 +- .../manifest/ArticleSearchDialog.jsx | 4 +- src/components/tiles/ArticleTile.jsx | 4 +- 44 files changed, 994 insertions(+), 268 deletions(-) create mode 100644 src/automats/categoryViewAutomat.js diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index 84159c6..405d911 100644 --- a/.agents/skills/backend/SKILL.md +++ b/.agents/skills/backend/SKILL.md @@ -30,11 +30,11 @@ Overview of all 167 API endpoints across all modules. | `PUT` | `/article/{sku}/attributevalue` | `sku:string, body: { attributevalues: any[] }` | `200 { attributevalues: { id, articleId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | Bulk set article attribute values | | `PUT` | `/article/{sku}/brand` | `sku:string, body: { brandId: integer }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set or clear article brand by SKU | | `PUT` | `/article/{sku}/categories` | `sku:string, body: { categoryIds: integer[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace article categories by SKU | -| `GET` | `/article/{sku}/children` | `sku:string` | `200 { children: { id, sku, name, fathersort, attributeValueId, attributeValue }[] }` | List child articles of a father article | -| `POST` | `/article/{sku}/children` | `sku:string, body: { attributeValueId?, value?, name?, sku?: string }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Add a child article to a father article | -| `DELETE` | `/article/{sku}/children/{childSku}` | `sku:string, childSku:string, query: deleteArticle?:boolean` | `200 { success, deleted }` | Remove a child article from a father article | +| `GET` | `/article/{sku}/children` | `sku:string` | `200 { children: { id, sku, name, parentsort, attributeValueId, attributeValue }[] }` | List child articles of a parent article | +| `POST` | `/article/{sku}/children` | `sku:string, body: { attributeValueId?, value?, name?, sku?: string }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Add a child article to a parent article | +| `DELETE` | `/article/{sku}/children/{childSku}` | `sku:string, childSku:string, query: deleteArticle?:boolean` | `200 { success, deleted }` | Remove a child article from a parent article | | `PUT` | `/article/{sku}/eans` | `sku:string, body: { eans: string[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace article EANs by SKU | -| `POST` | `/article/{sku}/father` | `sku:string, body: { isFather, attributeNameId? }` | `200 { article: { id, categoryId, name, sku, track_stock } }` | Toggle father mode for an article | +| `POST` | `/article/{sku}/parent` | `sku:string, body: { isParent, attributeNameId? }` | `200 { article: { id, categoryId, name, sku, track_stock } }` | Toggle parent mode for an article | | `GET` | `/article/{sku}/history` | `sku:string, query: limit?:integer` | `200 { history: { id, date, text }[], truncated }` | Get article history by SKU | | `POST` | `/article/{sku}/image` | `sku:string, image/avif` | `201 { image: { picId, sort, full, thumb, hash, thumbHash } }` | Add article image | | `DELETE` | `/article/{sku}/image/{fullHash}` | `sku:string, fullHash:string` | `204 void` | Delete article image | diff --git a/AGENTS.md b/AGENTS.md index 3046d97..4df9798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -270,7 +270,7 @@ npm run preview 1. **Do not create dummy/mock backends**: `dashback` runs locally at `http://localhost:4208`. All `/api/*` routes are handled by it. 2. **Keep `dashgui` links working**: `dashgui` is imported via `file:../dashgui`. Vite has deduplication configured (`dedupe: ['react', 'react-dom', ...]` in `vite.config.js`) to avoid dual-instance React conflicts. -3. **Article Deletion Restriction**: An article CANNOT be deleted if it has child variants (`is_father: 1` or `childCount > 0`). The backend returns HTTP 400. +3. **Article Deletion Restriction**: An article CANNOT be deleted if it has child variants (`is_parent: 1` or `childCount > 0`). The backend returns HTTP 400. 4. **Prices and Multipliers**: - Article prices are tiered by `minmultiplier` and `customergroup_id`. - Prices sent to the backend are net prices (`net`). diff --git a/docs/article-types-matrix.md b/docs/article-types-matrix.md index 45cd4c4..47f4977 100644 --- a/docs/article-types-matrix.md +++ b/docs/article-types-matrix.md @@ -30,13 +30,13 @@ Dieses Dokument definiert das Verhalten, die Geschäftsregeln und das Datenmodel Varianten bilden im System eine **Baum-Struktur** ab (z. B. für mehrdimensionale Varianten wie *Modell $\rightarrow$ Farbe $\rightarrow$ Größe*). ```text -[Wurzel: T-Shirt Classic] <- Reiner Vaterartikel (is_father = 1, fatherarticle_id = NULL) +[Wurzel: T-Shirt Classic] <- Reiner Vaterartikel (is_parent = 1, parentarticle_id = NULL) │ - ├── [Zwischenknoten: Rot] <- Kind UND Vater (fatherarticle_id != NULL, is_father = 1) + ├── [Zwischenknoten: Rot] <- Kind UND Vater (parentarticle_id != NULL, is_parent = 1) │ ├── [Größe S] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU) │ └── [Größe M] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU) │ - └── [Zwischenknoten: Blau] <- Kind UND Vater (fatherarticle_id != NULL, is_father = 1) + └── [Zwischenknoten: Blau] <- Kind UND Vater (parentarticle_id != NULL, is_parent = 1) ├── [Größe S] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU) └── [Größe L] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU) ``` @@ -89,14 +89,14 @@ Varianten bilden im System eine **Baum-Struktur** ab (z. B. für mehrdimensional --- ### B. Variations-Vater (Parent Variant Article) -*Technischer Bezug*: `article.is_father = 1`, `fatherarticle_id = NULL` *(Wurzelknoten)* +*Technischer Bezug*: `article.is_parent = 1`, `parentarticle_id = NULL` *(Wurzelknoten)* * **Kann auch Stückliste sein?**: - **Nein**: Vaterartikel können keine Stückliste besitzen. * **Kann auch Variations-Vater sein?**: - **Ja**: Ist der Vater. * **Kann auch Variations-Kind sein?**: - - **Ja, als Zwischenknoten**: Im Variantenbaum kann ein Vaterartikel seinerseits Kind eines übergeordneten Vaters sein (`fatherarticle_id != NULL`). + - **Ja, als Zwischenknoten**: Im Variantenbaum kann ein Vaterartikel seinerseits Kind eines übergeordneten Vaters sein (`parentarticle_id != NULL`). * **Kann Optionen haben?**: - **Nein**: Optionen werden erst an der konkreten verkaufbaren Variante angehängt. * **Hat Bestand?**: @@ -115,7 +115,7 @@ Varianten bilden im System eine **Baum-Struktur** ab (z. B. für mehrdimensional --- ### C. Variations-Kind (Child Variant Article) -*Technischer Bezug*: `article.fatherarticle_id -> article.id` +*Technischer Bezug*: `article.parentarticle_id -> article.id` * **Kann auch Stückliste sein?**: - **Ja**: Als Blattknoten kann die Variante als Set aus mehreren Komponenten aufgebaut sein. diff --git a/src/api/index.js b/src/api/index.js index e8ed653..ea7af74 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -217,7 +217,7 @@ export const articleIndex = { name: a.name, eans: a.eans ?? [], categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null, - isFather: Boolean(a.isFather ?? a.is_father), + isParent: Boolean(a.isParent ?? a.is_parent), })); }, }; @@ -547,11 +547,11 @@ export const article = { }, /** - * POST /api/article/:sku/father - * Body: { isFather: boolean, attributeNameId?: number } + * POST /api/article/:sku/parent + * Body: { isParent: boolean, attributeNameId?: number } */ - toggleFather(sku, body) { - return http(`/api/article/${encodeURIComponent(sku)}/father`, { + toggleParent(sku, body) { + return http(`/api/article/${encodeURIComponent(sku)}/parent`, { method: 'POST', body, }); diff --git a/src/automats/articleDetailAutomat.js b/src/automats/articleDetailAutomat.js index fd7aa48..514cecd 100644 --- a/src/automats/articleDetailAutomat.js +++ b/src/automats/articleDetailAutomat.js @@ -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-` 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'; diff --git a/src/automats/articleHistoryAutomat.js b/src/automats/articleHistoryAutomat.js index a51c956..6f9fd1c 100644 --- a/src/automats/articleHistoryAutomat.js +++ b/src/automats/articleHistoryAutomat.js @@ -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'; diff --git a/src/automats/articleIndexAutomat.js b/src/automats/articleIndexAutomat.js index eb33c24..c3937ad 100644 --- a/src/automats/articleIndexAutomat.js +++ b/src/automats/articleIndexAutomat.js @@ -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} 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. */ /** diff --git a/src/automats/articleSearchAutomat.js b/src/automats/articleSearchAutomat.js index ac0fa28..0040a38 100644 --- a/src/automats/articleSearchAutomat.js +++ b/src/automats/articleSearchAutomat.js @@ -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'; diff --git a/src/automats/artikelAutomat.js b/src/automats/artikelAutomat.js index 9390203..5c80abb 100644 --- a/src/automats/artikelAutomat.js +++ b/src/automats/artikelAutomat.js @@ -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-` 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'; diff --git a/src/automats/attributeAutomat.js b/src/automats/attributeAutomat.js index 11c9df5..60c0b3c 100644 --- a/src/automats/attributeAutomat.js +++ b/src/automats/attributeAutomat.js @@ -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'; diff --git a/src/automats/authAutomat.js b/src/automats/authAutomat.js index 76dec7c..be96222 100644 --- a/src/automats/authAutomat.js +++ b/src/automats/authAutomat.js @@ -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'; diff --git a/src/automats/brandAutomat.js b/src/automats/brandAutomat.js index a62263b..648ce11 100644 --- a/src/automats/brandAutomat.js +++ b/src/automats/brandAutomat.js @@ -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'; diff --git a/src/automats/categoryAutomat.js b/src/automats/categoryAutomat.js index 99ba88f..63b2174 100644 --- a/src/automats/categoryAutomat.js +++ b/src/automats/categoryAutomat.js @@ -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; diff --git a/src/automats/categoryViewAutomat.js b/src/automats/categoryViewAutomat.js new file mode 100644 index 0000000..e99ebcb --- /dev/null +++ b/src/automats/categoryViewAutomat.js @@ -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} articles Articles in that category. + * @property {Array} childCategories Direct child category nodes. + */ + +/** + * The tree automat, injected by `categoryAutomat.js` at module-evaluation time. + * @type {import('react-automat').Automat|null} + */ +let treeAutomat = null; + +/** + * Injects the category tree automat this module resolves slug paths against. + * + * @param {import('react-automat').Automat} automat Tree automat. + * @returns {void} + */ +export function attachCategoryTree(automat) { + treeAutomat = automat; +} + +/** + * Returns the attached tree automat. + * + * @returns {import('react-automat').Automat} 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} 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} + */ +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} items Work items. + * @param {number} limit Maximum concurrent workers. + * @param {(item: T, index: number) => Promise} worker Async work per item. + * @returns {Promise>} 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} 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} 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} Resolves once all cached views settled. + */ +export async function reloadLoadedCategoryViews() { + await categoryViews.setDirty(undefined, false, true); +} + +/** + * Background-invalidates all loaded category views. + * + * @returns {Promise} 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} 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|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 }} 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; \ No newline at end of file diff --git a/src/automats/chatAutomat.js b/src/automats/chatAutomat.js index 3c06c9a..e185e61 100644 --- a/src/automats/chatAutomat.js +++ b/src/automats/chatAutomat.js @@ -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'; diff --git a/src/automats/companyAutomat.js b/src/automats/companyAutomat.js index c219104..2ffa587 100644 --- a/src/automats/companyAutomat.js +++ b/src/automats/companyAutomat.js @@ -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'; diff --git a/src/automats/createListAutomat.js b/src/automats/createListAutomat.js index c040f14..31bdb1d 100644 --- a/src/automats/createListAutomat.js +++ b/src/automats/createListAutomat.js @@ -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'; diff --git a/src/automats/customergroupAutomat.js b/src/automats/customergroupAutomat.js index 9a9b457..ecab921 100644 --- a/src/automats/customergroupAutomat.js +++ b/src/automats/customergroupAutomat.js @@ -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'; diff --git a/src/automats/deliveryAutomat.js b/src/automats/deliveryAutomat.js index e5d3872..146c66f 100644 --- a/src/automats/deliveryAutomat.js +++ b/src/automats/deliveryAutomat.js @@ -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'; diff --git a/src/automats/deliveryEditorAutomat.js b/src/automats/deliveryEditorAutomat.js index 8175c4e..9805b6a 100644 --- a/src/automats/deliveryEditorAutomat.js +++ b/src/automats/deliveryEditorAutomat.js @@ -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'; diff --git a/src/automats/entityAutomat.js b/src/automats/entityAutomat.js index 5d8d0e6..bda4357 100644 --- a/src/automats/entityAutomat.js +++ b/src/automats/entityAutomat.js @@ -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'; diff --git a/src/automats/globalHistoryAutomat.js b/src/automats/globalHistoryAutomat.js index d1b716c..486ad75 100644 --- a/src/automats/globalHistoryAutomat.js +++ b/src/automats/globalHistoryAutomat.js @@ -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'; diff --git a/src/automats/helpAutomat.js b/src/automats/helpAutomat.js index 4c25368..3138087 100644 --- a/src/automats/helpAutomat.js +++ b/src/automats/helpAutomat.js @@ -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'; /** diff --git a/src/automats/index.js b/src/automats/index.js index 234d4ad..f39b26c 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -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). + * ============================================================================ + */ + diff --git a/src/automats/navigationAutomat.js b/src/automats/navigationAutomat.js index 3cd6c9a..9b2dc49 100644 --- a/src/automats/navigationAutomat.js +++ b/src/automats/navigationAutomat.js @@ -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'; /** diff --git a/src/automats/numberAutomat.js b/src/automats/numberAutomat.js index 8c3b0ca..6dec96e 100644 --- a/src/automats/numberAutomat.js +++ b/src/automats/numberAutomat.js @@ -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'; diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js index d11b822..3792b48 100644 --- a/src/automats/orderAutomat.js +++ b/src/automats/orderAutomat.js @@ -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'; diff --git a/src/automats/posterminalAutomat.js b/src/automats/posterminalAutomat.js index bdd45dc..fa1488d 100644 --- a/src/automats/posterminalAutomat.js +++ b/src/automats/posterminalAutomat.js @@ -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'; diff --git a/src/automats/propertyAutomat.js b/src/automats/propertyAutomat.js index 9a816d9..88de378 100644 --- a/src/automats/propertyAutomat.js +++ b/src/automats/propertyAutomat.js @@ -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'; diff --git a/src/automats/quoteAutomat.js b/src/automats/quoteAutomat.js index e9df407..21b30eb 100644 --- a/src/automats/quoteAutomat.js +++ b/src/automats/quoteAutomat.js @@ -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'; diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index dd0a282..d2c6a6a 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -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-` signals. + * - `articleDetailAutomat` (`articleHistoryAutomat`, `articleSupplierAutomat`): + * Reloads SKU-keyed views on `article-` 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'; diff --git a/src/automats/supplierAutomat.js b/src/automats/supplierAutomat.js index f1ee0fd..31a5d5f 100644 --- a/src/automats/supplierAutomat.js +++ b/src/automats/supplierAutomat.js @@ -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'; diff --git a/src/automats/unitAutomat.js b/src/automats/unitAutomat.js index fcc3fb2..f922be8 100644 --- a/src/automats/unitAutomat.js +++ b/src/automats/unitAutomat.js @@ -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'; diff --git a/src/automats/userSettingsAutomat.js b/src/automats/userSettingsAutomat.js index d19a3df..17e0f34 100644 --- a/src/automats/userSettingsAutomat.js +++ b/src/automats/userSettingsAutomat.js @@ -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'; /** diff --git a/src/automats/warehouseAutomat.js b/src/automats/warehouseAutomat.js index baf43f7..165c1a5 100644 --- a/src/automats/warehouseAutomat.js +++ b/src/automats/warehouseAutomat.js @@ -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'; diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx index 64a9123..5530311 100644 --- a/src/components/article/ArticleTileTab.jsx +++ b/src/components/article/ArticleTileTab.jsx @@ -79,9 +79,9 @@ const SETTINGS_TAB = { }; function visibleTabs(article) { - const isFather = Boolean(article?.isFather); + const isParent = Boolean(article?.isParent); const tabs = ALWAYS_TABS.map((row) => { - if (row.id === ARTICLE_TAB.supplier && isFather) { + if (row.id === ARTICLE_TAB.supplier && isParent) { return { ...row, disabled: true, diff --git a/src/components/article/articleRoute.js b/src/components/article/articleRoute.js index 705c209..659a788 100644 --- a/src/components/article/articleRoute.js +++ b/src/components/article/articleRoute.js @@ -32,7 +32,7 @@ export function articleSku(article) { export function articleTabIds(article) { let ids = [...BASE_ARTICLE_TAB_IDS]; - if (article?.isFather) { + if (article?.isParent) { ids = ids.filter((id) => id !== ARTICLE_TAB.supplier); } return [...ids, ARTICLE_TAB.settings]; diff --git a/src/components/article/boxes/ArticleViewLists.jsx b/src/components/article/boxes/ArticleViewLists.jsx index 7b93df5..a7aa5df 100644 --- a/src/components/article/boxes/ArticleViewLists.jsx +++ b/src/components/article/boxes/ArticleViewLists.jsx @@ -61,10 +61,10 @@ export default class ArticleViewLists extends PureComponent { // Active sub-tab for compound article: 'stueckliste' | 'options' activeListTab, - // Creator mode for standard article: null | 'father' | 'stueckliste' | 'options' + // Creator mode for standard article: null | 'parent' | 'stueckliste' | 'options' activeCreatorMode: null, - // Father mode state + // Parent mode state selectedAttributeId: '', selectedChildValueId: '', newChildText: '', @@ -117,8 +117,8 @@ export default class ArticleViewLists extends PureComponent { void this.loadAssignedAttributes(this.props.article.sku); } - if (this.props.article?.fatherSku) { - void artikelAutomat.actions.loadArticle(this.props.article.fatherSku); + if (this.props.article?.parentSku) { + void artikelAutomat.actions.loadArticle(this.props.article.parentSku); } void this.loadAllArticles(); @@ -129,8 +129,8 @@ export default class ArticleViewLists extends PureComponent { if (this.props.article?.sku) { void this.loadAssignedAttributes(this.props.article.sku); } - if (this.props.article?.fatherSku) { - void artikelAutomat.actions.loadArticle(this.props.article.fatherSku); + if (this.props.article?.parentSku) { + void artikelAutomat.actions.loadArticle(this.props.article.parentSku); } const nextArticle = this.props.article; const hasStueckliste = Boolean( @@ -205,9 +205,9 @@ export default class ArticleViewLists extends PureComponent { } } - // ── Father Actions ────────────────────────────────────────────────────────── + // ── Parent Actions ────────────────────────────────────────────────────────── - handleEnableFather = async () => { + handleEnableParent = async () => { const { article } = this.props; const { selectedAttributeId, assignedAttributes, busy } = this.state; if (busy || !article?.sku || !selectedAttributeId) return; @@ -232,8 +232,8 @@ export default class ArticleViewLists extends PureComponent { this.setState({ busy: true }); try { const api = this.getApi(); - const res = await api.toggleFather(article.sku, { - isFather: true, + const res = await api.toggleParent(article.sku, { + isParent: true, attributeNameId: Number(selectedAttributeId), }); if (this._cancelled) return; @@ -253,7 +253,7 @@ export default class ArticleViewLists extends PureComponent { } }; - handleDisableFather = async () => { + handleDisableParent = async () => { const { article } = this.props; const { busy } = this.state; if (busy || !article?.sku) return; @@ -261,7 +261,7 @@ export default class ArticleViewLists extends PureComponent { this.setState({ busy: true }); try { const api = this.getApi(); - const res = await api.toggleFather(article.sku, { isFather: false }); + const res = await api.toggleParent(article.sku, { isParent: false }); if (this._cancelled) return; if (res?.article) { @@ -365,12 +365,12 @@ export default class ArticleViewLists extends PureComponent { return; } - const isTargetFather = Boolean( - stuecklisteTargetArticle.isFather || - stuecklisteTargetArticle.is_father || - getArticle(stuecklisteTargetArticle.sku)?.isFather + const isTargetParent = Boolean( + stuecklisteTargetArticle.isParent || + stuecklisteTargetArticle.is_parent || + getArticle(stuecklisteTargetArticle.sku)?.isParent ); - if (isTargetFather) { + if (isTargetParent) { enqueueSnackbar('Ein Vaterartikel kann nicht als Komponente einer Stückliste hinzugefügt werden.', { variant: 'error' }); return; } @@ -489,12 +489,12 @@ export default class ArticleViewLists extends PureComponent { return; } - const isTargetFather = Boolean( - optionTargetArticle.isFather || - optionTargetArticle.is_father || - getArticle(optionTargetArticle.sku)?.isFather + const isTargetParent = Boolean( + optionTargetArticle.isParent || + optionTargetArticle.is_parent || + getArticle(optionTargetArticle.sku)?.isParent ); - if (isTargetFather) { + if (isTargetParent) { enqueueSnackbar('Ein Vaterartikel kann nicht als Option hinzugefügt werden.', { variant: 'error' }); return; } @@ -1081,33 +1081,33 @@ export default class ArticleViewLists extends PureComponent { ); } - renderChildView(fatherSku, fatherArticle, attributes, assignedAttributes) { + renderChildView(parentSku, parentArticle, attributes, assignedAttributes) { const { article: articleData } = this.props; - const fatherAttrName = - fatherArticle?.fatherAttributeName || - (fatherArticle?.fatherAttributeNameId - ? attributes.find((a) => a.id === fatherArticle.fatherAttributeNameId)?.name + const parentAttrName = + parentArticle?.parentAttributeName || + (parentArticle?.parentAttributeNameId + ? attributes.find((a) => a.id === parentArticle.parentAttributeNameId)?.name : null) || 'Varianten'; - const selfInFather = (fatherArticle?.children || []).find( + const selfInParent = (parentArticle?.children || []).find( (c) => c.sku === articleData.sku || c.id === articleData.id ); const assignedMatch = (assignedAttributes || []).find( (a) => - (fatherArticle?.fatherAttributeNameId != null && - Number(a.attributeId) === Number(fatherArticle.fatherAttributeNameId)) || + (parentArticle?.parentAttributeNameId != null && + Number(a.attributeId) === Number(parentArticle.parentAttributeNameId)) || (a.attributeName && - fatherAttrName && - String(a.attributeName).toLowerCase() === String(fatherAttrName).toLowerCase()) + parentAttrName && + String(a.attributeName).toLowerCase() === String(parentAttrName).toLowerCase()) ); const variationValue = - selfInFather?.attributeValue || + selfInParent?.attributeValue || assignedMatch?.attributeValue || null; - const siblings = (fatherArticle?.children || []).filter( + const siblings = (parentArticle?.children || []).filter( (c) => c.sku !== articleData.sku && c.id !== articleData.id ); @@ -1130,7 +1130,7 @@ export default class ArticleViewLists extends PureComponent { für - {fatherArticle?.name || fatherSku} + {parentArticle?.name || parentSku} - {fatherSku} + {parentSku} @@ -1316,7 +1316,7 @@ export default class ArticleViewLists extends PureComponent { const { busy, activeCreatorMode, selectedAttributeId } = this.state; return ( - {activeCreatorMode === 'father' ? ( + {activeCreatorMode === 'parent' ? ( @@ -1367,7 +1367,7 @@ export default class ArticleViewLists extends PureComponent { assignedAttributeIds.has(Number(selectedAttributeId)) || selectableAttributes.length === 0 } - onClick={this.handleEnableFather} + onClick={this.handleEnableParent} sx={{ height: 38, flexShrink: 0 }} > {busy ? : 'Aktivieren'} @@ -1381,7 +1381,7 @@ export default class ArticleViewLists extends PureComponent { variant="outlined" color="primary" startIcon={} - onClick={() => this.setState({ activeCreatorMode: 'father' })} + onClick={() => this.setState({ activeCreatorMode: 'parent' })} > Als Vaterartikel aktivieren @@ -1428,8 +1428,8 @@ export default class ArticleViewLists extends PureComponent { if (!articleData) return null; - const isChild = articleData.fatherArticleId != null || Boolean(articleData.fatherSku); - const isFather = Boolean(articleData.isFather); + const isChild = articleData.parentArticleId != null || Boolean(articleData.parentSku); + const isParent = Boolean(articleData.isParent); const children = Array.isArray(articleData.children) ? articleData.children : []; const stuecklisteItems = Array.isArray(articleData.articlelist) ? articleData.articlelist : []; @@ -1438,7 +1438,7 @@ export default class ArticleViewLists extends PureComponent { const optionsItems = Array.isArray(articleData.options) ? articleData.options : []; const hasOptions = articleData.optionslistId != null || optionsItems.length > 0; - // Attributes assigned to this article that cannot be selected for father mode + // Attributes assigned to this article that cannot be selected for parent mode const assignedAttributeIds = new Set( (assignedAttributes || []).map((a) => a.attributeId) ); @@ -1446,31 +1446,31 @@ export default class ArticleViewLists extends PureComponent { (a) => !assignedAttributeIds.has(a.id) ); - // Filter available articles for Autocomplete (cannot select self, and cannot select father articles) + // Filter available articles for Autocomplete (cannot select self, and cannot select parent articles) const selectableOtherArticles = allArticles.filter( - (a) => a.sku !== articleData.sku && !a.isFather && !a.is_father + (a) => a.sku !== articleData.sku && !a.isParent && !a.is_parent ); - // Find father attribute definition & values - const fatherAttrDef = isFather - ? attributes.find((a) => a.id === articleData.fatherAttributeNameId) + // Find parent attribute definition & values + const parentAttrDef = isParent + ? attributes.find((a) => a.id === articleData.parentAttributeNameId) : null; - const allFatherValues = fatherAttrDef?.values || []; + const allParentValues = parentAttrDef?.values || []; const assignedValueIds = new Set( children.map((c) => c.attributeValueId).filter((id) => id != null) ); - const availableFatherValues = allFatherValues.filter((v) => !assignedValueIds.has(v.id)); + const availableParentValues = allParentValues.filter((v) => !assignedValueIds.has(v.id)); - const fatherSku = articleData.fatherSku; - const fatherArticle = fatherSku ? getArticle(fatherSku) : null; + const parentSku = articleData.parentSku; + const parentArticle = parentSku ? getArticle(parentSku) : null; return ( - {/* ── Mode 1: Father Article ──────────────────────────────────────── */} - {isFather ? ( + {/* ── Mode 1: Parent Article ──────────────────────────────────────── */} + {isParent ? ( - {isChild && fatherSku ? ( + {isChild && parentSku ? ( @@ -1478,7 +1478,7 @@ export default class ArticleViewLists extends PureComponent { - {fatherSku} + {parentSku} ) : null} @@ -1499,7 +1499,7 @@ export default class ArticleViewLists extends PureComponent { Variantsvater für @@ -1524,7 +1524,7 @@ export default class ArticleViewLists extends PureComponent { {/* Add Child Variant Row */} - {isCustomChildValue || availableFatherValues.length === 0 ? ( + {isCustomChildValue || availableParentValues.length === 0 ? ( Variante wählen... - {availableFatherValues.map((v) => ( + {availableParentValues.map((v) => ( {v.value} @@ -1566,7 +1566,7 @@ export default class ArticleViewLists extends PureComponent { color="primary" disabled={ busy || - (isCustomChildValue || availableFatherValues.length === 0 + (isCustomChildValue || availableParentValues.length === 0 ? !newChildText.trim() : !selectedChildValueId) } @@ -1590,7 +1590,7 @@ export default class ArticleViewLists extends PureComponent { {/* Toggle custom value / predefined value if values exist */} - {allFatherValues.length > 0 && availableFatherValues.length > 0 ? ( + {allParentValues.length > 0 && availableParentValues.length > 0 ? ( - ) : isChild && fatherSku ? ( + ) : isChild && parentSku ? ( /* ── Mode 2: Child Variant Article ───────────────────────────────── */ {this.renderChildView( - fatherSku, - fatherArticle, + parentSku, + parentArticle, attributes, assignedAttributes )} diff --git a/src/components/article/boxes/ArticleViewPrice.jsx b/src/components/article/boxes/ArticleViewPrice.jsx index f1bf40c..37571e3 100644 --- a/src/components/article/boxes/ArticleViewPrice.jsx +++ b/src/components/article/boxes/ArticleViewPrice.jsx @@ -476,7 +476,7 @@ export default class ArticleViewPrice extends PureComponent { render() { const { article: articleData } = this.props; - if (articleData?.isFather) { + if (articleData?.isParent) { return ( 0 || articleData?.articlelist?.length > 0 ); const isTrackStock = - !isFather && !isStueckliste && articleData?.track_stock !== false && articleData?.track_stock !== 0; + !isParent && !isStueckliste && articleData?.track_stock !== false && articleData?.track_stock !== 0; const rawWarehouses = Array.isArray(articleData?.warehouses) ? articleData.warehouses @@ -66,9 +66,9 @@ export default class ArticleViewStock extends PureComponent { - {isFather ? ( + {isParent ? ( - {!isFather && !isStueckliste ? ( + {!isParent && !isStueckliste ? ( { - if (!isFather) return true; - if (fatherAttrId != null && Number(a.id) === Number(fatherAttrId)) return false; + if (!isParent) return true; + if (parentAttrId != null && Number(a.id) === Number(parentAttrId)) return false; if ( - fatherAttrName && - String(a.name).trim().toLowerCase() === String(fatherAttrName).trim().toLowerCase() + parentAttrName && + String(a.name).trim().toLowerCase() === String(parentAttrName).trim().toLowerCase() ) { return false; } @@ -231,7 +231,7 @@ export default class ArticleAttributesTab extends PureComponent { if (effectiveKey.startsWith('attr-')) { const attrId = Number(effectiveKey.replace('attr-', '')); const targetAttr = selectableAttributes.find((a) => a.id === attrId); - if (!targetAttr || this.isFatherVariantAttribute(targetAttr)) return; + if (!targetAttr || this.isParentVariantAttribute(targetAttr)) return; const effectiveItemId = targetAttr.values?.some((v) => v.id === selectedItemId) ? selectedItemId @@ -411,7 +411,7 @@ export default class ArticleAttributesTab extends PureComponent { merkmalOptions.find((m) => m.key === selectedMerkmalKey) ?? merkmalOptions[0] ?? null; const currentMerkmalKey = selectedOption?.key ?? ''; const isAttr = selectedOption?.type === 'attr'; - const isVariantBlocked = isAttr && this.isFatherVariantAttribute(selectedOption); + const isVariantBlocked = isAttr && this.isParentVariantAttribute(selectedOption); const availableItems = selectedOption?.values || []; const currentItemId = isAttr ? (availableItems.some((v) => v.id === selectedItemId) diff --git a/src/components/article/tabs/ArticleSupplierTab.jsx b/src/components/article/tabs/ArticleSupplierTab.jsx index cd55974..11547fc 100644 --- a/src/components/article/tabs/ArticleSupplierTab.jsx +++ b/src/components/article/tabs/ArticleSupplierTab.jsx @@ -207,7 +207,7 @@ export default class ArticleSupplierTab extends PureComponent { busyId, } = this.state; - if (this.props.article?.isFather) { + if (this.props.article?.isParent) { return ( !a.isFather && !a.is_father); + const bookableArticles = articles.filter((a) => !a.isParent && !a.is_parent); const trimmed = query.trim().toLowerCase(); const filtered = trimmed ? bookableArticles.filter((a) => { diff --git a/src/components/tiles/ArticleTile.jsx b/src/components/tiles/ArticleTile.jsx index 3dfabad..77e575e 100644 --- a/src/components/tiles/ArticleTile.jsx +++ b/src/components/tiles/ArticleTile.jsx @@ -312,9 +312,9 @@ export class ArticleTile extends Tile { renderExtra() { const { article } = this.props; if (!article) return null; - const isFather = Boolean(article.isFather || article.is_father); + const isParent = Boolean(article.isParent || article.is_parent); const childCount = Number(article.childCount) || 0; - if (!isFather && childCount <= 0) return null; + if (!isParent && childCount <= 0) return null; return (