u
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
344
src/automats/categoryViewAutomat.js
Normal file
344
src/automats/categoryViewAutomat.js
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
</Typography>
|
||||
<Chip
|
||||
label={fatherAttrName}
|
||||
label={parentAttrName}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
@@ -1163,7 +1163,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={artikelDetailPath(fatherSku)}
|
||||
to={artikelDetailPath(parentSku)}
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
@@ -1196,7 +1196,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
transition: 'color 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{fatherArticle?.name || fatherSku}
|
||||
{parentArticle?.name || parentSku}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.2 }}>
|
||||
<Typography
|
||||
@@ -1210,7 +1210,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{fatherSku}
|
||||
{parentSku}
|
||||
</Typography>
|
||||
<OpenInNewIcon sx={{ fontSize: 13, color: 'primary.main', opacity: 0.8 }} />
|
||||
</Box>
|
||||
@@ -1316,7 +1316,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
const { busy, activeCreatorMode, selectedAttributeId } = this.state;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{activeCreatorMode === 'father' ? (
|
||||
{activeCreatorMode === 'parent' ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
@@ -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 ? <CircularProgress size={14} color="inherit" /> : 'Aktivieren'}
|
||||
@@ -1381,7 +1381,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<AccountTreeIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={() => this.setState({ activeCreatorMode: 'father' })}
|
||||
onClick={() => this.setState({ activeCreatorMode: 'parent' })}
|
||||
>
|
||||
Als Vaterartikel aktivieren
|
||||
</Button>
|
||||
@@ -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 (
|
||||
<PanelTile wide={1} minHeight={200} color="#fbcfe8">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 1 }}>
|
||||
{/* ── Mode 1: Father Article ──────────────────────────────────────── */}
|
||||
{isFather ? (
|
||||
{/* ── Mode 1: Parent Article ──────────────────────────────────────── */}
|
||||
{isParent ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, flex: 1, minHeight: 0 }}>
|
||||
{isChild && fatherSku ? (
|
||||
{isChild && parentSku ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||
<AccountTreeIcon sx={{ color: 'text.secondary', fontSize: 16 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@@ -1478,7 +1478,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
</Typography>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={artikelDetailPath(fatherSku)}
|
||||
to={artikelDetailPath(parentSku)}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.82rem',
|
||||
@@ -1488,7 +1488,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{fatherSku}
|
||||
{parentSku}
|
||||
</Link>
|
||||
</Box>
|
||||
) : null}
|
||||
@@ -1499,7 +1499,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
Variantsvater für
|
||||
</Typography>
|
||||
<Chip
|
||||
label={articleData.fatherAttributeName || fatherAttrDef?.name || 'Varianten'}
|
||||
label={articleData.parentAttributeName || parentAttrDef?.name || 'Varianten'}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 600 }}
|
||||
@@ -1511,7 +1511,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={this.handleDisableFather}
|
||||
onClick={this.handleDisableParent}
|
||||
disabled={busy}
|
||||
sx={{ p: 0.4, color: 'text.secondary', '&:hover': { color: 'error.main' } }}
|
||||
>
|
||||
@@ -1524,7 +1524,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
|
||||
{/* Add Child Variant Row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||
{isCustomChildValue || availableFatherValues.length === 0 ? (
|
||||
{isCustomChildValue || availableParentValues.length === 0 ? (
|
||||
<TextInput
|
||||
label="Neuer Wert"
|
||||
placeholder="Neuer Wert..."
|
||||
@@ -1551,7 +1551,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
<MenuItem value="" disabled>
|
||||
<em>Variante wählen...</em>
|
||||
</MenuItem>
|
||||
{availableFatherValues.map((v) => (
|
||||
{availableParentValues.map((v) => (
|
||||
<MenuItem key={v.id} value={v.id}>
|
||||
{v.value}
|
||||
</MenuItem>
|
||||
@@ -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 {
|
||||
</Box>
|
||||
|
||||
{/* Toggle custom value / predefined value if values exist */}
|
||||
{allFatherValues.length > 0 && availableFatherValues.length > 0 ? (
|
||||
{allParentValues.length > 0 && availableParentValues.length > 0 ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: -0.25 }}>
|
||||
<Link
|
||||
component="button"
|
||||
@@ -1721,12 +1721,12 @@ export default class ArticleViewLists extends PureComponent {
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : isChild && fatherSku ? (
|
||||
) : isChild && parentSku ? (
|
||||
/* ── Mode 2: Child Variant Article ───────────────────────────────── */
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}>
|
||||
{this.renderChildView(
|
||||
fatherSku,
|
||||
fatherArticle,
|
||||
parentSku,
|
||||
parentArticle,
|
||||
attributes,
|
||||
assignedAttributes
|
||||
)}
|
||||
|
||||
@@ -476,7 +476,7 @@ export default class ArticleViewPrice extends PureComponent {
|
||||
render() {
|
||||
const { article: articleData } = this.props;
|
||||
|
||||
if (articleData?.isFather) {
|
||||
if (articleData?.isParent) {
|
||||
return (
|
||||
<PanelTile
|
||||
wide={1}
|
||||
|
||||
@@ -31,14 +31,14 @@ export default class ArticleViewStock extends PureComponent {
|
||||
render() {
|
||||
const { article: articleData } = this.props;
|
||||
const { adjustOpen } = this.state;
|
||||
const isFather = Boolean(articleData?.isFather);
|
||||
const isParent = Boolean(articleData?.isParent);
|
||||
const isStueckliste = Boolean(
|
||||
articleData?.articlelistId != null ||
|
||||
(articleData?.articleListCount ?? 0) > 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 {
|
||||
<PanelTile
|
||||
wide={1}
|
||||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||
color={isFather ? '#e2e8f0' : isStueckliste ? '#cffafe' : '#bbf7d0'}
|
||||
color={isParent ? '#e2e8f0' : isStueckliste ? '#cffafe' : '#bbf7d0'}
|
||||
action={
|
||||
isFather || isStueckliste
|
||||
isParent || isStueckliste
|
||||
? null
|
||||
: {
|
||||
label: 'Bestand anpassen',
|
||||
@@ -100,7 +100,7 @@ export default class ArticleViewStock extends PureComponent {
|
||||
Bestand
|
||||
</Typography>
|
||||
|
||||
{isFather ? (
|
||||
{isParent ? (
|
||||
<Box sx={{ my: 0.75 }}>
|
||||
<Typography
|
||||
component="div"
|
||||
@@ -317,7 +317,7 @@ export default class ArticleViewStock extends PureComponent {
|
||||
</Box>
|
||||
</PanelTile>
|
||||
|
||||
{!isFather && !isStueckliste ? (
|
||||
{!isParent && !isStueckliste ? (
|
||||
<ArticleStockAdjustDialog
|
||||
open={adjustOpen}
|
||||
onClose={this.handleCloseAdjust}
|
||||
|
||||
@@ -20,15 +20,15 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
return this.props.article || getArticle(this.props.sku);
|
||||
}
|
||||
|
||||
isFatherVariantAttribute(attr) {
|
||||
isParentVariantAttribute(attr) {
|
||||
const article = this.getArticle();
|
||||
const isFather = Boolean(article?.isFather ?? article?.is_father);
|
||||
if (!isFather || !attr) return false;
|
||||
const fatherAttrId = article?.fatherAttributeNameId ?? article?.attributeNameId ?? null;
|
||||
if (fatherAttrId != null && Number(attr.id) === Number(fatherAttrId)) return true;
|
||||
const isParent = Boolean(article?.isParent ?? article?.is_parent);
|
||||
if (!isParent || !attr) return false;
|
||||
const parentAttrId = article?.parentAttributeNameId ?? article?.attributeNameId ?? null;
|
||||
if (parentAttrId != null && Number(attr.id) === Number(parentAttrId)) return true;
|
||||
if (
|
||||
article?.fatherAttributeName &&
|
||||
String(attr.name).trim().toLowerCase() === String(article.fatherAttributeName).trim().toLowerCase()
|
||||
article?.parentAttributeName &&
|
||||
String(attr.name).trim().toLowerCase() === String(article.parentAttributeName).trim().toLowerCase()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -41,16 +41,16 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
const initialProperties = selectProperties() || [];
|
||||
|
||||
const article = props.article || getArticle(props.sku);
|
||||
const isFather = Boolean(article?.isFather ?? article?.is_father);
|
||||
const fatherAttrId = article?.fatherAttributeNameId ?? article?.attributeNameId ?? null;
|
||||
const fatherAttrName = article?.fatherAttributeName;
|
||||
const isParent = Boolean(article?.isParent ?? article?.is_parent);
|
||||
const parentAttrId = article?.parentAttributeNameId ?? article?.attributeNameId ?? null;
|
||||
const parentAttrName = article?.parentAttributeName;
|
||||
|
||||
const selectableAttrs = initialAttributes.filter((a) => {
|
||||
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)
|
||||
|
||||
@@ -207,7 +207,7 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
busyId,
|
||||
} = this.state;
|
||||
|
||||
if (this.props.article?.isFather) {
|
||||
if (this.props.article?.isParent) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -89,7 +89,7 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
const { onAssign, line } = this.props;
|
||||
if (!onAssign || !article?.sku) return;
|
||||
|
||||
if (article.isFather || article.is_father) {
|
||||
if (article.isParent || article.is_parent) {
|
||||
this.setState({ error: 'Ein Vaterartikel kann nicht auf Belege gebucht werden.' });
|
||||
return;
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
const { open, onClose, line } = this.props;
|
||||
const { query, articles, loading, error, assigningSku } = this.state;
|
||||
|
||||
const bookableArticles = articles.filter((a) => !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) => {
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
Reference in New Issue
Block a user